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/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,}['"]/ },
@@ -2263,7 +2264,7 @@ var codebaseConsistencyRule = {
2263
2264
  // src/rules/dead-exports.ts
2264
2265
  function isEntryPoint(relativePath) {
2265
2266
  const name = relativePath.split("/").pop() ?? "";
2266
- 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";
2267
+ 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";
2267
2268
  }
2268
2269
  var THRESHOLD = 5;
2269
2270
  var deadExportsRule = {
@@ -2286,8 +2287,31 @@ var deadExportsRule = {
2286
2287
  const importedFiles = /* @__PURE__ */ new Set();
2287
2288
  for (const file of sourceFiles) {
2288
2289
  if (isEntryPoint(file.relativePath)) continue;
2290
+ let inTemplateLiteral = false;
2289
2291
  for (let i = 0; i < file.lines.length; i++) {
2290
2292
  const line = file.lines[i];
2293
+ let backtickCount = 0;
2294
+ for (let j = 0; j < line.length; j++) {
2295
+ if (line[j] === "\\") {
2296
+ j++;
2297
+ continue;
2298
+ }
2299
+ if (line[j] === "`") backtickCount++;
2300
+ }
2301
+ if (backtickCount % 2 === 1) inTemplateLiteral = !inTemplateLiteral;
2302
+ if (inTemplateLiteral && backtickCount % 2 === 0) continue;
2303
+ const exportIdx = line.indexOf("export");
2304
+ if (exportIdx >= 0) {
2305
+ let inStr = false;
2306
+ for (let j = 0; j < exportIdx; j++) {
2307
+ if (line[j] === "\\") {
2308
+ j++;
2309
+ continue;
2310
+ }
2311
+ if (line[j] === "'" || line[j] === '"' || line[j] === "`") inStr = !inStr;
2312
+ }
2313
+ if (inStr) continue;
2314
+ }
2291
2315
  let match;
2292
2316
  const namedRe = /export\s+(?:async\s+)?(?:function|const|let|class|enum)\s+(\w+)/g;
2293
2317
  while ((match = namedRe.exec(line)) !== null) {
@@ -3856,6 +3880,8 @@ var hydrationMismatchRule = {
3856
3880
  if (isClientComponent(file.content)) return [];
3857
3881
  if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
3858
3882
  if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
3883
+ if (/(?:^|\/)(?:src\/)?app\/(?:lib|utils|helpers|server|actions)\//.test(file.relativePath)) return [];
3884
+ if (/\.[jt]s$/.test(file.relativePath) && !/<[A-Z]/.test(file.content)) return [];
3859
3885
  const findings = [];
3860
3886
  let useEffectRanges = [];
3861
3887
  if (file.ast) {
@@ -4395,6 +4421,254 @@ async function scan(options) {
4395
4421
  };
4396
4422
  }
4397
4423
 
4424
+ // src/web-scanner/checks.ts
4425
+ function make(id, name, description, maxPoints, severity, status, details) {
4426
+ return {
4427
+ id,
4428
+ name,
4429
+ description,
4430
+ status,
4431
+ severity,
4432
+ details,
4433
+ points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
4434
+ maxPoints
4435
+ };
4436
+ }
4437
+ function checkRobotsTxt(ctx) {
4438
+ if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
4439
+ return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
4440
+ }
4441
+ function checkRobotsAiDirectives(ctx) {
4442
+ 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.");
4443
+ const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
4444
+ const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
4445
+ 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.");
4446
+ 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(", ")}.`);
4447
+ 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(", ")}.`);
4448
+ }
4449
+ function checkContentUsage(ctx) {
4450
+ const hasHeader = ctx.headers["content-usage"] != null;
4451
+ const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
4452
+ if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
4453
+ 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.");
4454
+ }
4455
+ function checkLlmsTxt(ctx) {
4456
+ if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
4457
+ const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
4458
+ 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.");
4459
+ return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
4460
+ }
4461
+ function checkTdmRep(ctx) {
4462
+ const hasWK = ctx.tdmRep != null;
4463
+ const hasHeader = ctx.headers["tdm-reservation"] != null;
4464
+ if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
4465
+ 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"]}`);
4466
+ }
4467
+ function checkAiDisclosure(ctx) {
4468
+ 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.");
4469
+ return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4470
+ }
4471
+ function checkAgentCard(ctx) {
4472
+ if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
4473
+ try {
4474
+ const card = JSON.parse(ctx.agentCard);
4475
+ 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.");
4476
+ 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).`);
4477
+ } catch {
4478
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
4479
+ }
4480
+ }
4481
+ function checkAiTxt(ctx) {
4482
+ 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.");
4483
+ const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4484
+ 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.");
4485
+ return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
4486
+ }
4487
+ function checkWebMCP(ctx) {
4488
+ if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
4489
+ const hasToolname = /toolname=/i.test(ctx.html);
4490
+ const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
4491
+ if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
4492
+ const count = (ctx.html.match(/toolname=/gi) || []).length;
4493
+ return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4494
+ }
4495
+ function checkStructuredData(ctx) {
4496
+ if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
4497
+ const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
4498
+ const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
4499
+ if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
4500
+ 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).`);
4501
+ }
4502
+ function checkOpenGraph(ctx) {
4503
+ if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
4504
+ 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)];
4505
+ const passed = checks.filter(Boolean).length;
4506
+ if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
4507
+ if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
4508
+ return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
4509
+ }
4510
+ function checkSitemap(ctx) {
4511
+ if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
4512
+ const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
4513
+ if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
4514
+ if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
4515
+ return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
4516
+ }
4517
+ function checkHttpSignatures(ctx) {
4518
+ const hasDirectory = ctx.httpSigDirectory != null;
4519
+ const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
4520
+ const hasSignatureAgent = ctx.headers["signature-agent"] != null;
4521
+ 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.");
4522
+ 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.");
4523
+ 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.");
4524
+ }
4525
+ function checkPageSpeed(ctx) {
4526
+ if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
4527
+ 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.`);
4528
+ 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.`);
4529
+ return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4530
+ }
4531
+ var allChecks = [
4532
+ checkRobotsTxt,
4533
+ checkRobotsAiDirectives,
4534
+ checkContentUsage,
4535
+ checkLlmsTxt,
4536
+ checkAiTxt,
4537
+ checkTdmRep,
4538
+ checkAiDisclosure,
4539
+ checkAgentCard,
4540
+ checkWebMCP,
4541
+ checkHttpSignatures,
4542
+ checkStructuredData,
4543
+ checkOpenGraph,
4544
+ checkSitemap,
4545
+ checkPageSpeed
4546
+ ];
4547
+
4548
+ // src/web-scanner/index.ts
4549
+ function getGrade(score) {
4550
+ if (score >= 80) return "A";
4551
+ if (score >= 60) return "B";
4552
+ if (score >= 40) return "C";
4553
+ if (score >= 20) return "D";
4554
+ return "F";
4555
+ }
4556
+ function getDomain(url) {
4557
+ try {
4558
+ return new URL(url).hostname;
4559
+ } catch {
4560
+ return url;
4561
+ }
4562
+ }
4563
+ var PRIVATE_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "metadata.google.internal"]);
4564
+ function isPrivateHost(hostname) {
4565
+ if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
4566
+ const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
4567
+ if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
4568
+ const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4569
+ if (ipv4) {
4570
+ const [, a, b] = ipv4.map(Number);
4571
+ if (a === 10 || a === 127 || a === 0) return true;
4572
+ if (a === 172 && b >= 16 && b <= 31) return true;
4573
+ if (a === 192 && b === 168) return true;
4574
+ if (a === 169 && b === 254) return true;
4575
+ if (a === 100 && b >= 64 && b <= 127) return true;
4576
+ if (a === 198 && (b === 18 || b === 19)) return true;
4577
+ }
4578
+ return false;
4579
+ }
4580
+ function validateRedirectUrl(responseUrl) {
4581
+ try {
4582
+ const parsed = new URL(responseUrl);
4583
+ return !isPrivateHost(parsed.hostname);
4584
+ } catch {
4585
+ return false;
4586
+ }
4587
+ }
4588
+ async function fetchText(url, timeout = 8e3) {
4589
+ try {
4590
+ const c = new AbortController();
4591
+ const t = setTimeout(() => c.abort(), timeout);
4592
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4593
+ clearTimeout(t);
4594
+ if (r.url && !validateRedirectUrl(r.url)) return null;
4595
+ return r.ok ? await r.text() : null;
4596
+ } catch {
4597
+ return null;
4598
+ }
4599
+ }
4600
+ async function fetchHeaders(url, timeout = 8e3) {
4601
+ try {
4602
+ const c = new AbortController();
4603
+ const t = setTimeout(() => c.abort(), timeout);
4604
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4605
+ clearTimeout(t);
4606
+ if (r.url && !validateRedirectUrl(r.url)) return {};
4607
+ const h = {};
4608
+ r.headers.forEach((v, k) => {
4609
+ h[k.toLowerCase()] = v;
4610
+ });
4611
+ return h;
4612
+ } catch {
4613
+ return {};
4614
+ }
4615
+ }
4616
+ async function fetchWithTiming(url, timeout = 15e3) {
4617
+ try {
4618
+ const c = new AbortController();
4619
+ const t = setTimeout(() => c.abort(), timeout);
4620
+ const start = Date.now();
4621
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4622
+ const html = await r.text();
4623
+ clearTimeout(t);
4624
+ if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
4625
+ return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
4626
+ } catch {
4627
+ return { html: null, loadTimeMs: null };
4628
+ }
4629
+ }
4630
+ function normalizeUrl(input) {
4631
+ let url = input.trim();
4632
+ if (!/^https?:\/\//i.test(url)) {
4633
+ url = `https://${url}`;
4634
+ }
4635
+ const parsed = new URL(url);
4636
+ return parsed.origin;
4637
+ }
4638
+ async function runWebScan(targetUrl) {
4639
+ const domain = getDomain(targetUrl);
4640
+ const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, pageData] = await Promise.all([
4641
+ fetchText(`${targetUrl}/robots.txt`),
4642
+ fetchText(`${targetUrl}/llms.txt`),
4643
+ fetchText(`${targetUrl}/ai.txt`),
4644
+ fetchText(`${targetUrl}/.well-known/tdmrep.json`),
4645
+ fetchText(`${targetUrl}/.well-known/agent-card.json`),
4646
+ fetchText(`${targetUrl}/sitemap.xml`),
4647
+ fetchText(`${targetUrl}/.well-known/http-message-signatures-directory`),
4648
+ fetchHeaders(targetUrl),
4649
+ fetchWithTiming(targetUrl)
4650
+ ]);
4651
+ const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, html: pageData.html, loadTimeMs: pageData.loadTimeMs };
4652
+ const checks = allChecks.map((fn) => fn(ctx));
4653
+ const totalPoints = checks.reduce((s, c) => s + c.points, 0);
4654
+ const maxPoints = checks.reduce((s, c) => s + c.maxPoints, 0);
4655
+ const overallScore = maxPoints > 0 ? Math.round(totalPoints / maxPoints * 100) : 0;
4656
+ return {
4657
+ url: targetUrl,
4658
+ domain,
4659
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
4660
+ overallScore,
4661
+ grade: getGrade(overallScore),
4662
+ checks,
4663
+ summary: {
4664
+ passed: checks.filter((c) => c.status === "pass").length,
4665
+ failed: checks.filter((c) => c.status === "fail").length,
4666
+ warnings: checks.filter((c) => c.status === "warn").length,
4667
+ totalChecks: checks.length
4668
+ }
4669
+ };
4670
+ }
4671
+
4398
4672
  // src/mcp.ts
