prodlint 0.9.1 → 0.9.3

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
@@ -6,18 +6,18 @@
6
6
 
7
7
  Production readiness for vibe-coded apps.
8
8
 
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.
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.
10
10
 
11
11
  ```bash
12
12
  npx prodlint
13
13
  ```
14
14
 
15
15
  ```
16
- prodlint v0.9.1
17
- Scanned 148 files · 3 critical · 5 warnings
16
+ prodlint v0.9.3
17
+ Scanned 148 files · 2 critical · 5 warnings · 1 info
18
18
 
19
19
  src/app/api/checkout/route.ts
20
- 12:1 CRIT No rate limiting — anyone could spam this endpoint and run up your API costs rate-limiting
20
+ 12:1 INFO No rate limiting — anyone could spam this endpoint and run up your API costs rate-limiting
21
21
  28:5 WARN Empty catch block silently swallows error shallow-catch
22
22
 
23
23
  src/actions/submit.ts
@@ -35,7 +35,7 @@ npx prodlint
35
35
 
36
36
  Overall: 82/100 (weighted)
37
37
 
38
- 3 critical · 5 warnings · 3 info
38
+ 2 critical · 5 warnings · 4 info
39
39
  ```
40
40
 
41
41
  ## Why?
@@ -50,6 +50,11 @@ prodlint checks what TypeScript and ESLint don't: **whether your vibe-coded app
50
50
  npx prodlint # Run directly (no install)
51
51
  npx prodlint ./my-app # Scan specific path
52
52
  npx prodlint --json # JSON output for CI
53
+ npx prodlint --sarif # SARIF 2.1.0 for GitHub Code Scanning
54
+ npx prodlint --summary # Quick pass/fail + top 3 blockers
55
+ npx prodlint --profile startup # Only critical findings
56
+ npx prodlint --profile strict # All findings including info
57
+ npx prodlint --baseline .prodlint-baseline.json # Only new findings
53
58
  npx prodlint --ignore "*.test.ts" # Ignore patterns
54
59
  npx prodlint --min-severity warning # Only warnings and criticals
55
60
  npx prodlint --quiet # Suppress badge output
@@ -196,13 +201,40 @@ Posts a score breakdown as a PR comment and fails the build if below threshold.
196
201
  | `score` | Overall score (0-100) |
197
202
  | `critical` | Number of critical findings |
198
203
 
204
+ ### SARIF + GitHub Code Scanning
205
+
206
+ Upload prodlint results to GitHub's Security tab:
207
+
208
+ ```yaml
209
+ - name: Run prodlint
210
+ run: npx prodlint --sarif > prodlint.sarif
211
+
212
+ - name: Upload SARIF
213
+ uses: github/codeql-action/upload-sarif@v4
214
+ with:
215
+ sarif_file: prodlint.sarif
216
+ category: prodlint
217
+ ```
218
+
219
+ ### Baseline for Existing Projects
220
+
221
+ Adopt prodlint gradually without drowning in pre-existing findings:
222
+
223
+ ```bash
224
+ # Save current state as baseline
225
+ npx prodlint --baseline-save .prodlint-baseline.json
226
+
227
+ # CI only fails on NEW findings
228
+ npx prodlint --baseline .prodlint-baseline.json
229
+ ```
230
+
199
231
  ## MCP Server
200
232
 
201
233
  Use prodlint inside Cursor, Claude Code, or any MCP-compatible editor:
202
234
 
203
235
  **Claude Code:**
204
236
  ```bash
205
- claude mcp add prodlint npx prodlint-mcp
237
+ claude mcp add prodlint -- npx -y prodlint-mcp
206
238
  ```
207
239
 
208
240
  **Cursor / Windsurf:**
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { parseArgs } from "util";
5
+ import { readFileSync as readFileSync2, writeFileSync, existsSync } from "fs";
5
6
 
6
7
  // src/utils/file-walker.ts
7
8
  import fg from "fast-glob";
