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 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 4 categories:
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 4 categories (security, reliability, performance, ai-quality).
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
- ARGS="${{ inputs.path }}"
46
- if [ -n "${{ inputs.ignore }}" ]; then
47
- IFS=',' read -ra PATTERNS <<< "${{ inputs.ignore }}"
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
- ARGS="$ARGS --ignore \"$(echo $p | xargs)\""
52
+ trimmed=$(echo "$p" | xargs)
53
+ CMD_ARGS+=("--ignore" "$trimmed")
50
54
  done
51
55
  fi
52
56
 
53
- OUTPUT=$(npx -y prodlint@latest $ARGS --json 2>&1) || true
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="${{ steps.scan.outputs.score }}"
75
- THRESHOLD="${{ inputs.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
- BODY=$(node -e "
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('- **' + f.ruleId + '** ' + f.file + ':' + f.line + ' — ' + f.message);
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
- console.log(lines.join('\n'));
117
- ")
127
+ fs.writeFileSync('$TMPFILE', lines.join('\n'));
128
+ "
118
129
 
119
- # Write to file for the comment step
120
- echo "$BODY" > /tmp/prodlint-comment.md
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="${{ steps.scan.outputs.score }}"
134
- THRESHOLD="${{ inputs.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("\n");
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: root,
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("\n");
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: root,
1025
+ scannedPath: options.path,
1022
1026
  filesScanned: filePaths.length,
1023
1027
  scanDurationMs: Math.round(performance.now() - start),
1024
1028
  findings,