prodlint 0.7.2 → 0.8.1

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.1
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,}['"]/ },
@@ -2259,7 +2260,7 @@ var codebaseConsistencyRule = {
2259
2260
  // src/rules/dead-exports.ts
2260
2261
  function isEntryPoint(relativePath) {
2261
2262
  const name = relativePath.split("/").pop() ?? "";
2262
- return /^(page|layout|loading|error|not-found|route|middleware|instrumentation)\.(tsx?|jsx?)$/.test(name) || /^index\.(tsx?|jsx?)$/.test(name) || name === "global-error.tsx" || name === "global-error.jsx";
2263
+ return /^(page|layout|loading|error|not-found|route|middleware|instrumentation|opengraph-image|twitter-image|icon|apple-icon|sitemap|robots|manifest)\.(tsx?|jsx?)$/.test(name) || /^index\.(tsx?|jsx?)$/.test(name) || name === "global-error.tsx" || name === "global-error.jsx";
2263
2264
  }
2264
2265
  var THRESHOLD = 5;
2265
2266
  var deadExportsRule = {
@@ -2282,8 +2283,31 @@ var deadExportsRule = {
2282
2283
  const importedFiles = /* @__PURE__ */ new Set();
2283
2284
  for (const file of sourceFiles) {
2284
2285
  if (isEntryPoint(file.relativePath)) continue;
2286
+ let inTemplateLiteral = false;
2285
2287
  for (let i = 0; i < file.lines.length; i++) {
2286
2288
  const line = file.lines[i];
2289
+ let backtickCount = 0;
2290
+ for (let j = 0; j < line.length; j++) {
2291
+ if (line[j] === "\\") {
2292
+ j++;
2293
+ continue;
2294
+ }
2295
+ if (line[j] === "`") backtickCount++;
2296
+ }
2297
+ if (backtickCount % 2 === 1) inTemplateLiteral = !inTemplateLiteral;
2298
+ if (inTemplateLiteral && backtickCount % 2 === 0) continue;
2299
+ const exportIdx = line.indexOf("export");
2300
+ if (exportIdx >= 0) {
2301
+ let inStr = false;
2302
+ for (let j = 0; j < exportIdx; j++) {
2303
+ if (line[j] === "\\") {
2304
+ j++;
2305
+ continue;
2306
+ }
2307
+ if (line[j] === "'" || line[j] === '"' || line[j] === "`") inStr = !inStr;
2308
+ }
2309
+ if (inStr) continue;
2310
+ }
2287
2311
  let match;
2288
2312
  const namedRe = /export\s+(?:async\s+)?(?:function|const|let|class|enum)\s+(\w+)/g;
2289
2313
  while ((match = namedRe.exec(line)) !== null) {
@@ -3852,6 +3876,8 @@ var hydrationMismatchRule = {
3852
3876
  if (isClientComponent(file.content)) return [];
3853
3877
  if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
3854
3878
  if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
3879
+ if (/(?:^|\/)(?:src\/)?app\/(?:lib|utils|helpers|server|actions)\//.test(file.relativePath)) return [];
3880
+ if (/\.[jt]s$/.test(file.relativePath) && !/<[A-Z]/.test(file.content)) return [];
3855
3881
  const findings = [];
3856
3882
  let useEffectRanges = [];
3857
3883
  if (file.ast) {
@@ -4484,6 +4510,295 @@ function renderBar(score) {
4484
4510
  const color = scoreColor(score);
4485
4511
  return color("\u2588".repeat(filled)) + pc.dim("\u2591".repeat(empty));
4486
4512
  }
4513
+ var STATUS_ICONS = {
4514
+ pass: pc.green,
4515
+ fail: pc.red,
4516
+ warn: pc.yellow,
4517
+ info: pc.blue
4518
+ };
4519
+ var STATUS_SYMBOLS = {
4520
+ pass: "\u2713",
4521
+ fail: "\u2717",
4522
+ warn: "!",
4523
+ info: "i"
4524
+ };
4525
+ function reportWebPretty(result) {
4526
+ const lines = [];
4527
+ lines.push("");
4528
+ lines.push(pc.bold(" prodlint site score"));
4529
+ lines.push(pc.dim(` ${result.domain} \xB7 ${result.summary.totalChecks} checks`));
4530
+ lines.push("");
4531
+ const overallColor = scoreColor(result.overallScore);
4532
+ const bar = renderBar(result.overallScore);
4533
+ lines.push(` ${pc.bold("Score:")} ${overallColor(pc.bold(`${result.overallScore}`))} ${overallColor(result.grade)} ${bar}`);
4534
+ lines.push("");
4535
+ const order = { fail: 0, warn: 1, info: 2, pass: 3 };
4536
+ const sorted = [...result.checks].sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9));
4537
+ for (const check of sorted) {
4538
+ const color = STATUS_ICONS[check.status] ?? pc.dim;
4539
+ const symbol = STATUS_SYMBOLS[check.status] ?? "?";
4540
+ const pts = `${check.points}/${check.maxPoints}`;
4541
+ lines.push(` ${color(symbol)} ${check.name.padEnd(28)} ${pc.dim(pts.padStart(6))} ${pc.dim(check.details || "")}`);
4542
+ }
4543
+ lines.push("");
4544
+ const parts = [];
4545
+ if (result.summary.passed > 0) parts.push(pc.green(`${result.summary.passed} passed`));
4546
+ if (result.summary.failed > 0) parts.push(pc.red(`${result.summary.failed} failed`));
4547
+ if (result.summary.warnings > 0) parts.push(pc.yellow(`${result.summary.warnings} warnings`));
4548
+ lines.push(` ${parts.join(pc.dim(" \xB7 "))}`);
4549
+ lines.push("");
4550
+ lines.push(pc.dim(` Full results: https://prodlint.com/score?url=${encodeURIComponent(result.domain)}`));
4551
+ lines.push("");
4552
+ return lines.join("\n");
4553
+ }
4554
+
4555
+ // src/web-scanner/checks.ts
4556
+ function make(id, name, description, maxPoints, severity, status, details) {
4557
+ return {
4558
+ id,
4559
+ name,
4560
+ description,
4561
+ status,
4562
+ severity,
4563
+ details,
4564
+ points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
4565
+ maxPoints
4566
+ };
4567
+ }
4568
+ function checkRobotsTxt(ctx) {
4569
+ if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
4570
+ return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
4571
+ }
4572
+ function checkRobotsAiDirectives(ctx) {
4573
+ 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.");
4574
+ const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
4575
+ const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
4576
+ 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.");
4577
+ 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(", ")}.`);
4578
+ 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(", ")}.`);
4579
+ }
4580
+ function checkContentUsage(ctx) {
4581
+ const hasHeader = ctx.headers["content-usage"] != null;
4582
+ const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
4583
+ if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
4584
+ 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.");
4585
+ }
4586
+ function checkLlmsTxt(ctx) {
4587
+ if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
4588
+ const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
4589
+ 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.");
4590
+ return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
4591
+ }
4592
+ function checkTdmRep(ctx) {
4593
+ const hasWK = ctx.tdmRep != null;
4594
+ const hasHeader = ctx.headers["tdm-reservation"] != null;
4595
+ if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
4596
+ 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"]}`);
4597
+ }
4598
+ function checkAiDisclosure(ctx) {
4599
+ 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.");
4600
+ return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4601
+ }
4602
+ function checkAgentCard(ctx) {
4603
+ if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
4604
+ try {
4605
+ const card = JSON.parse(ctx.agentCard);
4606
+ 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.");
4607
+ 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).`);
4608
+ } catch {
4609
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
4610
+ }
4611
+ }
4612
+ function checkAiTxt(ctx) {
4613
+ 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.");
4614
+ const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4615
+ 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.");
4616
+ return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
4617
+ }
4618
+ function checkWebMCP(ctx) {
4619
+ if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
4620
+ const hasToolname = /toolname=/i.test(ctx.html);
4621
+ const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
4622
+ if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
4623
+ const count = (ctx.html.match(/toolname=/gi) || []).length;
4624
+ return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4625
+ }
4626
+ function checkStructuredData(ctx) {
4627
+ if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
4628
+ const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
4629
+ const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
4630
+ if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
4631
+ 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).`);
4632
+ }
4633
+ function checkOpenGraph(ctx) {
4634
+ if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
4635
+ 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)];
4636
+ const passed = checks.filter(Boolean).length;
4637
+ if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
4638
+ if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
4639
+ return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
4640
+ }
4641
+ function checkSitemap(ctx) {
4642
+ if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
4643
+ const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
4644
+ if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
4645
+ if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
4646
+ return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
4647
+ }
4648
+ function checkHttpSignatures(ctx) {
4649
+ const hasDirectory = ctx.httpSigDirectory != null;
4650
+ const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
4651
+ const hasSignatureAgent = ctx.headers["signature-agent"] != null;
4652
+ 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.");
4653
+ 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.");
4654
+ 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.");
4655
+ }
4656
+ function checkPageSpeed(ctx) {
4657
+ if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
4658
+ 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.`);
4659
+ 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.`);
4660
+ return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4661
+ }
4662
+ var allChecks = [
4663
+ checkRobotsTxt,
4664
+ checkRobotsAiDirectives,
4665
+ checkContentUsage,
4666
+ checkLlmsTxt,
4667
+ checkAiTxt,
4668
+ checkTdmRep,
4669
+ checkAiDisclosure,
4670
+ checkAgentCard,
4671
+ checkWebMCP,
4672
+ checkHttpSignatures,
4673
+ checkStructuredData,
4674
+ checkOpenGraph,
4675
+ checkSitemap,
4676
+ checkPageSpeed
4677
+ ];
4678
+
4679
+ // src/web-scanner/index.ts
4680
+ function getGrade(score) {
4681
+ if (score >= 80) return "A";
4682
+ if (score >= 60) return "B";
4683
+ if (score >= 40) return "C";
4684
+ if (score >= 20) return "D";
4685
+ return "F";
4686
+ }
4687
+ function getDomain(url) {
4688
+ try {
4689
+ return new URL(url).hostname;
4690
+ } catch {
4691
+ return url;
4692
+ }
4693
+ }
4694
+ var PRIVATE_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "metadata.google.internal"]);
4695
+ function isPrivateHost(hostname) {
4696
+ if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
4697
+ const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
4698
+ if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
4699
+ const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4700
+ if (ipv4) {
4701
+ const [, a, b] = ipv4.map(Number);
4702
+ if (a === 10 || a === 127 || a === 0) return true;
4703
+ if (a === 172 && b >= 16 && b <= 31) return true;
4704
+ if (a === 192 && b === 168) return true;
4705
+ if (a === 169 && b === 254) return true;
4706
+ if (a === 100 && b >= 64 && b <= 127) return true;
4707
+ if (a === 198 && (b === 18 || b === 19)) return true;
4708
+ }
4709
+ return false;
4710
+ }
4711
+ function validateRedirectUrl(responseUrl) {
4712
+ try {
4713
+ const parsed = new URL(responseUrl);
4714
+ return !isPrivateHost(parsed.hostname);
4715
+ } catch {
4716
+ return false;
4717
+ }
4718
+ }
4719
+ async function fetchText(url, timeout = 8e3) {
4720
+ try {
4721
+ const c = new AbortController();
4722
+ const t = setTimeout(() => c.abort(), timeout);
4723
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4724
+ clearTimeout(t);
4725
+ if (r.url && !validateRedirectUrl(r.url)) return null;
4726
+ return r.ok ? await r.text() : null;
4727
+ } catch {
4728
+ return null;
4729
+ }
4730
+ }
4731
+ async function fetchHeaders(url, timeout = 8e3) {
4732
+ try {
4733
+ const c = new AbortController();
4734
+ const t = setTimeout(() => c.abort(), timeout);
4735
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4736
+ clearTimeout(t);
4737
+ if (r.url && !validateRedirectUrl(r.url)) return {};
4738
+ const h = {};
4739
+ r.headers.forEach((v, k) => {
4740
+ h[k.toLowerCase()] = v;
4741
+ });
4742
+ return h;
4743
+ } catch {
4744
+ return {};
4745
+ }
4746
+ }
4747
+ async function fetchWithTiming(url, timeout = 15e3) {
4748
+ try {
4749
+ const c = new AbortController();
4750
+ const t = setTimeout(() => c.abort(), timeout);
4751
+ const start = Date.now();
4752
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4753
+ const html = await r.text();
4754
+ clearTimeout(t);
4755
+ if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
4756
+ return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
4757
+ } catch {
4758
+ return { html: null, loadTimeMs: null };
4759
+ }
4760
+ }
4761
+ function normalizeUrl(input) {
4762
+ let url = input.trim();
4763
+ if (!/^https?:\/\//i.test(url)) {
4764
+ url = `https://${url}`;
4765
+ }
4766
+ const parsed = new URL(url);
4767
+ return parsed.origin;
4768
+ }
4769
+ async function runWebScan(targetUrl) {
4770
+ const domain = getDomain(targetUrl);
4771
+ const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, pageData] = await Promise.all([
4772
+ fetchText(`${targetUrl}/robots.txt`),
4773
+ fetchText(`${targetUrl}/llms.txt`),
4774
+ fetchText(`${targetUrl}/ai.txt`),
4775
+ fetchText(`${targetUrl}/.well-known/tdmrep.json`),
4776
+ fetchText(`${targetUrl}/.well-known/agent-card.json`),
4777
+ fetchText(`${targetUrl}/sitemap.xml`),
4778
+ fetchText(`${targetUrl}/.well-known/http-message-signatures-directory`),
4779
+ fetchHeaders(targetUrl),
4780
+ fetchWithTiming(targetUrl)
4781
+ ]);
4782
+ const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, html: pageData.html, loadTimeMs: pageData.loadTimeMs };
4783
+ const checks = allChecks.map((fn) => fn(ctx));
4784
+ const totalPoints = checks.reduce((s, c) => s + c.points, 0);
4785
+ const maxPoints = checks.reduce((s, c) => s + c.maxPoints, 0);
4786
+ const overallScore = maxPoints > 0 ? Math.round(totalPoints / maxPoints * 100) : 0;
4787
+ return {
4788
+ url: targetUrl,
4789
+ domain,
4790
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
4791
+ overallScore,
4792
+ grade: getGrade(overallScore),
4793
+ checks,
4794
+ summary: {
4795
+ passed: checks.filter((c) => c.status === "pass").length,
4796
+ failed: checks.filter((c) => c.status === "fail").length,
4797
+ warnings: checks.filter((c) => c.status === "warn").length,
4798
+ totalChecks: checks.length
4799
+ }
4800
+ };
4801
+ }
4487
4802
 