@@ -4592,6 +4593,99 @@ function reportPretty(result, opts = {}) {
4592
4593
  function reportJson(result) {
4593
4594
  return JSON.stringify(result, null, 2);
4594
4595
  }
4596
+ function reportSummary(result) {
4597
+ const lines = [];
4598
+ const { critical, warning, info } = result.summary;
4599
+ const hasCritical = critical > 0;
4600
+ const verdict = hasCritical ? pc.red(pc.bold("FAIL")) : pc.green(pc.bold("PASS"));
4601
+ const scorePart = scoreColor(result.overallScore)(`${result.overallScore}/100`);
4602
+ const countParts = [];
4603
+ if (critical > 0) countParts.push(`${critical} critical`);
4604
+ if (warning > 0) countParts.push(`${warning} warning`);
4605
+ if (info > 0) countParts.push(`${info} info`);
4606
+ lines.push("");
4607
+ if (countParts.length > 0) {
4608
+ lines.push(` ${verdict} ${pc.dim("\u2014")} ${countParts.join(", ")} ${pc.dim(`(score: ${scorePart})`)}`);
4609
+ } else {
4610
+ lines.push(` ${verdict} ${pc.dim("\u2014")} no issues ${pc.dim(`(score: ${scorePart})`)}`);
4611
+ }
4612
+ const topFindings = result.findings.filter((f) => f.severity === "critical" || f.severity === "warning").slice(0, 3);
4613
+ if (topFindings.length > 0) {
4614
+ for (let i = 0; i < topFindings.length; i++) {
4615
+ const f = topFindings[i];
4616
+ const color = SEVERITY_COLORS[f.severity];
4617
+ const label = SEVERITY_LABELS[f.severity];
4618
+ lines.push(` ${pc.dim(`${i + 1}.`)} ${f.file}:${f.line} ${pc.dim("\u2014")} ${color(label)} ${f.message} ${pc.dim(f.ruleId)}`);
4619
+ }
4620
+ }
4621
+ lines.push("");
4622
+ return lines.join("\n");
4623
+ }
4624
+ var SARIF_SEVERITY_MAP = {
4625
+ critical: "error",
4626
+ warning: "warning",
4627
+ info: "note"
4628
+ };
4629
+ function reportSarif(result) {
4630
+ const ruleMap = /* @__PURE__ */ new Map();
4631
+ for (const r of rules) {
4632
+ ruleMap.set(r.id, r);
4633
+ }
4634
+ const usedRuleIds = new Set(result.findings.map((f) => f.ruleId));
4635
+ const sarifRules = [...usedRuleIds].map((id) => {
4636
+ const rule = ruleMap.get(id);
4637
+ return {
4638
+ id,
4639
+ name: rule?.name ?? id,
4640
+ shortDescription: { text: rule?.description ?? id },
4641
+ defaultConfiguration: {
4642
+ level: SARIF_SEVERITY_MAP[rule?.severity ?? "info"] ?? "note"
4643
+ },
4644
+ helpUri: `https://prodlint.com/rules/${id}`
4645
+ };
4646
+ });
4647
+ const sarifResults = result.findings.map((f) => {
4648
+ const sarifResult = {
4649
+ ruleId: f.ruleId,
4650
+ level: SARIF_SEVERITY_MAP[f.severity] ?? "note",
4651
+ message: { text: f.message },
4652
+ locations: [
4653
+ {
4654
+ physicalLocation: {
4655
+ artifactLocation: { uri: f.file.replace(/\\/g, "/") },
4656
+ region: { startLine: f.line, startColumn: f.column }
4657
+ }
4658
+ }
4659
+ ]
4660
+ };
4661
+ if (f.fix) {
4662
+ sarifResult.fixes = [
4663
+ {
4664
+ description: { text: f.fix }
4665
+ }
4666
+ ];
4667
+ }
4668
+ return sarifResult;
4669
+ });
4670
+ const sarif = {
4671
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
4672
+ version: "2.1.0",
4673
+ runs: [
4674
+ {
4675
+ tool: {
4676
+ driver: {
4677
+ name: "prodlint",
4678
+ version: getVersion(),
4679
+ informationUri: "https://prodlint.com",
4680
+ rules: sarifRules
4681
+ }
4682
+ },
4683
+ results: sarifResults
4684
+ }
4685
+ ]
4686
+ };
4687
+ return JSON.stringify(sarif, null, 2);
4688
+ }
4595
4689
  function renderBar(score) {
4596
4690
  const width = 20;
4597
4691
  const filled = Math.round(score / 100 * width);
@@ -4642,113 +4736,111 @@ function reportWebPretty(result) {
4642
4736
  }
4643
4737
 
4644
4738
  // src/web-scanner/checks.ts
4645
- function make(id, name, description, maxPoints, severity, maturity, status, details) {
4739
+ function make(id, name, description, maxPoints, severity, status, details) {
4646
4740
  return {
4647
4741
  id,
4648
4742
  name,
4649
4743
  description,
4650
4744
  status,
4651
4745
  severity,
4652
- maturity,
4653
4746
  details,
4654
4747
  points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
4655
4748
  maxPoints
4656
4749
  };
4657
4750
  }
4658
4751
  function checkRobotsTxt(ctx) {
4659
- if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 7, "medium", "established", "fail", "No robots.txt found.");
4660
- return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 7, "medium", "established", "pass", "robots.txt found.");
4752
+ if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
4753
+ return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
4661
4754
  }
4662
4755
  function checkRobotsAiDirectives(ctx) {
4663
- if (!ctx.robotsTxt) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "fail", "No robots.txt found. AI bots have no guidance.");
4664
- const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai", "Applebot-Extended", "Grok"];
4756
+ 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.");
4757
+ const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
4665
4758
  const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
4666
- if (found.length === 0) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "fail", "No AI-specific user-agent directives found.");
4667
- if (found.length < 3) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "warn", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4668
- return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "established", "pass", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4759
+ 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.");
4760
+ 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(", ")}.`);
4761
+ 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(", ")}.`);
4669
4762
  }
