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 +46 -10
- package/action.yml +2 -2
- package/dist/cli.js +351 -2
- package/dist/index.d.ts +44 -1
- package/dist/index.js +269 -2
- package/dist/mcp.js +326 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/prodlint)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Production readiness for vibe-coded apps.
|
|
8
8
|
|
|
9
|
-
Static analysis for vibe-coded apps.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: '
|
|
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' + '
|
|
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
|
-
|
|
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 };
|