4488
4803
  // src/cli.ts
4489
4804
  var SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
@@ -4495,6 +4810,7 @@ async function main() {
4495
4810
  ignore: { type: "string", multiple: true, default: [] },
4496
4811
  "min-severity": { type: "string", default: "info" },
4497
4812
  quiet: { type: "boolean", default: false },
4813
+ web: { type: "boolean", default: false },
4498
4814
  help: { type: "boolean", short: "h", default: false },
4499
4815
  version: { type: "boolean", short: "v", default: false }
4500
4816
  }
@@ -4507,6 +4823,15 @@ async function main() {
4507
4823
  console.log(getVersion());
4508
4824
  process.exit(0);
4509
4825
  }
4826
+ if (values.web) {
4827
+ const url = positionals[0];
4828
+ if (!url) {
4829
+ console.error("Usage: npx prodlint --web <url>");
4830
+ process.exit(2);
4831
+ }
4832
+ await runWebScan2(url, { json: values.json });
4833
+ return;
4834
+ }
4510
4835
  const targetPath = positionals[0] ?? ".";
4511
4836
  const minSeverity = values["min-severity"] ?? "info";
4512
4837
  const result = await scan({
@@ -4524,18 +4849,40 @@ async function main() {
4524
4849
  process.exit(1);
4525
4850
  }
4526
4851
  }
4852
+ async function runWebScan2(url, opts) {
4853
+ let normalizedUrl;
4854
+ try {
4855
+ normalizedUrl = normalizeUrl(url);
4856
+ } catch {
4857
+ console.error("Invalid URL:", url);
4858
+ process.exit(2);
4859
+ }
4860
+ const hostname = new URL(normalizedUrl).hostname;
4861
+ if (isPrivateHost(hostname)) {
4862
+ console.error("Cannot scan private/internal hosts.");
4863
+ process.exit(2);
4864
+ }
4865
+ const result = await runWebScan(normalizedUrl);
4866
+ if (opts.json) {
4867
+ console.log(JSON.stringify(result, null, 2));
4868
+ } else {
4869
+ console.log(reportWebPretty(result));
4870
+ }
4871
+ }
4527
4872
  function printHelp() {
4528
4873
  console.log(`
4529
4874
  prodlint - The linter for vibe-coded apps
4530
4875
 
4531
4876
  Usage:
4532
4877
  npx prodlint [path] [options]
4878
+ npx prodlint --web <url>
4533
4879
 
4534
4880
  Options:
4535
4881
  --json Output results as JSON
4536
4882
  --ignore <pattern> Glob patterns to ignore (can be repeated)
4537
4883
  --min-severity <level> Minimum severity to show: critical, warning, info (default: info)
4538
4884
  --quiet Suppress badge and summary
4885
+ --web Get your site's prodlint score (14 AI agent checks)
4539
4886
  -h, --help Show this help message
4540
4887
  -v, --version Show version
4541
4888
 
@@ -4546,6 +4893,8 @@ function printHelp() {
4546
4893
  npx prodlint --ignore "*.test" Ignore test files
4547
4894
  npx prodlint --min-severity warning Only warnings and criticals
4548
4895
  npx prodlint --quiet No badge output
4896
+ npx prodlint --web example.com Site score
4897
+ npx prodlint --web example.com --json Site score with JSON output
4549
4898
  `);
4550
4899
  }
4551
4900
  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 };