4670
4763
  function checkContentUsage(ctx) {
4671
4764
  const hasHeader = ctx.headers["content-usage"] != null;
4672
4765
  const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
4673
- if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 5, "high", "emerging", "fail", "No Content-Usage directives found.");
4674
- return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 5, "high", "emerging", "pass", hasHeader ? `Header: ${ctx.headers["content-usage"]}` : "Found in robots.txt.");
4766
+ if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
4767
+ 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.");
4675
4768
  }
4676
4769
  function checkLlmsTxt(ctx) {
4677
- if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "fail", "No llms.txt found.");
4678
- const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4679
- if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "warn", "llms.txt found but appears minimal.");
4680
- return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 6, "high", "emerging", "pass", `llms.txt found with ${lines.length} content lines.`);
4770
+ if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
4771
+ const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
4772
+ 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.");
4773
+ return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
4681
4774
  }
4682
4775
  function checkTdmRep(ctx) {
4683
4776
  const hasWK = ctx.tdmRep != null;
4684
4777
  const hasHeader = ctx.headers["tdm-reservation"] != null;
4685
- if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 4, "medium", "emerging", "fail", "No TDMRep configuration found.");
4686
- return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 4, "medium", "emerging", "pass", hasWK ? "/.well-known/tdmrep.json found." : `TDM-Reservation header: ${ctx.headers["tdm-reservation"]}`);
4778
+ if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
4779
+ 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"]}`);
4687
4780
  }
4688
4781
  function checkAiDisclosure(ctx) {
4689
- if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 2, "low", "emerging", "fail", "No AI-Disclosure header found.");
4690
- return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 2, "low", "emerging", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4782
+ 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.");
4783
+ return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4691
4784
  }