4399
4673
  var server = new McpServer({
4400
4674
  name: "prodlint",
@@ -4402,13 +4676,20 @@ var server = new McpServer({
4402
4676
  });
4403
4677
  server.tool(
4404
4678
  "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.",
4679
+ "Check a vibe-coded project's production readiness. Returns a 0-100 score with findings across security, reliability, performance, and AI quality categories.",
4406
4680
  {
4407
4681
  path: z.string().describe("Absolute path to the project directory to scan"),
4408
4682
  ignore: z.array(z.string()).optional().describe("Glob patterns to ignore")
4409
4683
  },
4410
4684
  async ({ path, ignore }) => {
4411
4685
  const resolved = resolve4(path);
4686
+ const cwd = process.cwd();
4687
+ if (!resolved.startsWith(cwd)) {
4688
+ return {
4689
+ content: [{ type: "text", text: `Error: Path must be within the current working directory (${cwd})` }],
4690
+ isError: true
4691
+ };
4692
+ }
4412
4693
  try {
4413
4694
  const stats = await stat2(resolved);
4414
4695
  if (!stats.isDirectory()) {
@@ -4425,7 +4706,7 @@ server.tool(
4425
4706
  }
4426
4707
  const result = await scan({ path: resolved, ignore });
4427
4708
  const summary = [
4428
- `## Prodlint Score: ${result.overallScore}/100`,
4709
+ `## Production Readiness: ${result.overallScore}/100`,
4429
4710
  "",
4430
4711
  `Scanned ${result.filesScanned} files in ${result.scanDurationMs}ms`,
4431
4712
  "",
@@ -4452,6 +4733,47 @@ server.tool(
4452
4733
  };
4453
4734
  }
4454
4735
  );
4736
+ server.tool(
4737
+ "scan-web",
4738
+ "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.",
4739
+ { url: z.string().describe("URL of the website to scan (e.g. https://example.com)") },
4740
+ async ({ url }) => {
4741
+ let normalizedUrl;
4742
+ try {
4743
+ normalizedUrl = normalizeUrl(url);
4744
+ } catch {
4745
+ return {
4746
+ content: [{ type: "text", text: `Error: Invalid URL "${url}"` }],
4747
+ isError: true
4748
+ };
4749
+ }
4750
+ const hostname = new URL(normalizedUrl).hostname;
4751
+ if (isPrivateHost(hostname)) {
4752
+ return {
4753
+ content: [{ type: "text", text: "Error: Cannot scan private or internal hosts." }],
4754
+ isError: true
4755
+ };
4756
+ }
4757
+ const result = await runWebScan(normalizedUrl);
4758
+ const STATUS_SYMBOLS = { pass: "\u2713", fail: "\u2717", warn: "!", info: "i" };
4759
+ const summary = [
4760
+ `## Site Score: ${result.overallScore}/100 (${result.grade})`,
4761
+ "",
4762
+ `Scanned ${result.domain} \xB7 ${result.summary.totalChecks} checks`,
4763
+ "",
4764
+ "### Checks",
4765
+ ...result.checks.map((c) => {
4766
+ const sym = STATUS_SYMBOLS[c.status] ?? "?";
4767
+ return `- ${sym} **${c.name}** \u2014 ${c.details || "No details"} (${c.points}/${c.maxPoints})`;
4768
+ }),
4769
+ "",
4770
+ `### Summary: ${result.summary.passed} passed \xB7 ${result.summary.failed} failed \xB7 ${result.summary.warnings} warnings`
4771
+ ];
4772
+ return {
4773
+ content: [{ type: "text", text: summary.join("\n") }]
4774
+ };
4775
+ }
4776
+ );
4455
4777
  async function main() {
4456
4778
  const transport = new StdioServerTransport();
4457
4779
  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.1",
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",