prodlint 0.7.0 → 0.7.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/action.yml CHANGED
@@ -1,152 +1,152 @@
1
- name: 'Prodlint'
2
- description: 'The linter for vibe-coded apps — catch what AI coding tools miss'
3
- branding:
4
- icon: 'shield'
5
- color: 'green'
6
-
7
- inputs:
8
- path:
9
- description: 'Path to scan (default: current directory)'
10
- required: false
11
- default: '.'
12
- threshold:
13
- description: 'Minimum score to pass (0-100). Fails the check if score is below this.'
14
- required: false
15
- default: '0'
16
- ignore:
17
- description: 'Glob patterns to ignore (comma-separated)'
18
- required: false
19
- default: ''
20
- comment:
21
- description: 'Post a PR comment with results (true/false)'
22
- required: false
23
- default: 'true'
24
-
25
- outputs:
26
- score:
27
- description: 'Overall prodlint score (0-100)'
28
- value: ${{ steps.scan.outputs.score }}
29
- critical:
30
- description: 'Number of critical findings'
31
- value: ${{ steps.scan.outputs.critical }}
32
-
33
- runs:
34
- using: 'composite'
35
- steps:
36
- - name: Setup Node.js
37
- uses: actions/setup-node@v4
38
- with:
39
- node-version: '20'
40
-
41
- - name: Run prodlint
42
- id: scan
43
- shell: bash
44
- env:
45
- INPUT_PATH: ${{ inputs.path }}
46
- INPUT_IGNORE: ${{ inputs.ignore }}
47
- run: |
48
- CMD_ARGS=("$INPUT_PATH" "--json")
49
- if [ -n "$INPUT_IGNORE" ]; then
50
- IFS=',' read -ra PATTERNS <<< "$INPUT_IGNORE"
51
- for p in "${PATTERNS[@]}"; do
52
- trimmed=$(echo "$p" | xargs)
53
- CMD_ARGS+=("--ignore" "$trimmed")
54
- done
55
- fi
56
-
57
- OUTPUT=$(npx -y prodlint@latest "${CMD_ARGS[@]}" 2>&1) || true
58
-
59
- SCORE=$(echo "$OUTPUT" | node -e "
60
- const d = JSON.parse(require('fs').readFileSync(0, 'utf8'));
61
- console.log(Number(d.overallScore) || 0);
62
- " 2>/dev/null || echo "0")
63
- SCORE=$(echo "$SCORE" | tr -cd '0-9')
64
-
65
- CRITICAL=$(echo "$OUTPUT" | node -e "
66
- const d = JSON.parse(require('fs').readFileSync(0, 'utf8'));
67
- console.log(Number(d.summary.critical) || 0);
68
- " 2>/dev/null || echo "0")
69
- CRITICAL=$(echo "$CRITICAL" | tr -cd '0-9')
70
-
71
- echo "score=${SCORE:-0}" >> "$GITHUB_OUTPUT"
72
- echo "critical=${CRITICAL:-0}" >> "$GITHUB_OUTPUT"
73
- echo "$OUTPUT" > /tmp/prodlint-result.json
74
-
75
- - name: Generate comment body
76
- if: inputs.comment == 'true' && github.event_name == 'pull_request'
77
- id: comment
78
- shell: bash
79
- env:
80
- SCAN_SCORE: ${{ steps.scan.outputs.score }}
81
- INPUT_THRESHOLD: ${{ inputs.threshold }}
82
- run: |
83
- SCORE="$SCAN_SCORE"
84
- THRESHOLD="$INPUT_THRESHOLD"
85
-
86
- if [ "$SCORE" -ge 80 ]; then
87
- EMOJI="✅"
88
- COLOR="brightgreen"
89
- elif [ "$SCORE" -ge 60 ]; then
90
- EMOJI="⚠️"
91
- COLOR="yellow"
92
- else
93
- EMOJI="🚨"
94
- COLOR="red"
95
- fi
96
-
97
- # Build comment from JSON with markdown sanitization
98
- TMPFILE=$(mktemp "${RUNNER_TEMP:-/tmp}/prodlint-comment-XXXXXX.md")
99
- node -e "
100
- const fs = require('fs');
101
- const d = JSON.parse(fs.readFileSync('/tmp/prodlint-result.json', 'utf8'));
102
- const esc = s => String(s).replace(/[[\]()\\*_\`<>]/g, c => '\\\\' + c);
103
- const lines = [];
104
- lines.push('## ' + '$EMOJI' + ' Prodlint Score: **' + d.overallScore + '/100**');
105
- lines.push('');
106
- lines.push('| Category | Score | Issues |');
107
- lines.push('|----------|-------|--------|');
108
- for (const c of d.categoryScores) {
109
- const icon = c.score >= 80 ? '🟢' : c.score >= 60 ? '🟡' : '🔴';
110
- lines.push('| ' + icon + ' ' + esc(c.category) + ' | ' + c.score + '/100 | ' + c.findingCount + ' |');
111
- }
112
- if (d.summary.critical > 0) {
113
- lines.push('');
114
- lines.push('### Critical Issues');
115
- lines.push('');
116
- const crits = d.findings.filter(f => f.severity === 'critical');
117
- for (const f of crits.slice(0, 10)) {
118
- lines.push('- \`' + esc(f.ruleId) + '\` \`' + esc(f.file) + ':' + f.line + '\` — ' + esc(f.message));
119
- }
120
- if (crits.length > 10) {
121
- lines.push('- ...and ' + (crits.length - 10) + ' more');
122
- }
123
- }
124
- lines.push('');
125
- lines.push('---');
126
- lines.push('*Scanned ' + d.filesScanned + ' files in ' + d.scanDurationMs + 'ms · [prodlint](https://prodlint.com)*');
127
- fs.writeFileSync('$TMPFILE', lines.join('\n'));
128
- "
129
-
130
- # Use the generated file for the comment step
131
- cp "$TMPFILE" /tmp/prodlint-comment.md
132
-
133
- - name: Post PR comment
134
- if: inputs.comment == 'true' && github.event_name == 'pull_request'
135
- uses: marocchino/sticky-pull-request-comment@v2
136
- with:
137
- header: prodlint
138
- path: /tmp/prodlint-comment.md
139
-
140
- - name: Check threshold
141
- if: inputs.threshold != '0'
142
- shell: bash
143
- env:
144
- SCAN_SCORE: ${{ steps.scan.outputs.score }}
145
- INPUT_THRESHOLD: ${{ inputs.threshold }}
146
- run: |
147
- SCORE="$SCAN_SCORE"
148
- THRESHOLD="$INPUT_THRESHOLD"
149
- if [ "$SCORE" -lt "$THRESHOLD" ]; then
150
- echo "::error::Prodlint score ($SCORE) is below threshold ($THRESHOLD)"
151
- exit 1
152
- fi
1
+ name: 'Prodlint'
2
+ description: 'The linter for vibe-coded apps — catch what AI coding tools miss'
3
+ branding:
4
+ icon: 'shield'
5
+ color: 'green'
6
+
7
+ inputs:
8
+ path:
9
+ description: 'Path to scan (default: current directory)'
10
+ required: false
11
+ default: '.'
12
+ threshold:
13
+ description: 'Minimum score to pass (0-100). Fails the check if score is below this.'
14
+ required: false
15
+ default: '0'
16
+ ignore:
17
+ description: 'Glob patterns to ignore (comma-separated)'
18
+ required: false
19
+ default: ''
20
+ comment:
21
+ description: 'Post a PR comment with results (true/false)'
22
+ required: false
23
+ default: 'true'
24
+
25
+ outputs:
26
+ score:
27
+ description: 'Overall prodlint score (0-100)'
28
+ value: ${{ steps.scan.outputs.score }}
29
+ critical:
30
+ description: 'Number of critical findings'
31
+ value: ${{ steps.scan.outputs.critical }}
32
+
33
+ runs:
34
+ using: 'composite'
35
+ steps:
36
+ - name: Setup Node.js
37
+ uses: actions/setup-node@v4
38
+ with:
39
+ node-version: '20'
40
+
41
+ - name: Run prodlint
42
+ id: scan
43
+ shell: bash
44
+ env:
45
+ INPUT_PATH: ${{ inputs.path }}
46
+ INPUT_IGNORE: ${{ inputs.ignore }}
47
+ run: |
48
+ CMD_ARGS=("$INPUT_PATH" "--json")
49
+ if [ -n "$INPUT_IGNORE" ]; then
50
+ IFS=',' read -ra PATTERNS <<< "$INPUT_IGNORE"
51
+ for p in "${PATTERNS[@]}"; do
52
+ trimmed=$(echo "$p" | xargs)
53
+ CMD_ARGS+=("--ignore" "$trimmed")
54
+ done
55
+ fi
56
+
57
+ OUTPUT=$(npx -y prodlint@latest "${CMD_ARGS[@]}" 2>&1) || true
58
+
59
+ SCORE=$(echo "$OUTPUT" | node -e "
60
+ const d = JSON.parse(require('fs').readFileSync(0, 'utf8'));
61
+ console.log(Number(d.overallScore) || 0);
62
+ " 2>/dev/null || echo "0")
63
+ SCORE=$(echo "$SCORE" | tr -cd '0-9')
64
+
65
+ CRITICAL=$(echo "$OUTPUT" | node -e "
66
+ const d = JSON.parse(require('fs').readFileSync(0, 'utf8'));
67
+ console.log(Number(d.summary.critical) || 0);
68
+ " 2>/dev/null || echo "0")
69
+ CRITICAL=$(echo "$CRITICAL" | tr -cd '0-9')
70
+
71
+ echo "score=${SCORE:-0}" >> "$GITHUB_OUTPUT"
72
+ echo "critical=${CRITICAL:-0}" >> "$GITHUB_OUTPUT"
73
+ echo "$OUTPUT" > /tmp/prodlint-result.json
74
+
75
+ - name: Generate comment body
76
+ if: inputs.comment == 'true' && github.event_name == 'pull_request'
77
+ id: comment
78
+ shell: bash
79
+ env:
80
+ SCAN_SCORE: ${{ steps.scan.outputs.score }}
81
+ INPUT_THRESHOLD: ${{ inputs.threshold }}
82
+ run: |
83
+ SCORE="$SCAN_SCORE"
84
+ THRESHOLD="$INPUT_THRESHOLD"
85
+
86
+ if [ "$SCORE" -ge 80 ]; then
87
+ EMOJI="✅"
88
+ COLOR="brightgreen"
89
+ elif [ "$SCORE" -ge 60 ]; then
90
+ EMOJI="⚠️"
91
+ COLOR="yellow"
92
+ else
93
+ EMOJI="🚨"
94
+ COLOR="red"
95
+ fi
96
+
97
+ # Build comment from JSON with markdown sanitization
98
+ TMPFILE=$(mktemp "${RUNNER_TEMP:-/tmp}/prodlint-comment-XXXXXX.md")
99
+ node -e "
100
+ const fs = require('fs');
101
+ const d = JSON.parse(fs.readFileSync('/tmp/prodlint-result.json', 'utf8'));
102
+ const esc = s => String(s).replace(/[[\]()\\*_\`<>]/g, c => '\\\\' + c);
103
+ const lines = [];
104
+ lines.push('## ' + '$EMOJI' + ' Prodlint Score: **' + d.overallScore + '/100**');
105
+ lines.push('');
106
+ lines.push('| Category | Score | Issues |');
107
+ lines.push('|----------|-------|--------|');
108
+ for (const c of d.categoryScores) {
109
+ const icon = c.score >= 80 ? '🟢' : c.score >= 60 ? '🟡' : '🔴';
110
+ lines.push('| ' + icon + ' ' + esc(c.category) + ' | ' + c.score + '/100 | ' + c.findingCount + ' |');
111
+ }
112
+ if (d.summary.critical > 0) {
113
+ lines.push('');
114
+ lines.push('### Critical Issues');
115
+ lines.push('');
116
+ const crits = d.findings.filter(f => f.severity === 'critical');
117
+ for (const f of crits.slice(0, 10)) {
118
+ lines.push('- \`' + esc(f.ruleId) + '\` \`' + esc(f.file) + ':' + f.line + '\` — ' + esc(f.message));
119
+ }
120
+ if (crits.length > 10) {
121
+ lines.push('- ...and ' + (crits.length - 10) + ' more');
122
+ }
123
+ }
124
+ lines.push('');
125
+ lines.push('---');
126
+ lines.push('*Scanned ' + d.filesScanned + ' files in ' + d.scanDurationMs + 'ms · [prodlint](https://prodlint.com)*');
127
+ fs.writeFileSync('$TMPFILE', lines.join('\n'));
128
+ "
129
+
130
+ # Use the generated file for the comment step
131
+ cp "$TMPFILE" /tmp/prodlint-comment.md
132
+
133
+ - name: Post PR comment
134
+ if: inputs.comment == 'true' && github.event_name == 'pull_request'
135
+ uses: marocchino/sticky-pull-request-comment@v2
136
+ with:
137
+ header: prodlint
138
+ path: /tmp/prodlint-comment.md
139
+
140
+ - name: Check threshold
141
+ if: inputs.threshold != '0'
142
+ shell: bash
143
+ env:
144
+ SCAN_SCORE: ${{ steps.scan.outputs.score }}
145
+ INPUT_THRESHOLD: ${{ inputs.threshold }}
146
+ run: |
147
+ SCORE="$SCAN_SCORE"
148
+ THRESHOLD="$INPUT_THRESHOLD"
149
+ if [ "$SCORE" -lt "$THRESHOLD" ]; then
150
+ echo "::error::Prodlint score ($SCORE) is below threshold ($THRESHOLD)"
151
+ exit 1
152
+ fi
package/dist/cli.js CHANGED
@@ -76,7 +76,10 @@ function isTestFile(relativePath) {
76
76
  return /\.(test|spec)\.[jt]sx?$/.test(relativePath) || /(?:^|\/)__tests__\//.test(relativePath) || /(?:^|\/)tests?\//.test(relativePath) || /(?:^|\/)fixtures?\//.test(relativePath) || /(?:^|\/)mocks?\//.test(relativePath);
77
77
  }
78
78
  function isScriptFile(relativePath) {
79
- return /(?:^|\/)scripts?\//.test(relativePath);
79
+ if (/(?:^|\/)scripts?\//.test(relativePath)) return true;
80
+ const name = relativePath.split("/").pop() ?? "";
81
+ const base = name.replace(/\.[^.]+$/, "");
82
+ return /^(seed|migrate|setup|bootstrap|generate|codegen|sync|deploy|cleanup|reset)$/.test(base);
80
83
  }
81
84
  function isConfigFile(relativePath) {
82
85
  const name = relativePath.split("/").pop() ?? "";
@@ -245,6 +248,25 @@ function findLoopsAST(ast) {
245
248
  });
246
249
  return loops;
247
250
  }
251
+ function getImportSources(ast) {
252
+ const sources = [];
253
+ walkAST(ast.program, (node) => {
254
+ if (node.type === "ImportDeclaration") {
255
+ const decl = node;
256
+ sources.push({ source: decl.source.value, line: decl.loc?.start.line ?? 1 });
257
+ }
258
+ if (node.type === "CallExpression") {
259
+ const call = node;
260
+ if (call.callee.type === "Identifier" && call.callee.name === "require" && call.arguments.length === 1 && call.arguments[0].type === "StringLiteral") {
261
+ sources.push({ source: call.arguments[0].value, line: node.loc?.start.line ?? 1 });
262
+ }
263
+ if (call.callee.type === "Import" && call.arguments.length >= 1 && call.arguments[0].type === "StringLiteral") {
264
+ sources.push({ source: call.arguments[0].value, line: node.loc?.start.line ?? 1 });
265
+ }
266
+ }
267
+ });
268
+ return sources;
269
+ }
248
270
  function isUserInputNode(node) {
249
271
  if (node.type === "MemberExpression") {
250
272
  const mem = node;
@@ -439,6 +461,66 @@ async function readFileContext(root, relativePath) {
439
461
  return null;
440
462
  }
441
463
  }
464
+ async function getWorkspacePatterns(root, packageJson) {
465
+ const patterns = [];
466
+ if (packageJson) {
467
+ const workspaces = packageJson.workspaces;
468
+ if (Array.isArray(workspaces)) {
469
+ patterns.push(...workspaces);
470
+ } else if (workspaces && typeof workspaces === "object" && Array.isArray(workspaces.packages)) {
471
+ patterns.push(...workspaces.packages);
472
+ }
473
+ }
474
+ try {
475
+ const raw = await readFile(resolve(root, "pnpm-workspace.yaml"), "utf-8");
476
+ let inPackages = false;
477
+ for (const line of raw.split(/\r?\n/)) {
478
+ const trimmed = line.trim();
479
+ if (/^packages\s*:/.test(trimmed)) {
480
+ inPackages = true;
481
+ continue;
482
+ }
483
+ if (inPackages) {
484
+ if (/^-\s+/.test(trimmed)) {
485
+ const glob = trimmed.replace(/^-\s+/, "").replace(/^['"]|['"]$/g, "");
486
+ if (glob) patterns.push(glob);
487
+ } else if (trimmed && !trimmed.startsWith("#")) {
488
+ break;
489
+ }
490
+ }
491
+ }
492
+ } catch {
493
+ }
494
+ return patterns;
495
+ }
496
+ async function collectWorkspaceDependencies(root, patterns) {
497
+ const deps = /* @__PURE__ */ new Set();
498
+ const globPatterns = patterns.map((p) => `${p}/package.json`);
499
+ try {
500
+ const pkgFiles = await fg(globPatterns, {
501
+ cwd: root,
502
+ absolute: false,
503
+ ignore: ["**/node_modules/**"]
504
+ });
505
+ for (const pkgFile of pkgFiles) {
506
+ try {
507
+ const raw = await readFile(resolve(root, pkgFile), "utf-8");
508
+ const pkg = JSON.parse(raw);
509
+ if (pkg.name) deps.add(pkg.name);
510
+ for (const key of ["dependencies", "devDependencies", "peerDependencies"]) {
511
+ if (pkg[key] && typeof pkg[key] === "object") {
512
+ for (const dep of Object.keys(pkg[key])) {
513
+ deps.add(dep);
514
+ }
515
+ }
516
+ }
517
+ } catch {
518
+ }
519
+ }
520
+ } catch {
521
+ }
522
+ return deps;
523
+ }
442
524
  async function buildProjectContext(root, files) {
443
525
  let packageJson = null;
444
526
  let declaredDependencies = /* @__PURE__ */ new Set();
@@ -457,19 +539,26 @@ async function buildProjectContext(root, files) {
457
539
  ...packageJson?.peerDependencies ?? {}
458
540
  };
459
541
  declaredDependencies = new Set(Object.keys(deps));
460
- for (const dep of declaredDependencies) {
461
- const framework = DEPENDENCY_TO_FRAMEWORK[dep];
462
- if (framework) {
463
- detectedFrameworks.add(framework);
464
- }
542
+ } catch {
543
+ }
544
+ const workspacePatterns = await getWorkspacePatterns(root, packageJson);
545
+ if (workspacePatterns.length > 0) {
546
+ const workspaceDeps = await collectWorkspaceDependencies(root, workspacePatterns);
547
+ for (const dep of workspaceDeps) {
548
+ declaredDependencies.add(dep);
465
549
  }
466
- for (const framework of detectedFrameworks) {
467
- if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
468
- hasRateLimiting = true;
469
- break;
470
- }
550
+ }
551
+ for (const dep of declaredDependencies) {
552
+ const framework = DEPENDENCY_TO_FRAMEWORK[dep];
553
+ if (framework) {
554
+ detectedFrameworks.add(framework);
555
+ }
556
+ }
557
+ for (const framework of detectedFrameworks) {
558
+ if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
559
+ hasRateLimiting = true;
560
+ break;
471
561
  }
472
- } catch {
473
562
  }
474
563
  try {
475
564
  const raw = await readFile(resolve(root, "tsconfig.json"), "utf-8");
@@ -724,6 +813,32 @@ var hallucinatedImportsRule = {
724
813
  const findings = [];
725
814
  const seen = /* @__PURE__ */ new Set();
726
815
  const isNonProd = isTestFile(file.relativePath) || isScriptFile(file.relativePath);
816
+ if (file.ast) {
817
+ try {
818
+ const imports = getImportSources(file.ast);
819
+ for (const { source: importPath, line } of imports) {
820
+ if (importPath.startsWith(".") || importPath.startsWith("/")) continue;
821
+ const pkgName = getPackageName(importPath);
822
+ if (seen.has(pkgName)) continue;
823
+ seen.add(pkgName);
824
+ if (isPathAlias(importPath, project.tsconfigPaths)) continue;
825
+ if (isNodeBuiltin(pkgName)) continue;
826
+ if (IMPLICIT_PACKAGES.has(importPath) || IMPLICIT_PACKAGES.has(pkgName)) continue;
827
+ if (project.declaredDependencies.has(pkgName)) continue;
828
+ findings.push({
829
+ ruleId: "hallucinated-imports",
830
+ file: file.relativePath,
831
+ line,
832
+ column: 1,
833
+ message: `Package "${pkgName}" is imported but not in package.json`,
834
+ severity: isNonProd ? "warning" : "critical",
835
+ category: "reliability"
836
+ });
837
+ }
838
+ return findings;
839
+ } catch {
840
+ }
841
+ }
727
842
  for (let i = 0; i < file.lines.length; i++) {
728
843
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
729
844
  const line = file.lines[i];
@@ -2255,8 +2370,14 @@ function scoreCatchBody(bodyLines) {
2255
2370
  const body = bodyLines.join("\n");
2256
2371
  let score = 0;
2257
2372
  if (/console\.(log|warn|error|info)\s*\(/.test(body)) score = 1;
2258
- if (/console\.(error|warn)\s*\(/.test(body) && /\b(err|error|e)\b/.test(body)) score = 2;
2259
- if (/\bthrow\b/.test(body) || /\breturn\b.*(?:error|err|status|Response|NextResponse|json)/.test(body) || /\bset\w*Error\s*\(/.test(body) || /\bres\.\w+\s*\(/.test(body)) {
2373
+ if ((/console\.(error|warn)\s*\(/.test(body) || /\b(logger|log)\.(error|warn)\s*\(/.test(body)) && /\b(err|error|e)\b/.test(body)) score = 2;
2374
+ if (/\bthrow\b/.test(body) || /\breturn\b.*(?:error|err|status|Response|NextResponse|json)/.test(body) || /\bset\w*Error\s*\(/.test(body) || /\bres\.\w+\s*\(/.test(body) || // Toast notifications (react-toastify, sonner, shadcn)
2375
+ /\btoast\.(error|warn|warning)\s*\(/.test(body) || /\btoast\s*\(\s*\{/.test(body) || // Error monitoring (Sentry, etc.)
2376
+ /\b(Sentry\.)?captureException\s*\(/.test(body) || /\b(Sentry\.)?captureMessage\s*\(/.test(body) || // Structured loggers
2377
+ /\b(logger|log)\.(error|fatal)\s*\(/.test(body) || // Error handler utilities
2378
+ /\b(handleError|reportError|logError|notifyError|showError|onError|trackError)\s*\(/.test(body) || // Express middleware: next(err)
2379
+ /\bnext\s*\(\s*(err|error|e)\s*\)/.test(body) || // Next.js notFound()
2380
+ /\bnotFound\s*\(/.test(body)) {
2260
2381
  score = 3;
2261
2382
  }
2262
2383
  const labels = {
@@ -2288,7 +2409,7 @@ var shallowCatchRule = {
2288
2409
  if (!body || !body.loc) return;
2289
2410
  const bodyStart = body.loc.start.line - 1;
2290
2411
  const bodyEnd = body.loc.end.line - 1;
2291
- const bodyLines = file.lines.slice(bodyStart + 1, bodyEnd);
2412
+ const bodyLines = bodyStart === bodyEnd ? [file.lines[bodyStart]] : file.lines.slice(bodyStart + 1, bodyEnd);
2292
2413
  const { score, label } = scoreCatchBody(bodyLines);
2293
2414
  if (score <= 1) {
2294
2415
  findings.push({