4692
4785
  function checkAgentCard(ctx) {
4693
- if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "fail", "No A2A AgentCard found.");
4786
+ if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
4694
4787
  try {
4695
4788
  const card = JSON.parse(ctx.agentCard);
4696
- 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", 5, "high", "emerging", "warn", "AgentCard found but missing name or skills.");
4697
- return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "pass", `AgentCard found with ${card.skills.length} skill(s).`);
4789
+ 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.");
4790
+ 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).`);
4698
4791
  } catch {
4699
- return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 5, "high", "emerging", "warn", "AgentCard found but contains invalid JSON.");
4792
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
4700
4793
  }
4701
4794
  }
4702
4795
  function checkAiTxt(ctx) {
4703
- if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "fail", "No ai.txt found at site root.");
4796
+ 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.");
4704
4797
  const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4705
- if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "warn", "ai.txt found but appears minimal.");
4706
- return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 3, "medium", "emerging", "pass", `ai.txt found with ${lines.length} directive(s).`);
4798
+ 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.");
4799
+ return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
4707
4800
  }
4708
4801
  function checkWebMCP(ctx) {
4709
- if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "info", "Could not check for WebMCP tools.");
4802
+ if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
4710
4803
  const hasToolname = /toolname=/i.test(ctx.html);
4711
4804
  const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
4712
- if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "fail", "No WebMCP tools detected.");
4805
+ if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
4713
4806
  const count = (ctx.html.match(/toolname=/gi) || []).length;
4714
- return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 5, "high", "emerging", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4807
+ return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4715
4808
  }
4716
4809
  function checkStructuredData(ctx) {
4717
- if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "info", "Could not fetch page HTML.");
4810
+ if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
4718
4811
  const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
4719
4812
  const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
4720
- if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "fail", "No structured data found.");
4721
- return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 12, "medium", "established", "pass", `Found ${(jsonLd?.length || 0) + (hasMicrodata ? 1 : 0)} structured data block(s).`);
4813
+ if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
4814
+ 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).`);
4722
4815
  }
4723
4816
  function checkOpenGraph(ctx) {
4724
- if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "info", "Could not fetch HTML.");
4817
+ if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
4725
4818
  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)];
4726
4819
  const passed = checks.filter(Boolean).length;
4727
- if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "fail", "No OpenGraph tags or meta description.");
4728
- if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "warn", `Found ${passed}/4 meta tags.`);
4729
- if (passed < 4) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", `Found ${passed}/4 key meta tags.`);
4730
- return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 10, "low", "established", "pass", "All 4 key meta tags present.");
4820
+ if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
4821
+ if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
4822
+ return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
4731
4823
  }
4732
4824
  function checkSitemap(ctx) {
4733
- if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "fail", "No sitemap.xml found.");
4825
+ if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
4734
4826
  const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
4735
- if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "pass", "Sitemap index found.");
4736
- if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "warn", "sitemap.xml found but appears empty.");
4737
- return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 10, "medium", "established", "pass", `sitemap.xml found with ${count} URL(s).`);
4827
+ if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
4828
+ if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
4829
+ return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
4738
4830
  }
4739
4831
  function checkHttpSignatures(ctx) {
4740
4832
  const hasDirectory = ctx.httpSigDirectory != null;
4741
4833
  const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
4742
4834
  const hasSignatureAgent = ctx.headers["signature-agent"] != null;
4743
- if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "fail", "No HTTP signature support detected.");
4744
- if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "pass", "/.well-known/http-message-signatures-directory found.");
4745
- return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 3, "medium", "emerging", "pass", hasSignatureAgent ? "Signature-Agent header detected." : "Signature/Signature-Input headers detected.");
4835
+ 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.");
4836
+ 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.");
4837
+ 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.");
4746
4838
  }
4747
4839
  function checkPageSpeed(ctx) {
4748
- if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "info", "Could not measure.");
4749
- if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "fail", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 agents may time out.`);
4750
- if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "warn", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 consider optimizing.`);
4751
- return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 8, "low", "established", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4840
+ if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
4841
+ 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.`);
4842
+ 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.`);
4843
+ return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4752
4844
  }
