prodlint 0.2.0 → 0.2.2
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 +86 -4
- package/action.yml +35 -21
- package/dist/cli.js +10 -6
- package/dist/index.js +9 -5
- package/dist/mcp.js +124 -13875
- package/package.json +14 -5
- package/dist/cli.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/mcp.js.map +0 -1
package/README.md
CHANGED
|
@@ -20,6 +20,33 @@ prodlint catches what TypeScript and ESLint miss: **production readiness gaps**.
|
|
|
20
20
|
npx prodlint
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
## Example Output
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
prodlint v0.2.1
|
|
27
|
+
Scanned 142 files in 87ms
|
|
28
|
+
|
|
29
|
+
src/app/api/users/route.ts
|
|
30
|
+
8:1 CRIT API route has no authentication check auth-checks
|
|
31
|
+
8:1 WARN API route has no rate limiting rate-limiting
|
|
32
|
+
|
|
33
|
+
src/components/chat.tsx
|
|
34
|
+
24:5 CRIT Hardcoded Stripe secret key detected secrets
|
|
35
|
+
|
|
36
|
+
src/lib/db.ts
|
|
37
|
+
15:1 CRIT SQL query built with template literal interpolation sql-injection
|
|
38
|
+
|
|
39
|
+
Scores
|
|
40
|
+
security 40 ████████░░░░░░░░░░░░
|
|
41
|
+
reliability 70 ██████████████░░░░░░
|
|
42
|
+
performance 95 ███████████████████░
|
|
43
|
+
ai-quality 88 ██████████████████░░
|
|
44
|
+
|
|
45
|
+
Overall: 73/100
|
|
46
|
+
|
|
47
|
+
3 critical · 4 warnings · 2 info
|
|
48
|
+
```
|
|
49
|
+
|
|
23
50
|
## Usage
|
|
24
51
|
|
|
25
52
|
```bash
|
|
@@ -31,7 +58,7 @@ npx prodlint --ignore "*.test.ts" # Ignore patterns
|
|
|
31
58
|
|
|
32
59
|
## What It Checks
|
|
33
60
|
|
|
34
|
-
prodlint runs **11 rules** across
|
|
61
|
+
prodlint runs **11 rules** across 3 categories:
|
|
35
62
|
|
|
36
63
|
### Security
|
|
37
64
|
| Rule | Severity | What it detects |
|
|
@@ -64,9 +91,7 @@ Each category starts at 100 points. Deductions:
|
|
|
64
91
|
- **Warning**: -3 points
|
|
65
92
|
- **Info**: -1 point
|
|
66
93
|
|
|
67
|
-
Overall score = average of all
|
|
68
|
-
|
|
69
|
-
Exit code is `1` if any critical findings exist, `0` otherwise.
|
|
94
|
+
Overall score = average of all category scores. Exit code is `1` if any critical findings exist, `0` otherwise.
|
|
70
95
|
|
|
71
96
|
## Smart Detection
|
|
72
97
|
|
|
@@ -77,6 +102,63 @@ prodlint avoids common false positives:
|
|
|
77
102
|
- **TypeScript path aliases** — `@/`, `~/`, and custom tsconfig paths aren't flagged as hallucinated imports
|
|
78
103
|
- **Route exemptions** — auth, webhook, health, and cron routes are exempt from auth/rate-limit checks
|
|
79
104
|
|
|
105
|
+
## GitHub Action
|
|
106
|
+
|
|
107
|
+
Add prodlint to your CI pipeline. It posts a score summary as a PR comment and can fail builds below a threshold.
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
- uses: prodlint/prodlint@v1
|
|
111
|
+
with:
|
|
112
|
+
threshold: 70 # Fail if score < 70 (optional)
|
|
113
|
+
comment: true # Post PR comment (default: true)
|
|
114
|
+
ignore: '*.test.ts, __mocks__/**' # Ignore patterns (optional)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Inputs:**
|
|
118
|
+
| Input | Default | Description |
|
|
119
|
+
|-------|---------|-------------|
|
|
120
|
+
| `path` | `.` | Path to scan |
|
|
121
|
+
| `threshold` | `0` | Minimum score to pass (0-100) |
|
|
122
|
+
| `ignore` | `''` | Comma-separated glob patterns to ignore |
|
|
123
|
+
| `comment` | `true` | Post a PR comment with results |
|
|
124
|
+
|
|
125
|
+
**Outputs:**
|
|
126
|
+
| Output | Description |
|
|
127
|
+
|--------|-------------|
|
|
128
|
+
| `score` | Overall score (0-100) |
|
|
129
|
+
| `critical` | Number of critical findings |
|
|
130
|
+
|
|
131
|
+
## MCP Server
|
|
132
|
+
|
|
133
|
+
prodlint ships an MCP server for AI coding tools (Cursor, Claude Code, Windsurf, etc.).
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
npx prodlint-mcp
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Claude Code
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
claude mcp add prodlint npx prodlint-mcp
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Cursor / Windsurf
|
|
146
|
+
|
|
147
|
+
Add to your MCP config:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"mcpServers": {
|
|
152
|
+
"prodlint": {
|
|
153
|
+
"command": "npx",
|
|
154
|
+
"args": ["prodlint-mcp"]
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The MCP server exposes a single `scan` tool that accepts a project path and returns the full score breakdown with findings.
|
|
161
|
+
|
|
80
162
|
## Suppressing Findings
|
|
81
163
|
|
|
82
164
|
Suppress a single line:
|
package/action.yml
CHANGED
|
@@ -41,38 +41,47 @@ runs:
|
|
|
41
41
|
- name: Run prodlint
|
|
42
42
|
id: scan
|
|
43
43
|
shell: bash
|
|
44
|
+
env:
|
|
45
|
+
INPUT_PATH: ${{ inputs.path }}
|
|
46
|
+
INPUT_IGNORE: ${{ inputs.ignore }}
|
|
44
47
|
run: |
|
|
45
|
-
|
|
46
|
-
if [ -n "$
|
|
47
|
-
IFS=',' read -ra PATTERNS <<< "$
|
|
48
|
+
CMD_ARGS=("$INPUT_PATH" "--json")
|
|
49
|
+
if [ -n "$INPUT_IGNORE" ]; then
|
|
50
|
+
IFS=',' read -ra PATTERNS <<< "$INPUT_IGNORE"
|
|
48
51
|
for p in "${PATTERNS[@]}"; do
|
|
49
|
-
|
|
52
|
+
trimmed=$(echo "$p" | xargs)
|
|
53
|
+
CMD_ARGS+=("--ignore" "$trimmed")
|
|
50
54
|
done
|
|
51
55
|
fi
|
|
52
56
|
|
|
53
|
-
OUTPUT=$(npx -y prodlint@latest $
|
|
57
|
+
OUTPUT=$(npx -y prodlint@latest "${CMD_ARGS[@]}" 2>&1) || true
|
|
54
58
|
|
|
55
59
|
SCORE=$(echo "$OUTPUT" | node -e "
|
|
56
60
|
const d = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
|
57
|
-
console.log(d.overallScore);
|
|
61
|
+
console.log(Number(d.overallScore) || 0);
|
|
58
62
|
" 2>/dev/null || echo "0")
|
|
63
|
+
SCORE=$(echo "$SCORE" | tr -cd '0-9')
|
|
59
64
|
|
|
60
65
|
CRITICAL=$(echo "$OUTPUT" | node -e "
|
|
61
66
|
const d = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
|
62
|
-
console.log(d.summary.critical);
|
|
67
|
+
console.log(Number(d.summary.critical) || 0);
|
|
63
68
|
" 2>/dev/null || echo "0")
|
|
69
|
+
CRITICAL=$(echo "$CRITICAL" | tr -cd '0-9')
|
|
64
70
|
|
|
65
|
-
echo "score=$SCORE" >> $GITHUB_OUTPUT
|
|
66
|
-
echo "critical=$CRITICAL" >> $GITHUB_OUTPUT
|
|
71
|
+
echo "score=${SCORE:-0}" >> "$GITHUB_OUTPUT"
|
|
72
|
+
echo "critical=${CRITICAL:-0}" >> "$GITHUB_OUTPUT"
|
|
67
73
|
echo "$OUTPUT" > /tmp/prodlint-result.json
|
|
68
74
|
|
|
69
75
|
- name: Generate comment body
|
|
70
76
|
if: inputs.comment == 'true' && github.event_name == 'pull_request'
|
|
71
77
|
id: comment
|
|
72
78
|
shell: bash
|
|
79
|
+
env:
|
|
80
|
+
SCAN_SCORE: ${{ steps.scan.outputs.score }}
|
|
81
|
+
INPUT_THRESHOLD: ${{ inputs.threshold }}
|
|
73
82
|
run: |
|
|
74
|
-
SCORE="$
|
|
75
|
-
THRESHOLD="$
|
|
83
|
+
SCORE="$SCAN_SCORE"
|
|
84
|
+
THRESHOLD="$INPUT_THRESHOLD"
|
|
76
85
|
|
|
77
86
|
if [ "$SCORE" -ge 80 ]; then
|
|
78
87
|
EMOJI="✅"
|
|
@@ -85,10 +94,12 @@ runs:
|
|
|
85
94
|
COLOR="red"
|
|
86
95
|
fi
|
|
87
96
|
|
|
88
|
-
# Build comment from JSON
|
|
89
|
-
|
|
97
|
+
# Build comment from JSON with markdown sanitization
|
|
98
|
+
TMPFILE=$(mktemp "${RUNNER_TEMP:-/tmp}/prodlint-comment-XXXXXX.md")
|
|
99
|
+
node -e "
|
|
90
100
|
const fs = require('fs');
|
|
91
101
|
const d = JSON.parse(fs.readFileSync('/tmp/prodlint-result.json', 'utf8'));
|
|
102
|
+
const esc = s => String(s).replace(/[[\]()\\*_\`<>]/g, c => '\\\\' + c);
|
|
92
103
|
const lines = [];
|
|
93
104
|
lines.push('## ' + '$EMOJI' + ' Prodlint Score: **' + d.overallScore + '/100**');
|
|
94
105
|
lines.push('');
|
|
@@ -96,7 +107,7 @@ runs:
|
|
|
96
107
|
lines.push('|----------|-------|--------|');
|
|
97
108
|
for (const c of d.categoryScores) {
|
|
98
109
|
const icon = c.score >= 80 ? '🟢' : c.score >= 60 ? '🟡' : '🔴';
|
|
99
|
-
lines.push('| ' + icon + ' ' + c.category + ' | ' + c.score + '/100 | ' + c.findingCount + ' |');
|
|
110
|
+
lines.push('| ' + icon + ' ' + esc(c.category) + ' | ' + c.score + '/100 | ' + c.findingCount + ' |');
|
|
100
111
|
}
|
|
101
112
|
if (d.summary.critical > 0) {
|
|
102
113
|
lines.push('');
|
|
@@ -104,7 +115,7 @@ runs:
|
|
|
104
115
|
lines.push('');
|
|
105
116
|
const crits = d.findings.filter(f => f.severity === 'critical');
|
|
106
117
|
for (const f of crits.slice(0, 10)) {
|
|
107
|
-
lines.push('-
|
|
118
|
+
lines.push('- \`' + esc(f.ruleId) + '\` \`' + esc(f.file) + ':' + f.line + '\` — ' + esc(f.message));
|
|
108
119
|
}
|
|
109
120
|
if (crits.length > 10) {
|
|
110
121
|
lines.push('- ...and ' + (crits.length - 10) + ' more');
|
|
@@ -113,11 +124,11 @@ runs:
|
|
|
113
124
|
lines.push('');
|
|
114
125
|
lines.push('---');
|
|
115
126
|
lines.push('*Scanned ' + d.filesScanned + ' files in ' + d.scanDurationMs + 'ms · [prodlint](https://prodlint.com)*');
|
|
116
|
-
|
|
117
|
-
"
|
|
127
|
+
fs.writeFileSync('$TMPFILE', lines.join('\n'));
|
|
128
|
+
"
|
|
118
129
|
|
|
119
|
-
#
|
|
120
|
-
|
|
130
|
+
# Use the generated file for the comment step
|
|
131
|
+
cp "$TMPFILE" /tmp/prodlint-comment.md
|
|
121
132
|
|
|
122
133
|
- name: Post PR comment
|
|
123
134
|
if: inputs.comment == 'true' && github.event_name == 'pull_request'
|
|
@@ -129,9 +140,12 @@ runs:
|
|
|
129
140
|
- name: Check threshold
|
|
130
141
|
if: inputs.threshold != '0'
|
|
131
142
|
shell: bash
|
|
143
|
+
env:
|
|
144
|
+
SCAN_SCORE: ${{ steps.scan.outputs.score }}
|
|
145
|
+
INPUT_THRESHOLD: ${{ inputs.threshold }}
|
|
132
146
|
run: |
|
|
133
|
-
SCORE="$
|
|
134
|
-
THRESHOLD="$
|
|
147
|
+
SCORE="$SCAN_SCORE"
|
|
148
|
+
THRESHOLD="$INPUT_THRESHOLD"
|
|
135
149
|
if [ "$SCORE" -lt "$THRESHOLD" ]; then
|
|
136
150
|
echo "::error::Prodlint score ($SCORE) is below threshold ($THRESHOLD)"
|
|
137
151
|
exit 1
|
package/dist/cli.js
CHANGED
|
@@ -5,8 +5,8 @@ import { parseArgs } from "util";
|
|
|
5
5
|
|
|
6
6
|
// src/utils/file-walker.ts
|
|
7
7
|
import fg from "fast-glob";
|
|
8
|
-
import { readFile, stat } from "fs/promises";
|
|
9
|
-
import { resolve, extname } from "path";
|
|
8
|
+
import { readFile, stat, realpath } from "fs/promises";
|
|
9
|
+
import { resolve, extname, sep } from "path";
|
|
10
10
|
|
|
11
11
|
// src/utils/patterns.ts
|
|
12
12
|
function isApiRoute(relativePath) {
|
|
@@ -153,17 +153,21 @@ async function walkFiles(root, extraIgnores = []) {
|
|
|
153
153
|
cwd: root,
|
|
154
154
|
ignore: [...DEFAULT_IGNORES, ...extraIgnores],
|
|
155
155
|
absolute: false,
|
|
156
|
-
dot: true
|
|
156
|
+
dot: true,
|
|
157
|
+
followSymbolicLinks: false
|
|
157
158
|
});
|
|
158
159
|
return files.sort();
|
|
159
160
|
}
|
|
160
161
|
async function readFileContext(root, relativePath) {
|
|
161
162
|
try {
|
|
162
163
|
const absolutePath = resolve(root, relativePath);
|
|
164
|
+
const realRoot = await realpath(root);
|
|
165
|
+
const realFile = await realpath(absolutePath);
|
|
166
|
+
if (!realFile.startsWith(realRoot + sep) && realFile !== realRoot) return null;
|
|
163
167
|
const fileStats = await stat(absolutePath);
|
|
164
168
|
if (fileStats.size > MAX_FILE_SIZE) return null;
|
|
165
169
|
const content = await readFile(absolutePath, "utf-8");
|
|
166
|
-
const lines = content.split(
|
|
170
|
+
const lines = content.split(/\r?\n|\r/);
|
|
167
171
|
return {
|
|
168
172
|
absolutePath,
|
|
169
173
|
relativePath,
|
|
@@ -1023,7 +1027,7 @@ async function scan(options) {
|
|
|
1023
1027
|
const summary = summarizeFindings(findings);
|
|
1024
1028
|
return {
|
|
1025
1029
|
version: getVersion(),
|
|
1026
|
-
scannedPath:
|
|
1030
|
+
scannedPath: options.path,
|
|
1027
1031
|
filesScanned: filePaths.length,
|
|
1028
1032
|
scanDurationMs: Math.round(performance.now() - start),
|
|
1029
1033
|
findings,
|
|
@@ -1172,7 +1176,7 @@ function printHelp() {
|
|
|
1172
1176
|
`);
|
|
1173
1177
|
}
|
|
1174
1178
|
main().catch((err) => {
|
|
1175
|
-
console.error(err);
|
|
1179
|
+
console.error("prodlint error:", err instanceof Error ? err.message : "Unknown error");
|
|
1176
1180
|
process.exit(2);
|
|
1177
1181
|
});
|
|
1178
1182
|
//# sourceMappingURL=cli.js.map
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/utils/file-walker.ts
|
|
2
2
|
import fg from "fast-glob";
|
|
3
|
-
import { readFile, stat } from "fs/promises";
|
|
4
|
-
import { resolve, extname } from "path";
|
|
3
|
+
import { readFile, stat, realpath } from "fs/promises";
|
|
4
|
+
import { resolve, extname, sep } from "path";
|
|
5
5
|
|
|
6
6
|
// src/utils/patterns.ts
|
|
7
7
|
function isApiRoute(relativePath) {
|
|
@@ -148,17 +148,21 @@ async function walkFiles(root, extraIgnores = []) {
|
|
|
148
148
|
cwd: root,
|
|
149
149
|
ignore: [...DEFAULT_IGNORES, ...extraIgnores],
|
|
150
150
|
absolute: false,
|
|
151
|
-
dot: true
|
|
151
|
+
dot: true,
|
|
152
|
+
followSymbolicLinks: false
|
|
152
153
|
});
|
|
153
154
|
return files.sort();
|
|
154
155
|
}
|
|
155
156
|
async function readFileContext(root, relativePath) {
|
|
156
157
|
try {
|
|
157
158
|
const absolutePath = resolve(root, relativePath);
|
|
159
|
+
const realRoot = await realpath(root);
|
|
160
|
+
const realFile = await realpath(absolutePath);
|
|
161
|
+
if (!realFile.startsWith(realRoot + sep) && realFile !== realRoot) return null;
|
|
158
162
|
const fileStats = await stat(absolutePath);
|
|
159
163
|
if (fileStats.size > MAX_FILE_SIZE) return null;
|
|
160
164
|
const content = await readFile(absolutePath, "utf-8");
|
|
161
|
-
const lines = content.split(
|
|
165
|
+
const lines = content.split(/\r?\n|\r/);
|
|
162
166
|
return {
|
|
163
167
|
absolutePath,
|
|
164
168
|
relativePath,
|
|
@@ -1018,7 +1022,7 @@ async function scan(options) {
|
|
|
1018
1022
|
const summary = summarizeFindings(findings);
|
|
1019
1023
|
return {
|
|
1020
1024
|
version: getVersion(),
|
|
1021
|
-
scannedPath:
|
|
1025
|
+
scannedPath: options.path,
|
|
1022
1026
|
filesScanned: filePaths.length,
|
|
1023
1027
|
scanDurationMs: Math.round(performance.now() - start),
|
|
1024
1028
|
findings,
|