4753
4845
  var allChecks = [
4754
4846
  checkRobotsTxt,
@@ -4787,29 +4879,6 @@ function isPrivateHost(hostname) {
4787
4879
  if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
4788
4880
  const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
4789
4881
  if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
4790
- const v4mapped = h.match(/^(?:::ffff:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4791
- if (v4mapped) {
4792
- const [, a, b] = v4mapped.map(Number);
4793
- if (a === 10 || a === 127 || a === 0) return true;
4794
- if (a === 172 && b >= 16 && b <= 31) return true;
4795
- if (a === 192 && b === 168) return true;
4796
- if (a === 169 && b === 254) return true;
4797
- return false;
4798
- }
4799
- if (/^(0[xX][0-9a-fA-F]+|0[0-7]+)(\.|$)/.test(hostname)) return true;
4800
- if (/^\d+$/.test(hostname)) {
4801
- const dec = parseInt(hostname, 10);
4802
- if (dec >= 0 && dec <= 4294967295) {
4803
- const a = dec >>> 24 & 255;
4804
- const b = dec >>> 16 & 255;
4805
- if (a === 10 || a === 127 || a === 0) return true;
4806
- if (a === 172 && b >= 16 && b <= 31) return true;
4807
- if (a === 192 && b === 168) return true;
4808
- if (a === 169 && b === 254) return true;
4809
- if (a === 100 && b >= 64 && b <= 127) return true;
4810
- if (a === 198 && (b === 18 || b === 19)) return true;
4811
- }
4812
- }
4813
4882
  const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4814
4883
  if (ipv4) {
4815
4884
  const [, a, b] = ipv4.map(Number);
@@ -4822,49 +4891,22 @@ function isPrivateHost(hostname) {
4822
4891
  }
4823
4892
  return false;
4824
4893
  }
4825
- function isAllowedProtocol(url) {
4826
- try {
4827
- const p = new URL(url).protocol;
4828
- return p === "https:" || p === "http:";
4829
- } catch {
4830
- return false;
4831
- }
4832
- }
4833
4894
  function validateRedirectUrl(responseUrl) {
4834
4895
  try {
4835
4896
  const parsed = new URL(responseUrl);
4836
- if (!isAllowedProtocol(responseUrl)) return false;
4837
4897
  return !isPrivateHost(parsed.hostname);
4838
4898
  } catch {
4839
4899
  return false;
4840
4900
  }
4841
4901
  }
4842
- var MAX_REDIRECTS = 5;
4843
- async function safeFetch(url, timeout, signal) {
4844
- let current = url;
4845
- for (let i = 0; i < MAX_REDIRECTS; i++) {
4846
- const r = await fetch(current, { signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "manual" });
4847
- const status = r.status;
4848
- if (status >= 300 && status < 400) {
4849
- const location = r.headers.get("location");
4850
- if (!location) return null;
4851
- const resolved = new URL(location, current).toString();
4852
- if (!validateRedirectUrl(resolved)) return null;
4853
- current = resolved;
4854
- continue;
4855
- }
4856
- return r;
4857
- }
4858
- return null;
4859
- }
4860
4902
  async function fetchText(url, timeout = 8e3) {
4861
4903
  try {
4862
4904
  const c = new AbortController();
4863
4905
  const t = setTimeout(() => c.abort(), timeout);
4864
- const r = await safeFetch(url, timeout, c.signal);
4906
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4865
4907
  clearTimeout(t);
4866
- if (!r || !r.ok) return null;
4867
- return await r.text();
4908
+ if (r.url && !validateRedirectUrl(r.url)) return null;
4909
+ return r.ok ? await r.text() : null;
4868
4910
  } catch {
4869
4911
  return null;
4870
4912
  }
@@ -4873,9 +4915,9 @@ async function fetchHeaders(url, timeout = 8e3) {
4873
4915
  try {
4874
4916
  const c = new AbortController();
4875
4917
  const t = setTimeout(() => c.abort(), timeout);
4876
- const r = await safeFetch(url, timeout, c.signal);
4918
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4877
4919
  clearTimeout(t);
4878
- if (!r) return {};
4920
+ if (r.url && !validateRedirectUrl(r.url)) return {};
4879
4921
  const h = {};
4880
4922
  r.headers.forEach((v, k) => {
4881
4923
  h[k.toLowerCase()] = v;
@@ -4890,18 +4932,20 @@ async function fetchWithTiming(url, timeout = 15e3) {
4890
4932
  const c = new AbortController();
4891
4933
  const t = setTimeout(() => c.abort(), timeout);
4892
4934
  const start = Date.now();
4893
- const r = await safeFetch(url, timeout, c.signal);
4894
- clearTimeout(t);
4895
- if (!r || !r.ok) return { html: null, loadTimeMs: null };
4935
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4896
4936
  const html = await r.text();
4897
- return { html, loadTimeMs: Date.now() - start };
4937
+ clearTimeout(t);
4938
+ if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
4939
+ return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
4898
4940
  } catch {
4899
4941
  return { html: null, loadTimeMs: null };
4900
4942
  }
4901
4943
  }
4902
4944
  function normalizeUrl(input) {
4903
4945
  let url = input.trim();
4904
- if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
4946
+ if (!/^https?:\/\//i.test(url)) {
4947
+ url = `https://${url}`;
4948
+ }
4905
4949
  const parsed = new URL(url);
4906
4950
  return parsed.origin;
4907
4951
  }
@@ -4941,13 +4985,26 @@ async function runWebScan(targetUrl) {
4941
4985
 
4942
4986
  // src/cli.ts
4943
4987
  var SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
4988
+ var PROFILE_SEVERITY = {
4989
+ startup: "critical",
4990
+ balanced: "warning",
4991
+ strict: "info"
4992
+ };
4993
+ function fingerprintFinding(f) {
4994
+ return `${f.ruleId}::${f.file}::${f.line}`;
4995
+ }
4944
4996
  async function main() {
4945
4997
  const { values, positionals } = parseArgs({
4946
4998
  allowPositionals: true,
4947
4999
  options: {
4948
5000
  json: { type: "boolean", default: false },
5001
+ sarif: { type: "boolean", default: false },
5002
+ summary: { type: "boolean", default: false },
4949
5003
  ignore: { type: "string", multiple: true, default: [] },
4950
5004
  "min-severity": { type: "string", default: "info" },
5005
+ profile: { type: "string" },
5006
+ baseline: { type: "string" },
5007
+ "baseline-save": { type: "string" },
4951
5008
  quiet: { type: "boolean", default: false },
4952
5009
  web: { type: "boolean", default: false },
4953
5010
  help: { type: "boolean", short: "h", default: false },
@@ -4972,15 +5029,66 @@ async function main() {
4972
5029
  return;
4973
5030
  }
4974
5031
  const targetPath = positionals[0] ?? ".";
4975
- const minSeverity = values["min-severity"] ?? "info";
5032
+ let minSeverity;
5033
+ if (values.profile) {
5034
+ const profileName = values.profile;
5035
+ if (!(profileName in PROFILE_SEVERITY)) {
5036
+ console.error(`Unknown profile: ${profileName}. Available: startup, balanced, strict`);
5037
+ process.exit(2);
5038
+ }
5039
+ minSeverity = PROFILE_SEVERITY[profileName];
5040
+ } else {
5041
+ minSeverity = values["min-severity"] ?? "info";
5042
+ }
4976
5043
  const result = await scan({
4977
5044
  path: targetPath,
4978
5045
  ignore: values.ignore
4979
5046
  });
4980
5047
  const minRank = SEVERITY_RANK[minSeverity] ?? 1;
4981
5048
  result.findings = result.findings.filter((f) => SEVERITY_RANK[f.severity] >= minRank);
4982
- if (values.json) {
5049
+ result.summary = {
5050
+ critical: result.findings.filter((f) => f.severity === "critical").length,
5051
+ warning: result.findings.filter((f) => f.severity === "warning").length,
5052
+ info: result.findings.filter((f) => f.severity === "info").length
5053
+ };
5054
+ if (values["baseline-save"]) {
5055
+ const baselinePath = values["baseline-save"];
5056
+ const entries = result.findings.map((f) => ({
5057
+ ruleId: f.ruleId,
5058
+ file: f.file,
5059
+ line: f.line,
5060
+ message: f.message
5061
+ }));
5062
+ writeFileSync(baselinePath, JSON.stringify(entries, null, 2));
5063
+ console.log(`Baseline saved: ${entries.length} findings written to ${baselinePath}`);
5064
+ return;
5065
+ }
5066
+ if (values.baseline) {
5067
+ const baselinePath = values.baseline;
5068
+ if (!existsSync(baselinePath)) {
5069
+ console.error(`Baseline file not found: ${baselinePath}`);
5070
+ console.error("Run with --baseline-save first to create a baseline.");
5071
+ process.exit(2);
5072
+ }
5073
+ const baselineData = JSON.parse(readFileSync2(baselinePath, "utf8"));
5074
+ const baselineFingerprints = new Set(
5075
+ baselineData.map((e) => `${e.ruleId}::${e.file}::${e.line}`)
5076
+ );
5077
+ result.findings = result.findings.filter(
5078
+ (f) => !baselineFingerprints.has(fingerprintFinding(f))
5079
+ );
5080
+ result.summary = {
5081
+ critical: result.findings.filter((f) => f.severity === "critical").length,
5082
+ warning: result.findings.filter((f) => f.severity === "warning").length,
5083
+ info: result.findings.filter((f) => f.severity === "info").length
5084
+ };
5085
+ }
5086
+ if (values.sarif) {
5087
+ console.log(reportSarif(result));
5088
+ } else if (values.json) {
4983
5089
  console.log(reportJson(result));
5090
+ } else if (values.summary) {
5091
+ console.log(reportSummary(result));
4984
5092
  } else {
4985
5093
  console.log(reportPretty(result, { quiet: values.quiet }));
4986
5094
  }
@@ -5018,22 +5126,32 @@ function printHelp() {
5018
5126
 
5019
5127
  Options:
5020
5128
  --json Output results as JSON
5129
+ --sarif Output results as SARIF 2.1.0 (for GitHub Code Scanning)
5130
+ --summary Quick pass/fail verdict with top 3 findings
5021
5131
  --ignore <pattern> Glob patterns to ignore (can be repeated)
5022
- --min-severity <level> Minimum severity to show: critical, warning, info (default: info)
5132
+ --min-severity <level> Minimum severity: critical, warning, info (default: info)
5133
+ --profile <name> Preset: startup (criticals only), balanced (warnings+), strict (all)
5134
+ --baseline <file> Only show findings not in baseline file
5135
+ --baseline-save <file> Save current findings as baseline snapshot
5023
5136
  --quiet Suppress badge and summary
5024
5137
  --web Get your site's prodlint score (14 AI agent checks)
5025
5138
  -h, --help Show this help message
5026
5139
  -v, --version Show version
5027
5140
 
5028
5141
  Examples:
5029
- npx prodlint Scan current directory
5030
- npx prodlint ./my-app Scan specific path
5031
- npx prodlint --json JSON output
5032
- npx prodlint --ignore "*.test" Ignore test files
5033
- npx prodlint --min-severity warning Only warnings and criticals
5034
- npx prodlint --quiet No badge output
5035
- npx prodlint --web example.com Site score
5036
- npx prodlint --web example.com --json Site score with JSON output
5142
+ npx prodlint Scan current directory
5143
+ npx prodlint ./my-app Scan specific path
5144
+ npx prodlint --json JSON output
5145
+ npx prodlint --sarif SARIF output for CI integration
5146
+ npx prodlint --summary Quick pass/fail + top blockers
5147
+ npx prodlint --profile startup Only critical findings
5148
+ npx prodlint --baseline .prodlint-baseline.json Only new findings
5149
+ npx prodlint --baseline-save .prodlint-baseline.json Save baseline
5150
+ npx prodlint --ignore "*.test" Ignore test files
5151
+ npx prodlint --min-severity warning Only warnings and criticals
5152
+ npx prodlint --quiet No badge output
5153
+ npx prodlint --web example.com Site score
5154
+ npx prodlint --web example.com --json Site score with JSON output
5037
5155
  `);
5038
5156
  }
5039
5157
  main().catch((err) => {