@clawhub-charlie-morrison-9e6609396b
Conduct structured self-evaluations after tasks to analyze efficiency, accuracy, approach quality, and extract patterns for improved future performance.
# Task Retrospective Structured self-evaluation for AI agents after completing tasks. Analyze what worked, what failed, and extract reusable patterns to improve future performance. Use after completing complex tasks, debugging sessions, or multi-step workflows. ## Usage ``` Run a retrospective on the task I just completed. ``` Or with specific context: ``` Retrospective: [task description]. Outcome: [success/partial/failure]. Time spent: [duration]. What surprised me: [unexpected findings]. ``` ## How It Works 1. **Reconstruct** — review the task timeline (steps taken, tools used, decisions made) 2. **Evaluate** — score each phase on efficiency, accuracy, and approach quality 3. **Extract** — identify reusable patterns, anti-patterns, and decision heuristics 4. **Record** — generate a structured retrospective for future reference ## Evaluation Dimensions ### Efficiency - Were there unnecessary steps or dead ends? - Could tool calls have been batched or parallelized? - Was the research phase too long or too short? ### Accuracy - Was the final output correct and complete? - Were there false starts or incorrect assumptions? - Did the solution match the actual requirements? ### Approach Quality - Was the problem decomposition effective? - Were the right tools chosen for each step? - Would a different strategy have been faster? ### Learning Extracted - What new patterns can be reused? - What anti-patterns should be avoided? - What domain knowledge was gained? ## Output Format ```markdown ## Task Retrospective ### Summary [1-2 sentences: what was the task, what was the outcome] ### Timeline | Phase | Duration | Verdict | |-------|----------|---------| | Research | Xm | Efficient / Too long / Insufficient | | Planning | Xm | Good / Skipped / Over-planned | | Execution | Xm | Clean / Had rework / Multiple attempts | | Validation | Xm | Thorough / Skipped / Caught issues | ### What Worked - [Pattern that should be repeated] ### What Didn't Work - [Anti-pattern to avoid] → [Better alternative] ### Reusable Patterns - **Pattern name**: [Description of when and how to apply] ### Key Decisions - [Decision point] → [Choice made] → [Outcome: good/bad/neutral] ### Improvement Actions - [ ] [Specific action to improve future performance] ``` ## Advanced Usage ### Compare Approaches ``` Compare my approach to [task] with the ideal approach. What I did: [steps]. What I should have done: [if known]. ``` ### Pattern Library Over time, retrospectives build a pattern library: ``` Review my last 5 retrospectives. What recurring patterns emerge? Which improvement actions have I actually followed through on? ``` ### Team Retrospective ``` Run a retrospective on this multi-agent workflow. Agents involved: [list]. Handoff points: [where work transferred between agents]. Bottlenecks: [where things slowed down]. ```
Create, maintain, and execute detailed incident response runbooks to guide triage, communication, and post-incident reviews for production outages.
# Incident Response Runbook
Generate, maintain, and execute incident response runbooks for production systems. Use when setting up incident workflows, responding to outages, or documenting post-incident learnings.
## Usage
### Generate Runbook
```
Create an incident response runbook for [service/system].
Infrastructure: [cloud provider, key services].
Common failure modes: [list known issues].
```
### During Incident
```
Incident: [description]. Severity: [1-4].
Current symptoms: [what's happening].
Help me triage and respond.
```
### Post-Incident
```
Generate a post-incident review for: [incident summary].
Timeline: [key events with timestamps].
Resolution: [what fixed it].
```
## Runbook Structure
Generated runbooks follow this template:
```markdown
# [Service] Incident Response Runbook
## Quick Reference
- **On-call:** [rotation link]
- **Dashboards:** [monitoring links]
- **Escalation:** [contact chain]
## Severity Levels
- **SEV1**: Complete outage, revenue impact → respond in 5 min
- **SEV2**: Degraded service, user-facing → respond in 15 min
- **SEV3**: Internal impact, no users affected → respond in 1 hour
- **SEV4**: Cosmetic or minor, no urgency → next business day
## Triage Steps
1. Confirm the issue (check dashboards, reproduce)
2. Assess blast radius (which users/services affected)
3. Assign severity level
4. Start incident channel/thread
5. Communicate to stakeholders
## Failure Modes
### [Failure Mode 1: e.g., Database Connection Pool Exhaustion]
**Symptoms:** [what you'll see]
**Diagnosis:** [commands to run, logs to check]
**Mitigation:** [immediate steps to restore service]
**Root Fix:** [permanent solution]
### [Failure Mode 2: e.g., Memory Leak in Worker Process]
...
## Rollback Procedures
[Service-specific rollback steps]
## Communication Templates
[Internal + external status page templates]
## Post-Incident Review Template
[Blameless review structure]
```
## Scripts
### `scripts/generate_runbook.py`
Generate a runbook skeleton from service metadata:
```bash
python3 scripts/generate_runbook.py --service api-gateway \
--provider aws --region us-east-1 \
--monitors datadog,pagerduty \
--output runbook-api-gateway.md
```
## AI Enhancement
When used as an agent skill, the incident responder:
- Guides triage in real-time with diagnostic commands specific to the stack
- Correlates symptoms with known failure modes from the runbook
- Drafts status page updates and internal communications
- Generates post-incident reviews with timeline, root cause analysis, and action items
- Learns from past incidents to improve future runbooks
FILE:scripts/generate_runbook.py
#!/usr/bin/env python3
"""Generate incident response runbook skeleton from service metadata."""
import argparse
from datetime import datetime
TEMPLATE = """# {service} Incident Response Runbook
*Generated: {date}*
*Provider: {provider} | Region: {region}*
## Quick Reference
| Item | Value |
|------|-------|
| Service | {service} |
| Provider | {provider} |
| Region | {region} |
| Monitoring | {monitors} |
| On-call | [Add rotation link] |
| Runbook owner | [Add name] |
## Severity Levels
- **SEV1** — Complete outage, revenue/user impact → respond in **5 min**, all hands
- **SEV2** — Degraded service, user-facing errors → respond in **15 min**, on-call + lead
- **SEV3** — Internal impact, no users affected → respond in **1 hour**, on-call
- **SEV4** — Cosmetic or minor issue → **next business day**
## Triage Checklist
1. [ ] Confirm the issue is real (not a monitoring false positive)
2. [ ] Check dashboards: {monitors}
3. [ ] Identify blast radius (which users/regions/services affected)
4. [ ] Assign severity level
5. [ ] Start incident channel: `#incident-{service_slug}-YYYY-MM-DD`
6. [ ] Post initial status update
## Diagnostic Commands
```bash
# Health check
curl -s https://{service_slug}.example.com/health | jq .
# Recent logs
# AWS: aws logs tail /aws/lambda/{service_slug} --since 30m
# Docker: docker logs {service_slug} --since 30m --tail 500
# K8s: kubectl logs -l app={service_slug} --since=30m --tail=500
# Resource usage
# K8s: kubectl top pods -l app={service_slug}
# Docker: docker stats {service_slug}
```
## Known Failure Modes
### 1. [Add: e.g., Database Connection Exhaustion]
**Symptoms:**
- [What alerts fire]
- [What users see]
**Diagnosis:**
```bash
# [Commands to confirm this specific failure mode]
```
**Mitigation:**
1. [Immediate step to restore service]
2. [Follow-up step]
**Root Fix:**
- [Permanent solution to prevent recurrence]
---
### 2. [Add: e.g., Memory Leak]
**Symptoms:**
- [Gradual response time increase]
- [OOM kills in logs]
**Diagnosis:**
```bash
# [Commands to check memory]
```
**Mitigation:**
1. [Rolling restart]
**Root Fix:**
- [Find and fix the leak]
---
## Rollback Procedure
```bash
# Option 1: Revert to previous deployment
# [deployment-specific rollback command]
# Option 2: Feature flag disable
# [feature flag command]
# Option 3: DNS failover
# [DNS update command]
```
## Communication Templates
### Internal (Slack/Teams)
```
🔴 INCIDENT — {service} — SEV[X]
Impact: [what's broken]
Status: [investigating/mitigating/resolved]
Lead: [name]
Channel: #incident-{service_slug}-[date]
```
### External (Status Page)
```
[Service Name] — [Investigating/Identified/Monitoring/Resolved]
We are aware of issues affecting [description].
Our team is actively investigating.
Updates will be posted every [30 minutes].
Last updated: [time UTC]
```
## Post-Incident Review Template
### Timeline
| Time (UTC) | Event |
|------------|-------|
| HH:MM | [First alert fired] |
| HH:MM | [Incident declared] |
| HH:MM | [Root cause identified] |
| HH:MM | [Mitigation applied] |
| HH:MM | [Service restored] |
### Five Whys
1. Why did the service fail? →
2. Why did that happen? →
3. Why? →
4. Why? →
5. Root cause: →
### Action Items
- [ ] [Preventive action 1] — owner: [name] — due: [date]
- [ ] [Preventive action 2] — owner: [name] — due: [date]
- [ ] [Detection improvement] — owner: [name] — due: [date]
"""
def main():
p = argparse.ArgumentParser(description="Generate incident response runbook")
p.add_argument("--service", required=True, help="Service name")
p.add_argument("--provider", default="generic", help="Cloud provider")
p.add_argument("--region", default="us-east-1", help="Deployment region")
p.add_argument("--monitors", default="custom", help="Monitoring tools (comma-separated)")
p.add_argument("--output", help="Output file path")
args = p.parse_args()
service_slug = args.service.lower().replace(" ", "-")
content = TEMPLATE.format(
service=args.service,
service_slug=service_slug,
provider=args.provider,
region=args.region,
monitors=args.monitors,
date=datetime.now().strftime("%Y-%m-%d"),
)
if args.output:
with open(args.output, "w") as f:
f.write(content)
print(f"Runbook written to {args.output}")
else:
print(content)
if __name__ == "__main__":
main()
Automated pull request review providing detailed feedback on correctness, security, performance, maintainability, testing, and best practices.
# PR Review Assistant
Automated pull request review with structured feedback on code quality, security, performance, and best practices. Use when reviewing PRs, preparing code for review, or setting up automated review workflows.
## Usage
```bash
# Review current branch changes against main
python3 scripts/pr_review.py
# Review specific PR (requires gh CLI)
python3 scripts/pr_review.py --pr 42
# Review staged changes only
python3 scripts/pr_review.py --staged
# Review with specific focus areas
python3 scripts/pr_review.py --focus security,performance
```
## Review Categories
The assistant evaluates code across 6 dimensions:
### 1. Correctness
- Logic errors, off-by-one, null handling
- Missing edge cases
- Incorrect type usage
### 2. Security
- Injection vulnerabilities (SQL, XSS, command)
- Hardcoded secrets or credentials
- Insecure deserialization
- Missing input validation
### 3. Performance
- N+1 queries, unnecessary loops
- Memory leaks, unbounded growth
- Missing indexes on queried fields
- Inefficient algorithms
### 4. Maintainability
- Dead code, unused imports
- Functions doing too much
- Unclear naming
- Missing or excessive comments
### 5. Testing
- Are new code paths covered?
- Missing edge case tests
- Test quality (assertions, mocking)
### 6. Best Practices
- Framework-specific patterns
- Error handling conventions
- API design consistency
- Documentation updates needed
## Output Format
```markdown
## PR Review Summary
**Risk Level:** 🟢 Low / 🟡 Medium / 🔴 High
### Must Fix (blocking)
- [file:line] Description of critical issue
### Should Fix (non-blocking)
- [file:line] Description of improvement
### Consider (optional)
- [file:line] Suggestion for better approach
### Positive Notes
- What was done well
```
## Parameters
| Parameter | Description | Default |
|-----------|-------------|---------|
| `--pr` | GitHub PR number | None (uses diff) |
| `--base` | Base branch to compare | `main` |
| `--staged` | Review staged changes only | false |
| `--focus` | Comma-separated focus areas | All |
| `--severity` | Minimum severity to report | `low` |
| `--format` | Output format: `markdown`, `json`, `github-comment` | `markdown` |
| `--max-files` | Max files to review | 50 |
## AI Enhancement
When used as an agent skill, the AI reviewer:
- Understands project context from surrounding code, not just the diff
- Identifies patterns across multiple changed files
- Suggests specific code fixes, not just descriptions of problems
- Learns from repository conventions and applies them consistently
- Generates review comments in the project's preferred style
FILE:scripts/pr_review.py
#!/usr/bin/env python3
"""Collect PR/diff data for AI-powered code review."""
import subprocess
import argparse
import json
import sys
def run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
return r.stdout.strip(), r.returncode
def get_diff(base="main", staged=False):
if staged:
diff, _ = run(["git", "diff", "--staged"])
else:
diff, _ = run(["git", "diff", f"{base}...HEAD"])
return diff
def get_pr_diff(pr_number):
diff, rc = run(["gh", "pr", "diff", str(pr_number)])
if rc != 0:
print(f"Error: could not fetch PR #{pr_number}. Is `gh` authenticated?", file=sys.stderr)
sys.exit(1)
return diff
def get_changed_files(base="main"):
out, _ = run(["git", "diff", "--name-only", f"{base}...HEAD"])
return out.split("\n") if out else []
def parse_diff_stats(diff):
additions = diff.count("\n+") - diff.count("\n+++")
deletions = diff.count("\n-") - diff.count("\n---")
files = [l.split(" b/", 1)[1] for l in diff.split("\n") if l.startswith("diff --git")]
return {"files_changed": len(files), "additions": additions, "deletions": deletions, "files": files}
def main():
p = argparse.ArgumentParser(description="Collect diff data for PR review")
p.add_argument("--pr", type=int, help="GitHub PR number")
p.add_argument("--base", default="main", help="Base branch")
p.add_argument("--staged", action="store_true", help="Review staged changes")
p.add_argument("--focus", help="Comma-separated focus areas")
p.add_argument("--format", default="markdown", choices=["markdown", "json", "github-comment"])
p.add_argument("--max-files", type=int, default=50)
args = p.parse_args()
if args.pr:
diff = get_pr_diff(args.pr)
else:
diff = get_diff(args.base, args.staged)
if not diff:
print("No changes found.")
return
stats = parse_diff_stats(diff)
if stats["files_changed"] > args.max_files:
print(f"Warning: {stats['files_changed']} files changed (limit: {args.max_files}). Truncating.")
diff_lines = diff.split("\ndiff --git")
diff = "\ndiff --git".join(diff_lines[:args.max_files + 1])
review_data = {
"stats": stats,
"focus": args.focus.split(",") if args.focus else ["correctness", "security", "performance", "maintainability", "testing", "best-practices"],
"diff": diff,
}
if args.format == "json":
print(json.dumps(review_data, indent=2))
else:
print(f"## PR Review Data\n")
print(f"- **Files changed:** {stats['files_changed']}")
print(f"- **Additions:** +{stats['additions']}")
print(f"- **Deletions:** -{stats['deletions']}")
print(f"- **Focus areas:** {', '.join(review_data['focus'])}")
print(f"\n### Changed Files\n")
for f in stats["files"][:args.max_files]:
print(f"- {f}")
print(f"\n### Diff\n```diff\n{diff[:50000]}\n```")
if __name__ == "__main__":
main()
Iteratively improve AI prompts by analyzing, rewriting, comparing, and refining them using structured patterns for clarity, structure, and format compliance.
# Prompt Optimizer Iteratively improve AI prompts through structured evaluation, A/B testing, and feedback-driven refinement. Use when a prompt underperforms, produces inconsistent results, or needs optimization for a specific use case. ## Usage ``` Optimize this prompt: [paste your prompt] ``` Or with context: ``` Optimize this prompt for [goal]. Current issues: [problems]. Target model: [model name]. ``` ## How It Works 1. **Analyze** — identify structural weaknesses (vague instructions, missing constraints, poor examples) 2. **Rewrite** — apply proven prompt engineering patterns (chain-of-thought, few-shot, role-setting, output format) 3. **Compare** — generate before/after evaluation with expected improvement areas 4. **Iterate** — if user provides feedback on the rewritten prompt, refine further ## Optimization Patterns Applied - **Clarity**: Replace ambiguous language with specific, measurable instructions - **Structure**: Add section headers, numbered steps, output format templates - **Constraints**: Add boundaries (length, tone, forbidden patterns, edge cases) - **Examples**: Generate few-shot examples if missing - **Chain-of-thought**: Add reasoning steps for complex tasks - **Role/persona**: Set context-appropriate expertise framing - **Output anchoring**: Specify exact output format (JSON, markdown, etc.) ## Parameters | Parameter | Description | Default | |-----------|-------------|---------| | `goal` | What the prompt should achieve | Inferred from content | | `model` | Target LLM (affects strategy) | General-purpose | | `max_tokens` | Target output length | No limit | | `style` | `concise` / `detailed` / `creative` | `detailed` | | `iterations` | How many refinement passes | 1 | ## Output Format ```markdown ## Analysis [Weaknesses identified in original prompt] ## Optimized Prompt [The improved prompt, ready to copy-paste] ## Changes Made [Bullet list of specific improvements and why] ## Expected Impact [What should improve: consistency, accuracy, relevance, format compliance] ``` ## Advanced Usage ### Batch Optimization ``` Optimize these 3 prompts for the same task, pick the best approach: 1. [prompt A] 2. [prompt B] 3. [prompt C] ``` ### A/B Test Design ``` Create an A/B test for this prompt. Generate variant A (structured) and variant B (conversational). Include 5 test inputs to compare. ``` ### Model-Specific Tuning ``` Optimize this prompt specifically for Claude Sonnet 4.6. Use extended thinking triggers and XML tags. ```
Generate structured changelogs from git history using conventional commits, with support for multiple formats, AI-enhanced descriptions, and customizable ran...
# Git Changelog Generator
Generate structured changelogs from git history. Supports conventional commits, semantic versioning, and multiple output formats. Use when preparing releases, writing release notes, or documenting project history.
## Usage
```bash
# Generate changelog for latest unreleased changes
python3 scripts/generate_changelog.py
# Generate changelog between two tags
python3 scripts/generate_changelog.py --from v1.2.0 --to v1.3.0
# Generate for last N commits
python3 scripts/generate_changelog.py --last 20
# Generate since a date
python3 scripts/generate_changelog.py --since 2026-04-01
```
## Output Formats
```bash
# Markdown (default)
python3 scripts/generate_changelog.py --format markdown
# Keep a Changelog format (keepachangelog.com)
python3 scripts/generate_changelog.py --format keepachangelog
# GitHub Release format
python3 scripts/generate_changelog.py --format github-release
# JSON (for programmatic use)
python3 scripts/generate_changelog.py --format json
```
## How It Works
1. **Collect** — reads git log between specified ranges
2. **Parse** — extracts conventional commit types (feat, fix, refactor, docs, test, chore, perf, ci)
3. **Categorize** — groups changes by type with human-readable headers
4. **Enrich** — adds PR links, issue references, author attribution, breaking change warnings
5. **Format** — outputs in the requested format
## Conventional Commit Support
Parses standard prefixes:
- `feat:` → Features
- `fix:` → Bug Fixes
- `refactor:` → Code Refactoring
- `docs:` → Documentation
- `test:` → Tests
- `perf:` → Performance
- `ci:` → CI/CD
- `chore:` → Maintenance
- `BREAKING CHANGE:` → Breaking Changes (highlighted)
Non-conventional commits are categorized as "Other Changes" with AI-assisted categorization.
## Parameters
| Parameter | Description | Default |
|-----------|-------------|---------|
| `--from` | Start tag/commit | Last tag |
| `--to` | End tag/commit | HEAD |
| `--last` | Last N commits | All since last tag |
| `--since` | Start date (YYYY-MM-DD) | None |
| `--format` | Output format | `markdown` |
| `--output` | Write to file | stdout |
| `--repo` | Repository path | Current directory |
| `--include-authors` | Show commit authors | false |
| `--include-hashes` | Show commit hashes | false |
| `--group-by` | Group by `type` or `scope` | `type` |
## AI Enhancement
When used as an agent skill, the AI can:
- Rewrite terse commit messages into human-readable descriptions
- Identify the most impactful changes and highlight them
- Generate a summary paragraph for release announcements
- Detect breaking changes even without conventional commit markers
- Cross-reference with issue trackers for richer context
FILE:scripts/generate_changelog.py
#!/usr/bin/env python3
"""Generate structured changelogs from git history."""
import subprocess
import re
import json
import argparse
from datetime import datetime
from collections import defaultdict
COMMIT_TYPES = {
"feat": "Features",
"fix": "Bug Fixes",
"refactor": "Code Refactoring",
"docs": "Documentation",
"test": "Tests",
"perf": "Performance",
"ci": "CI/CD",
"chore": "Maintenance",
"style": "Style",
"build": "Build",
}
def git(cmd, repo="."):
result = subprocess.run(
["git", "-C", repo] + cmd,
capture_output=True, text=True, timeout=30
)
return result.stdout.strip()
def get_last_tag(repo):
return git(["describe", "--tags", "--abbrev=0"], repo) or None
def get_commits(repo, from_ref=None, to_ref="HEAD", last=None, since=None):
cmd = ["log", "--format=%H|%s|%an|%aI"]
if last:
cmd.append(f"-{last}")
elif since:
cmd.append(f"--since={since}")
elif from_ref:
cmd.append(f"{from_ref}..{to_ref}")
else:
cmd.append(to_ref)
output = git(cmd, repo)
if not output:
return []
commits = []
for line in output.split("\n"):
parts = line.split("|", 3)
if len(parts) == 4:
commits.append({
"hash": parts[0][:8],
"message": parts[1],
"author": parts[2],
"date": parts[3][:10],
})
return commits
def categorize(commits):
groups = defaultdict(list)
breaking = []
for c in commits:
msg = c["message"]
if "BREAKING CHANGE" in msg or msg.startswith("!"):
breaking.append(c)
matched = False
for prefix, label in COMMIT_TYPES.items():
pattern = rf'^{prefix}(?:\(.+?\))?!?:\s*(.+)'
m = re.match(pattern, msg)
if m:
c["clean_message"] = m.group(1)
scope_m = re.search(rf'^{prefix}\((.+?)\)', msg)
c["scope"] = scope_m.group(1) if scope_m else None
groups[label].append(c)
matched = True
break
if not matched:
c["clean_message"] = msg
c["scope"] = None
groups["Other Changes"].append(c)
return dict(groups), breaking
def format_markdown(groups, breaking, args):
lines = []
version = args.to if args.to != "HEAD" else "Unreleased"
lines.append(f"# {version} ({datetime.now().strftime('%Y-%m-%d')})\n")
if breaking:
lines.append("## Breaking Changes\n")
for c in breaking:
lines.append(f"- {c['clean_message']}")
if args.include_hashes:
lines[-1] += f" ({c['hash']})"
lines.append("")
order = list(COMMIT_TYPES.values()) + ["Other Changes"]
for category in order:
if category not in groups:
continue
lines.append(f"## {category}\n")
for c in groups[category]:
prefix = f"**{c['scope']}**: " if c.get("scope") else ""
entry = f"- {prefix}{c['clean_message']}"
if args.include_authors:
entry += f" (@{c['author']})"
if args.include_hashes:
entry += f" ({c['hash']})"
lines.append(entry)
lines.append("")
return "\n".join(lines)
def format_json(groups, breaking, args):
return json.dumps({"version": args.to, "date": datetime.now().isoformat(),
"breaking_changes": breaking, "categories": groups}, indent=2, default=str)
def main():
p = argparse.ArgumentParser(description="Generate changelog from git history")
p.add_argument("--from", dest="from_ref", help="Start tag/commit")
p.add_argument("--to", default="HEAD", help="End tag/commit")
p.add_argument("--last", type=int, help="Last N commits")
p.add_argument("--since", help="Start date (YYYY-MM-DD)")
p.add_argument("--format", default="markdown", choices=["markdown", "keepachangelog", "github-release", "json"])
p.add_argument("--output", help="Output file path")
p.add_argument("--repo", default=".", help="Repository path")
p.add_argument("--include-authors", action="store_true")
p.add_argument("--include-hashes", action="store_true")
p.add_argument("--group-by", default="type", choices=["type", "scope"])
args = p.parse_args()
from_ref = args.from_ref or get_last_tag(args.repo)
commits = get_commits(args.repo, from_ref, args.to, args.last, args.since)
if not commits:
print("No commits found in the specified range.")
return
groups, breaking = categorize(commits)
if args.format == "json":
output = format_json(groups, breaking, args)
else:
output = format_markdown(groups, breaking, args)
if args.output:
with open(args.output, "w") as f:
f.write(output)
print(f"Changelog written to {args.output}")
else:
print(output)
if __name__ == "__main__":
main()
Analyze error messages and logs to identify root causes of crashes, build failures, or runtime errors and suggest actionable fixes with code examples.
# Error Diagnosis
Analyze error messages, stack traces, and log output to diagnose root causes and suggest fixes. Use when debugging crashes, runtime errors, build failures, or unexpected behavior.
## Usage
```
Diagnose this error: [paste error message or stack trace]
```
Or with context:
```
Diagnose: [error]. Language: [lang]. Framework: [framework]. Recent changes: [what changed].
```
## How It Works
1. **Parse** — extract error type, message, file locations, line numbers from raw output
2. **Classify** — categorize the error (syntax, runtime, dependency, config, permission, network, OOM, etc.)
3. **Trace** — follow the call stack to identify the originating code vs. where the error surfaced
4. **Diagnose** — determine root cause using error patterns, common pitfalls, and framework-specific knowledge
5. **Fix** — provide actionable fix with code snippets
## Supported Error Sources
- **Stack traces**: Python, JavaScript/Node.js, Java, Go, Rust, C/C++, Ruby, PHP
- **Build errors**: npm, pip, cargo, gradle, maven, webpack, vite, tsc
- **Runtime errors**: segfaults, OOM, deadlocks, race conditions, type errors
- **Infrastructure**: Docker, Kubernetes, systemd, nginx, database connection errors
- **CI/CD**: GitHub Actions, GitLab CI, CircleCI failure logs
## Output Format
```markdown
## Error Type
[Classification: e.g., "TypeError — accessing property of undefined"]
## Root Cause
[1-2 sentences explaining WHY this happened]
## Fix
[Code snippet or command to resolve]
## Prevention
[How to avoid this in the future: type check, test, lint rule, etc.]
```
## Advanced Features
### Multi-Error Analysis
Paste multiple errors — the skill identifies whether they share a root cause or are independent issues.
### Regression Detection
```
This error started after [commit/change]. Analyze whether the change could cause this.
```
### Environment Comparison
```
Works in dev, fails in prod. Error: [error]. Dev config: [config]. Prod config: [config].
```
## Scripts
### `scripts/parse_stacktrace.py`
Extracts structured data from raw stack traces:
```bash
python3 scripts/parse_stacktrace.py < error.log
```
Returns JSON with error type, message, frames (file, line, function), and suggested search queries.
FILE:scripts/parse_stacktrace.py
#!/usr/bin/env python3
"""Parse stack traces from various languages into structured JSON."""
import sys
import re
import json
def parse_python(text):
frames = []
for m in re.finditer(r'File "([^"]+)", line (\d+), in (\w+)', text):
frames.append({"file": m.group(1), "line": int(m.group(2)), "function": m.group(3)})
err_match = re.search(r'^(\w+Error|\w+Exception|KeyboardInterrupt): (.+)$', text, re.MULTILINE)
if not err_match:
err_match = re.search(r'^(\w+Error|\w+Exception)$', text, re.MULTILINE)
error_type = err_match.group(1) if err_match else "Unknown"
error_msg = err_match.group(2) if err_match and err_match.lastindex >= 2 else ""
return {"language": "python", "error_type": error_type, "message": error_msg, "frames": frames}
def parse_javascript(text):
frames = []
for m in re.finditer(r'at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?', text):
frames.append({
"function": m.group(1) or "<anonymous>",
"file": m.group(2), "line": int(m.group(3)), "column": int(m.group(4))
})
err_match = re.search(r'^(\w+Error|\w+Exception): (.+)$', text, re.MULTILINE)
error_type = err_match.group(1) if err_match else "Unknown"
error_msg = err_match.group(2) if err_match else ""
return {"language": "javascript", "error_type": error_type, "message": error_msg, "frames": frames}
def parse_java(text):
frames = []
for m in re.finditer(r'at ([\w.$]+)\(([\w.]+):(\d+)\)', text):
frames.append({"function": m.group(1), "file": m.group(2), "line": int(m.group(3))})
err_match = re.search(r'^([\w.]+(?:Error|Exception)): (.+)$', text, re.MULTILINE)
error_type = err_match.group(1) if err_match else "Unknown"
error_msg = err_match.group(2) if err_match else ""
return {"language": "java", "error_type": error_type, "message": error_msg, "frames": frames}
def parse_go(text):
frames = []
for m in re.finditer(r'([\w/.-]+\.go):(\d+)', text):
frames.append({"file": m.group(1), "line": int(m.group(2))})
err_match = re.search(r'(?:panic|fatal error): (.+)', text)
error_type = "panic" if "panic:" in text else "fatal error" if "fatal error:" in text else "error"
error_msg = err_match.group(1) if err_match else ""
return {"language": "go", "error_type": error_type, "message": error_msg, "frames": frames}
def detect_and_parse(text):
if re.search(r'File ".*", line \d+', text):
return parse_python(text)
if re.search(r'at .+:\d+:\d+', text):
return parse_javascript(text)
if re.search(r'at [\w.$]+\([\w.]+:\d+\)', text):
return parse_java(text)
if re.search(r'\.go:\d+', text):
return parse_go(text)
err_match = re.search(r'(?:error|Error|ERROR)[:\s]+(.+)', text)
return {
"language": "unknown",
"error_type": "Error",
"message": err_match.group(1).strip() if err_match else text.strip()[:200],
"frames": []
}
if __name__ == "__main__":
text = sys.stdin.read()
result = detect_and_parse(text)
result["search_queries"] = [
f'{result["language"]} {result["error_type"]} {result["message"][:80]}',
f'{result["error_type"]} {result["message"][:50]} fix',
]
print(json.dumps(result, indent=2))
Database migration safety reviewer — detect locks, data loss risks, missing rollback plans, and performance issues in SQL and ORM migrations before they hit...
--- name: migration-safety-checker description: Database migration safety reviewer — detect locks, data loss risks, missing rollback plans, and performance issues in SQL and ORM migrations before they hit production. --- # Migration Safety Checker Review database migrations for safety issues before they run in production. Catches table locks, data loss, missing rollbacks, and performance problems that would otherwise cause outages. Use when: "review this migration", "is this migration safe", "check before we deploy", or when reviewing a PR that contains migration files. ## Step 1 — Find Migrations ```bash # Common migration locations find . -type f \( \ -path "*/migrations/*.sql" -o \ -path "*/migrations/*.py" -o \ -path "*/migrate/*.sql" -o \ -path "*/db/migrate/*.rb" -o \ -path "*/alembic/versions/*.py" -o \ -path "*/prisma/migrations/*" -o \ -path "*/drizzle/*.sql" -o \ -path "*/knex/migrations/*" -o \ -path "*/flyway/*.sql" -o \ -path "*/liquibase/*.xml" -o \ -path "*/sequelize/migrations/*" \ \) -not -path '*/node_modules/*' 2>/dev/null | sort | tail -10 # Latest migration (the one being reviewed) git diff --name-only HEAD~1 | grep -i migrat ``` ## Step 2 — Check for Dangerous Operations Read the migration file and check for each of these issues: ### Locking Risks (HIGH) | Operation | Risk | Fix | |-----------|------|-----| | `ALTER TABLE ... ADD COLUMN ... NOT NULL` | Full table lock on large tables (PostgreSQL <11, MySQL) | Add column as nullable first, backfill, then add constraint | | `ALTER TABLE ... ADD COLUMN ... DEFAULT` | Rewrites entire table (PostgreSQL <11) | Add without default, backfill in batches, then set default | | `CREATE INDEX` | Blocks writes for duration of index build | Use `CREATE INDEX CONCURRENTLY` (Postgres) or `ALGORITHM=INPLACE` (MySQL) | | `ALTER TABLE ... ALTER COLUMN TYPE` | Full table rewrite + exclusive lock | Create new column, backfill, swap — never alter type on large tables | | `ALTER TABLE ... ADD CONSTRAINT` | Validates all rows (lock) | Use `NOT VALID` then `VALIDATE CONSTRAINT` separately (Postgres) | | `LOCK TABLE` | Explicit lock — why? | Almost never needed; remove or justify | ### Data Loss Risks (CRITICAL) | Operation | Risk | Check | |-----------|------|-------| | `DROP TABLE` | Permanent data deletion | Is there a backup? Is the table truly unused? | | `DROP COLUMN` | Column data lost | Is the column read anywhere? Check app code | | `TRUNCATE` | All data deleted | Should this be in a migration at all? | | `DELETE FROM` without WHERE | Deletes everything | Missing WHERE clause? | | `ALTER COLUMN ... TYPE` with cast | Possible data truncation | Do current values fit the new type? | | `DROP INDEX` | Query performance regression | Was this index used? Check query plans | ### Rollback Issues (MEDIUM) | Issue | Check | |-------|-------| | No down/rollback migration | Every `up` must have a `down` | | Irreversible `down` | `DROP TABLE` can't be rolled back with data | | Data migration without reverse | If you transformed data, can you reverse it? | | Schema + data in one migration | Split them — data migrations should be separately rollbackable | ### Performance Issues (MEDIUM) | Pattern | Issue | Fix | |---------|-------|-----| | Backfill in migration | Blocks deployment, holds transaction | Use background jobs for large backfills | | No batching | One giant UPDATE/INSERT | Batch in chunks of 1000-5000 | | Multiple ALTERs on same table | Each one locks separately | Combine into one ALTER when possible | | Large DEFAULT values | Rewrites table | Add column, then set default separately | ## Step 3 — ORM-Specific Checks ### Django / Alembic (Python) ```python # Watch for these in migration files: # - RunPython without reverse_code → no rollback # - AddField with default on large table → lock # - AlterField changing type → rewrite # - RemoveField → data loss # - RunSQL without reverse_sql → no rollback ``` ### Rails (Ruby) ```ruby # Watch for: # - add_column with null: false without default → fails on existing rows # - add_index without algorithm: :concurrently → table lock # - change_column → type change, possible lock # - remove_column → data loss # - No reversible block or no down method ``` ### Prisma / Drizzle / Knex (Node.js) ``` # Watch for: # - Column made required (@required/NOT NULL) without default # - Type changes on existing columns # - Dropped columns or tables # - No migration squashing on 50+ migration files ``` ## Step 4 — Production Readiness Check these before approving: - [ ] Migration runs in a transaction (or explicitly opted out with reason) - [ ] Estimated row count for affected tables (< 1M rows = usually safe, > 10M = needs careful planning) - [ ] Tested on staging with production-size data - [ ] Rollback tested (run down migration, verify data intact) - [ ] Application code is backward-compatible with both old and new schema - [ ] Deploy order: schema first (additive), then code, then cleanup migration ## Output Template ```markdown # Migration Review: [filename] ## Safety Rating: 🟢 Safe / 🟡 Caution / 🔴 Dangerous ## Operations | # | Operation | Table | Risk | Issue | |---|-----------|-------|------|-------| | 1 | ADD COLUMN | users | 🟡 | NOT NULL without default — will lock on 2M rows | | 2 | CREATE INDEX | orders | 🔴 | Not CONCURRENTLY — will block writes on 5M rows | ## Recommendations 1. [specific fix for each issue] ## Rollback Plan - [does a rollback migration exist?] - [is it tested?] - [any data loss on rollback?] ## Estimated Impact - Tables affected: X - Estimated lock time: ~Xs on [table] ([row count] rows) - Downtime required: yes/no ``` ## Notes - PostgreSQL 11+ handles `ADD COLUMN ... DEFAULT` without a table rewrite (but NOT NULL still needs `NOT VALID` trick) - MySQL 8.0+ supports instant `ADD COLUMN` at the end of the table - Always check the actual row count: `SELECT reltuples FROM pg_class WHERE relname = 'table_name'` - For zero-downtime deploys, follow the expand/contract pattern: add new → backfill → migrate code → drop old
Architecture Decision Record generator — analyze codebases and document technical decisions with context, alternatives, and consequences. Use when asked to d...
---
name: adr-generator
description: Architecture Decision Record generator — analyze codebases and document technical decisions with context, alternatives, and consequences. Use when asked to document architecture decisions, create ADRs, or explain why technical choices were made.
---
# Architecture Decision Record Generator
Analyze a codebase or conversation to produce Architecture Decision Records (ADRs) — structured documents that capture the WHY behind technical choices so future developers understand the reasoning.
Use when: "document this decision", "create an ADR", "why did we choose X", "record our architecture decision", or when a significant technical choice is being made.
## What is an ADR?
A short document capturing one significant architectural decision: the context, the decision itself, the alternatives considered, and the consequences. ADRs form a decision log that prevents the same debates from recurring and helps new team members understand the codebase.
## When to Create an ADR
- Choosing a framework, database, or major library
- Defining API contracts or data schemas
- Setting team conventions (testing strategy, branching model, deployment process)
- Making a trade-off (performance vs maintainability, monolith vs microservices)
- Adopting or dropping a tool
- Any decision someone might later ask "why did we do it this way?"
## Analysis Steps
### 1. Identify the Decision
From conversation or code review, extract:
- What was decided
- When (date or PR/commit reference)
- Who was involved (if known)
### 2. Reconstruct Context
```bash
# Check git history for related changes
git log --oneline --all --grep="<keyword>" | head -20
# Find when a dependency was added
git log --all --diff-filter=A -- package.json | head -5
git log -p --all -S '<package-name>' -- package.json | head -40
# Look for prior discussion in docs
grep -ri "decision\|chose\|alternative\|trade-off\|migrate" docs/ README.md CONTRIBUTING.md 2>/dev/null
# Check for existing ADRs
find . -type f -name "*.md" -path "*/adr/*" -o -name "*decision*" 2>/dev/null
ls docs/adr/ docs/decisions/ doc/architecture/ 2>/dev/null
```
### 3. Analyze Alternatives
For framework/library decisions:
```bash
# What else was evaluated? Check for traces
grep -ri "considered\|vs\|compared\|evaluated\|alternative" docs/ 2>/dev/null
git log --all --oneline | grep -i "try\|experiment\|spike\|poc\|prototype" | head -10
# Check if multiple solutions were tried
git log --all --oneline --diff-filter=D -- '**/package.json' | head -10
```
### 4. Assess Consequences
Read the current implementation to understand what the decision enabled or constrained:
```bash
# How deeply is the choice embedded?
grep -rc "<framework-import>" --include="*.{ts,js,py,go}" . 2>/dev/null | sort -t: -k2 -rn | head -10
# Are there workarounds that suggest regret?
grep -ri "hack\|workaround\|todo\|fixme\|technical debt" --include="*.{ts,js,py,go}" . 2>/dev/null | head -20
```
## ADR Template
Use the Michael Nygard format (industry standard):
```markdown
# ADR-{NNN}: {Title — short, noun-phrase}
**Date:** YYYY-MM-DD
**Status:** Proposed | Accepted | Deprecated | Superseded by ADR-XXX
**Deciders:** [names or roles]
## Context
What is the issue that we're seeing that is motivating this decision or change?
Describe the forces at play: technical constraints, business requirements, team capabilities, timeline pressure.
## Decision
State the decision clearly in full sentences.
"We will use PostgreSQL as our primary database."
"We will adopt a monorepo structure using Turborepo."
## Alternatives Considered
### Alternative A: [name]
- **Pros:** ...
- **Cons:** ...
- **Why not:** ...
### Alternative B: [name]
- **Pros:** ...
- **Cons:** ...
- **Why not:** ...
## Consequences
### Positive
- What becomes easier or possible because of this decision
### Negative
- What becomes harder, more expensive, or is now ruled out
- What technical debt does this introduce
### Risks
- What could go wrong
- Under what conditions would we reconsider this decision
## References
- Links to relevant PRs, issues, benchmarks, or external resources
```
## File Organization
Standard locations (create if none exist):
```
docs/adr/
0001-use-postgresql.md
0002-adopt-monorepo.md
0003-api-versioning-strategy.md
README.md # index of all ADRs with one-line summaries
```
Index format for README.md:
```markdown
# Architecture Decision Records
| # | Decision | Status | Date |
|---|----------|--------|------|
| 1 | [Use PostgreSQL](0001-use-postgresql.md) | Accepted | 2026-01-15 |
| 2 | [Adopt monorepo](0002-adopt-monorepo.md) | Accepted | 2026-02-01 |
```
## Tips
- Keep ADRs short — 1-2 pages max. If it's longer, the decision is too big (split it).
- Write ADRs at decision time, not after. Retrospective ADRs lose the "alternatives considered" context.
- ADRs are immutable once accepted. If a decision changes, create a new ADR that supersedes the old one.
- Number them sequentially. Never reuse numbers.
- Store them in the repo, next to the code they govern.
- Review ADRs in PRs — they deserve the same scrutiny as code.
Multi-ecosystem dependency audit — find outdated, vulnerable, unused, and license-incompatible packages across npm, pip, cargo, go, and composer. Use when as...
---
name: dependency-health-check
description: Multi-ecosystem dependency audit — find outdated, vulnerable, unused, and license-incompatible packages across npm, pip, cargo, go, and composer. Use when asked to check dependency health, audit packages, or plan upgrades.
---
# Dependency Health Check
Audit project dependencies across ecosystems for security, freshness, license compliance, and unused bloat. Produces a prioritized upgrade plan with risk assessment.
Use when: "check our dependencies", "are we up to date", "audit packages", "plan an upgrade", "find unused deps".
## Step 1 — Detect Ecosystem
```bash
# Auto-detect package managers
ls package.json package-lock.json yarn.lock pnpm-lock.yaml 2>/dev/null # Node.js
ls requirements.txt Pipfile pyproject.toml setup.py 2>/dev/null # Python
ls Cargo.toml Cargo.lock 2>/dev/null # Rust
ls go.mod go.sum 2>/dev/null # Go
ls composer.json composer.lock 2>/dev/null # PHP
ls Gemfile Gemfile.lock 2>/dev/null # Ruby
```
## Step 2 — Outdated Packages
### Node.js
```bash
npm outdated --json 2>/dev/null | jq 'to_entries[] | {name: .key, current: .value.current, wanted: .value.wanted, latest: .value.latest}'
# or
yarn outdated --json 2>/dev/null
pnpm outdated --format json 2>/dev/null
```
### Python
```bash
pip list --outdated --format json 2>/dev/null | jq '.[] | {name, version, latest_version}'
# or with pip-audit
pip-audit --format json 2>/dev/null
```
### Rust
```bash
cargo outdated -R --format json 2>/dev/null
```
### Go
```bash
go list -u -m -json all 2>/dev/null | jq 'select(.Update) | {Path, Version, Update: .Update.Version}'
```
### PHP
```bash
composer outdated --format json 2>/dev/null
```
## Step 3 — Vulnerability Scan
```bash
# Node.js
npm audit --json 2>/dev/null | jq '.vulnerabilities | to_entries[] | {name: .key, severity: .value.severity, fixAvailable: .value.fixAvailable}'
# Python
pip-audit --format json 2>/dev/null
# or
safety check --json 2>/dev/null
# Rust
cargo audit --json 2>/dev/null
# Go
govulncheck ./... 2>/dev/null
# Universal (if installed)
trivy fs --format json --scanners vuln . 2>/dev/null | jq '.Results[].Vulnerabilities[]? | {PkgName, Severity, Title}'
```
## Step 4 — Unused Dependencies
### Node.js
```bash
# depcheck finds unused deps
npx depcheck --json 2>/dev/null | jq '{unused: .dependencies, devUnused: .devDependencies, missing: .missing}'
```
### Python
```bash
# Check imports vs requirements
pip install pipreqs 2>/dev/null
pipreqs . --print 2>/dev/null > /tmp/actual-imports.txt
diff <(sort requirements.txt | sed 's/[>=<].*//' | tr '[:upper:]' '[:lower:]') \
<(sort /tmp/actual-imports.txt | sed 's/[>=<].*//' | tr '[:upper:]' '[:lower:]')
```
### Rust
```bash
cargo udeps 2>/dev/null # requires nightly
```
## Step 5 — License Audit
```bash
# Node.js
npx license-checker --json 2>/dev/null | jq 'to_entries[] | {pkg: .key, license: .value.licenses}' | head -40
# Python
pip-licenses --format json 2>/dev/null | jq '.[] | {Name, License}'
# Universal
trivy fs --format json --scanners license . 2>/dev/null
```
Flag: GPL in MIT projects, AGPL in SaaS, unknown/unlicensed packages, dual-license packages.
## Step 6 — Risk Assessment
For each outdated dependency, evaluate:
1. **Severity**: critical (known CVE) > high (>2 major versions behind) > medium (minor behind) > low (patch behind)
2. **Breaking changes**: check the changelog/release notes for breaking changes between current and latest
3. **Usage frequency**: grep for imports — a heavily-used dep is riskier to upgrade
4. **Test coverage**: if the dep's area has good tests, the upgrade is safer
## Output Template
```markdown
# Dependency Health Report
**Project:** [name]
**Scanned:** [date]
**Ecosystems:** Node.js, Python, etc.
## Summary
- Total dependencies: X
- Outdated: X (Y critical, Z major behind)
- Vulnerabilities: X (Y critical, Z high)
- Unused: X (safe to remove)
- License issues: X
## Critical (fix now)
| Package | Current | Latest | Issue | Risk |
|---------|---------|--------|-------|------|
| lodash | 4.17.20 | 4.17.21 | CVE-2021-23337 (prototype pollution) | High — used in 47 files |
## Recommended Upgrades (this sprint)
| Package | Current | Latest | Breaking Changes | Effort |
|---------|---------|--------|-----------------|--------|
| react | 17.0.2 | 18.3.1 | Yes — concurrent mode, new root API | 2-4 hours |
## Safe Quick Wins (patch updates)
Packages that can be bumped with minimal risk:
- `axios`: 1.6.0 → 1.7.2 (bug fixes only)
- `dotenv`: 16.3.1 → 16.4.5 (no breaking changes)
## Unused (remove)
- `moment` — imported nowhere, replaced by date-fns
- `@types/express` — no Express code found
## License Flags
- `[email protected]`: GPL-3.0 in MIT project — review compatibility
```
## Upgrade Workflow
After the audit:
1. Fix critical vulnerabilities first (`npm audit fix`, `pip-audit --fix`)
2. Remove unused dependencies
3. Batch patch updates into one PR
4. Plan major upgrades individually with dedicated PRs
5. Run tests after each upgrade batch
## Notes
- Always run the project's test suite after upgrades
- For monorepos, audit each workspace separately
- `npm audit fix --force` can introduce breaking changes — prefer targeted fixes
- Check `CHANGELOG.md` or GitHub releases for each major version jump
AI-powered codebase analysis — generate architecture docs, onboarding guides, and key-flow walkthroughs for any project. Use when joining a new codebase, onb...
---
name: codebase-onboarder
description: AI-powered codebase analysis — generate architecture docs, onboarding guides, and key-flow walkthroughs for any project. Use when joining a new codebase, onboarding a team member, or documenting an undocumented project.
---
# Codebase Onboarder
Analyze any codebase and produce a structured onboarding guide. Covers architecture, key flows, patterns, dependencies, entry points, and gotchas — the things that take weeks to figure out by reading code.
Use when: someone says "help me understand this codebase", "onboard me", "document this project", or "what does this repo do".
## Analysis Steps
Run these in order. Each step informs the next.
### 1. Project Identity
```bash
# What is this?
cat README.md 2>/dev/null || cat readme.md 2>/dev/null
cat package.json 2>/dev/null | jq '{name, description, scripts}'
cat pyproject.toml 2>/dev/null | head -30
cat Cargo.toml 2>/dev/null | head -20
cat go.mod 2>/dev/null | head -10
cat Makefile 2>/dev/null | head -40
```
Determine: language, framework, purpose, build system.
### 2. Project Structure
```bash
# Directory tree (depth 3, ignore noise)
find . -maxdepth 3 -type d \
-not -path '*/node_modules/*' \
-not -path '*/.git/*' \
-not -path '*/vendor/*' \
-not -path '*/__pycache__/*' \
-not -path '*/dist/*' \
-not -path '*/build/*' \
-not -path '*/.next/*' \
-not -path '*/target/*' \
| head -80
# Count files by extension
find . -type f -not -path '*/node_modules/*' -not -path '*/.git/*' \
| sed 's/.*\.//' | sort | uniq -c | sort -rn | head -15
```
Map the architecture: where does business logic live, where are configs, where are tests, what's the convention.
### 3. Entry Points
```bash
# Web apps
grep -rl "listen\|createServer\|app\.run\|uvicorn\|Flask(__name__)" --include="*.{js,ts,py,go,rb}" . 2>/dev/null | head -10
# CLI tools
grep -rl "if __name__\|func main\|fn main\|bin.*:" --include="*.{py,go,rs,json}" . 2>/dev/null | head -10
# Config-declared entry points
cat package.json 2>/dev/null | jq '.main, .bin, .scripts.start, .scripts.dev'
cat pyproject.toml 2>/dev/null | grep -A5 'scripts\|entry_points'
```
Identify: where does execution start, what are the main scripts/commands, how do you run it locally.
### 4. Dependencies & Stack
```bash
# Key dependencies (not all — just the important ones)
cat package.json 2>/dev/null | jq '.dependencies | keys' | head -20
cat requirements.txt 2>/dev/null | head -20
cat go.mod 2>/dev/null | grep -v '//' | tail -20
cat Cargo.toml 2>/dev/null | grep -A50 '\[dependencies\]' | head -30
```
Identify: database (postgres, mongo, redis), framework (express, fastapi, gin), ORM, auth, queue, cloud SDKs. These define the project's personality.
### 5. Data Layer
```bash
# Database schemas, migrations, models
find . -type f \( -name "*.sql" -o -name "*migration*" -o -name "*schema*" -o -name "*model*" \) \
-not -path '*/node_modules/*' 2>/dev/null | head -20
# ORM models
grep -rl "class.*Model\|@Entity\|schema\.\|CREATE TABLE\|db\.Column" \
--include="*.{py,ts,js,go,rb,java}" . 2>/dev/null | head -10
```
Map: what are the core data entities, how are they related, where do migrations live.
### 6. API Surface
```bash
# REST routes
grep -rn "app\.\(get\|post\|put\|delete\|patch\)\|@app\.route\|router\.\(get\|post\)\|@Get\|@Post\|@Controller" \
--include="*.{ts,js,py,go,rb,java}" . 2>/dev/null | head -30
# GraphQL
find . -name "*.graphql" -o -name "*.gql" -o -name "*schema*" -name "*.graphql" 2>/dev/null | head -10
grep -rl "type Query\|type Mutation\|@Query\|@Mutation" --include="*.{ts,js,py,go}" . 2>/dev/null | head -10
```
List the key endpoints/operations, grouped by domain.
### 7. Config & Environment
```bash
# Environment variables
cat .env.example 2>/dev/null || cat .env.sample 2>/dev/null || cat .env.template 2>/dev/null
grep -rh "process\.env\.\|os\.environ\|os\.getenv\|env::\|std::env" \
--include="*.{ts,js,py,go,rs,rb}" . 2>/dev/null | sort -u | head -30
```
Document: what env vars are needed, which are secrets, what services need to be running.
### 8. Testing
```bash
# Test structure
find . -type f \( -name "*test*" -o -name "*spec*" -o -name "*_test.*" \) \
-not -path '*/node_modules/*' 2>/dev/null | head -20
# How to run tests
cat package.json 2>/dev/null | jq '.scripts.test'
grep -r "pytest\|jest\|mocha\|vitest\|go test\|cargo test" Makefile* 2>/dev/null
```
### 9. CI/CD & Deployment
```bash
ls -la .github/workflows/ 2>/dev/null
ls -la .gitlab-ci.yml 2>/dev/null
cat Dockerfile 2>/dev/null | head -20
cat docker-compose.yml 2>/dev/null | head -30
ls -la k8s/ kubernetes/ helm/ 2>/dev/null
```
## Output Template
After analysis, produce a document with these sections:
```markdown
# [Project Name] — Onboarding Guide
## What This Is
One paragraph: what it does, who it's for, what problem it solves.
## Tech Stack
- Language: X
- Framework: X
- Database: X
- Key dependencies: X, Y, Z
## Architecture
Describe the high-level architecture in 3-5 sentences. Include a simple diagram if helpful:
- Monolith / microservices / serverless
- Request flow: client → API → service → database
- Key patterns: MVC, event-driven, CQRS, etc.
## Directory Map
| Path | Purpose |
|------|---------|
| src/api/ | REST endpoints |
| src/services/ | Business logic |
| src/models/ | Database models |
| ... | ... |
## Key Flows
Walk through 2-3 critical user journeys:
1. **User signup** — POST /auth/register → validate → hash password → insert user → send email → return token
2. **Place order** — POST /orders → check inventory → charge payment → create order → notify warehouse
## Getting Started
Step-by-step: clone, install, configure env, seed database, run locally.
## Gotchas
Things that are non-obvious, surprising, or likely to trip someone up:
- "The auth middleware silently returns 200 on missing tokens (legacy behavior)"
- "Tests require a running Redis instance on port 6380 (not default)"
- "The migration in 0042 takes 20 minutes on large datasets"
## Where to Look
| I want to... | Look at... |
|--------------|-----------|
| Add an API endpoint | src/api/routes/ |
| Change the database schema | src/models/ + migrations/ |
| Debug auth issues | src/middleware/auth.ts |
| Understand the build | Makefile + .github/workflows/ |
```
## Tips
- Read tests first — they document behavior better than comments
- Check git log for the most-changed files — those are the hot paths
- Look at recent PRs for coding conventions and review standards
- If something is confusing, it's a gotcha worth documenting
Slack messaging — send messages, manage channels, upload files, add reactions, and automate team notifications via CLI and API.
---
name: slack-integration
description: Slack messaging — send messages, manage channels, upload files, add reactions, and automate team notifications via CLI and API.
metadata: {"openclaw":{"requires":{"env":["SLACK_TOKEN"]}}}
---
# Slack Integration
Send messages, manage channels, upload files, and automate notifications in Slack workspaces using the Web API. Works with bot tokens or user tokens.
## Setup
```bash
# 1. Create a Slack App: https://api.slack.com/apps → Create New App
# 2. Add scopes under OAuth & Permissions:
# Bot Token Scopes: chat:write, channels:read, channels:history,
# files:write, reactions:write, users:read
# 3. Install to workspace → copy Bot User OAuth Token
export SLACK_TOKEN="xoxb-..." # Bot token (starts with xoxb-)
```
For user-level actions (DMs to yourself, custom status), use a User OAuth Token (`xoxp-...`) instead.
## Sending Messages
```bash
# Simple text message
curl -s -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer $SLACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel": "C0123ABCDEF", "text": "Deploy complete ✓"}'
# With rich formatting (Block Kit)
curl -s -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer $SLACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"channel": "C0123ABCDEF",
"blocks": [
{"type": "header", "text": {"type": "plain_text", "text": "Deploy Report"}},
{"type": "section", "text": {"type": "mrkdwn", "text": "*Status:* ✅ Success\n*Version:* v2.1.0\n*Duration:* 45s"}}
]
}'
# Reply in thread
curl -s -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer $SLACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel": "C0123ABCDEF", "thread_ts": "1234567890.123456", "text": "Fix deployed"}'
# Schedule a message (Unix timestamp)
curl -s -X POST https://slack.com/api/chat.scheduleMessage \
-H "Authorization: Bearer $SLACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel": "C0123ABCDEF", "text": "Standup time!", "post_at": 1700000000}'
# Update a message
curl -s -X POST https://slack.com/api/chat.update \
-H "Authorization: Bearer $SLACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel": "C0123ABCDEF", "ts": "1234567890.123456", "text": "Updated text"}'
```
## Channels
```bash
# List channels (paginated — follow next_cursor)
curl -s "https://slack.com/api/conversations.list?types=public_channel,private_channel&limit=200" \
-H "Authorization: Bearer $SLACK_TOKEN" | jq '.channels[] | {id, name, num_members}'
# Get channel info
curl -s "https://slack.com/api/conversations.info?channel=C0123ABCDEF" \
-H "Authorization: Bearer $SLACK_TOKEN"
# Channel history (recent messages)
curl -s "https://slack.com/api/conversations.history?channel=C0123ABCDEF&limit=10" \
-H "Authorization: Bearer $SLACK_TOKEN" | jq '.messages[] | {ts, text, user}'
# Find channel by name
curl -s "https://slack.com/api/conversations.list?types=public_channel&limit=999" \
-H "Authorization: Bearer $SLACK_TOKEN" | jq '.channels[] | select(.name=="general") | .id'
# Create a channel
curl -s -X POST https://slack.com/api/conversations.create \
-H "Authorization: Bearer $SLACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "incident-2026-04", "is_private": false}'
# Set channel topic
curl -s -X POST https://slack.com/api/conversations.setTopic \
-H "Authorization: Bearer $SLACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel": "C0123ABCDEF", "topic": "Production incident — DB latency spike"}'
```
## Files
```bash
# Upload a file (v2 API)
curl -s -X POST https://slack.com/api/files.getUploadURLExternal \
-H "Authorization: Bearer $SLACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"filename": "report.csv", "length": 1024}' \
| jq '{upload_url, file_id}'
# Then POST the file content to upload_url, then call files.completeUploadExternal
# Share a remote file link
curl -s -X POST https://slack.com/api/files.remote.add \
-H "Authorization: Bearer $SLACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"external_id": "report-1", "external_url": "https://example.com/report.pdf", "title": "Monthly Report"}'
```
## Reactions & Users
```bash
# Add reaction to a message
curl -s -X POST https://slack.com/api/reactions.add \
-H "Authorization: Bearer $SLACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel": "C0123ABCDEF", "timestamp": "1234567890.123456", "name": "white_check_mark"}'
# List users
curl -s "https://slack.com/api/users.list?limit=200" \
-H "Authorization: Bearer $SLACK_TOKEN" | jq '.members[] | {id, name, real_name}'
# User info by ID
curl -s "https://slack.com/api/users.info?user=U0123ABCDEF" \
-H "Authorization: Bearer $SLACK_TOKEN" | jq '.user | {name, real_name, tz}'
# Look up user by email
curl -s "https://slack.com/api/[email protected]" \
-H "Authorization: Bearer $SLACK_TOKEN" | jq '.user.id'
```
## Common Workflows
**CI/CD notification:** After a deploy, post a formatted message to #deployments with version, commit, and status. On failure, create a thread with the error log.
**Incident channel:** Create a private channel named `incident-YYYY-MM-DD-<slug>`, set the topic, invite responders, post the initial report with severity and affected services.
**Standup reminder:** Schedule a daily message at 9:30 AM to #team with a prompt template. Collect threaded replies.
**Error alert:** When Sentry fires a webhook, post the error title, count, and link to the relevant channel. Add a 🔴 reaction for critical, 🟡 for warning.
## Notes
- Bot tokens (`xoxb-`) can only post to channels the bot has been invited to
- Rate limits: ~1 msg/sec per channel (Tier 2); respect `Retry-After` headers
- Channel IDs (not names) are required for most API calls — look them up first
- For rich messages, use [Block Kit Builder](https://app.slack.com/block-kit-builder) to design layouts
- `mrkdwn` (Slack's markdown) uses `*bold*`, `_italic_`, `~strike~`, `` `code` ``, `>quote`
- DMs: use `conversations.open` with user IDs to get the DM channel ID, then post normally
Sentry error tracking — list, triage, and resolve issues; manage releases and source maps via CLI and REST API.
---
name: sentry-integration
description: Sentry error tracking — list, triage, and resolve issues; manage releases and source maps via CLI and REST API.
metadata: {"openclaw":{"requires":{"bins":["sentry-cli"]},"install":[{"id":"npm","kind":"npm","package":"@sentry/cli","global":true,"bins":["sentry-cli"],"label":"Install sentry-cli (npm)"},{"id":"pip","kind":"pip","package":"sentry-cli","bins":["sentry-cli"],"label":"Install sentry-cli (pip)"}]}}
---
# Sentry Integration
Use `sentry-cli` and the Sentry REST API to monitor errors, triage issues, manage releases, and upload source maps.
## Setup
```bash
# Install
npm i -g @sentry/cli
# — or —
pip install sentry-cli
# Auth (set once, used by all commands)
export SENTRY_AUTH_TOKEN="sntrys_..." # Settings → Auth Tokens → Create
export SENTRY_ORG="my-org"
export SENTRY_PROJECT="my-project"
# Verify
sentry-cli info
```
Generate a token at **sentry.io → Settings → Auth Tokens** with scopes: `project:read`, `project:releases`, `org:read`, `event:read`.
## CLI Commands
### Releases
```bash
# Create a release (version from git)
sentry-cli releases new "$(sentry-cli releases propose-version)"
# Set commits (auto-detect from git)
sentry-cli releases set-commits "$VERSION" --auto
# Finalize (marks release as deployed)
sentry-cli releases finalize "$VERSION"
# Create + finalize in one step
sentry-cli releases new "$VERSION" --finalize
# Record a deploy
sentry-cli deploys new -r "$VERSION" -e production
# List releases
sentry-cli releases list
```
### Source Maps
```bash
# Upload source maps for a release
sentry-cli sourcemaps upload ./dist --release "$VERSION"
# With URL prefix (match hosted paths)
sentry-cli sourcemaps upload ./dist --release "$VERSION" --url-prefix "~/static/js"
# Validate before upload
sentry-cli sourcemaps explain --release "$VERSION" --org "$SENTRY_ORG" --project "$SENTRY_PROJECT"
```
### Send Test Event
```bash
sentry-cli send-event -m "Test event from CLI"
sentry-cli send-event -m "Deploy check" -t environment:production -t release:1.0.0
```
### Monitor (Cron Monitoring)
```bash
# Wrap a command — Sentry tracks if it runs and succeeds
sentry-cli monitors run <monitor-slug> -- <command>
sentry-cli monitors run backup-job -- ./run-backup.sh
```
## REST API (for queries the CLI doesn't cover)
Base URL: `https://sentry.io/api/0`
Auth header: `Authorization: Bearer $SENTRY_AUTH_TOKEN`
### List Issues
```bash
# All unresolved issues for a project
curl -s "https://sentry.io/api/0/projects/$SENTRY_ORG/$SENTRY_PROJECT/issues/?query=is:unresolved" \
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" | jq '.[].title'
# Organization-wide issues (sorted by last seen)
curl -s "https://sentry.io/api/0/organizations/$SENTRY_ORG/issues/?query=is:unresolved&sort=date" \
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" | jq '.[] | {id, title, count, lastSeen}'
# Filter by level, time, assignment
curl -s "https://sentry.io/api/0/organizations/$SENTRY_ORG/issues/?query=is:unresolved+level:error+lastSeen:>2d" \
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN"
```
### Get Issue Details + Events
```bash
# Issue details
curl -s "https://sentry.io/api/0/issues/$ISSUE_ID/" \
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" | jq '{title, status, count, firstSeen, lastSeen}'
# Latest events for an issue (stack traces, breadcrumbs)
curl -s "https://sentry.io/api/0/issues/$ISSUE_ID/events/?full=true" \
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" | jq '.[0].entries'
```
### Resolve / Ignore Issues
```bash
# Resolve
curl -s "https://sentry.io/api/0/issues/$ISSUE_ID/" \
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
-X PUT -H "Content-Type: application/json" \
-d '{"status": "resolved"}'
# Resolve in next release
curl -s "https://sentry.io/api/0/issues/$ISSUE_ID/" \
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
-X PUT -H "Content-Type: application/json" \
-d '{"status": "resolvedInNextRelease"}'
# Ignore for 24 hours
curl -s "https://sentry.io/api/0/issues/$ISSUE_ID/" \
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
-X PUT -H "Content-Type: application/json" \
-d '{"status": "ignored", "statusDetails": {"ignoreDuration": 1440}}'
# Assign to team member
curl -s "https://sentry.io/api/0/issues/$ISSUE_ID/" \
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
-X PUT -H "Content-Type: application/json" \
-d '{"assignedTo": "[email protected]"}'
```
### Bulk Resolve
```bash
# Resolve multiple issues at once
curl -s "https://sentry.io/api/0/projects/$SENTRY_ORG/$SENTRY_PROJECT/issues/" \
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
-X PUT -H "Content-Type: application/json" \
-d '{"id": ["123","456","789"], "status": "resolved"}'
```
## Triage Workflow
When asked to check or triage Sentry errors:
1. List unresolved issues sorted by frequency: `query=is:unresolved&sort=freq`
2. For the top issues, fetch latest event with full stack trace
3. Analyze the stack trace — identify the failing function, file, and line
4. Check if the error is in code the agent can access (read the file, suggest a fix)
5. Classify: **critical** (data loss, crash), **high** (user-facing errors), **medium** (degraded experience), **low** (cosmetic, logs)
6. Resolve issues that have confirmed fixes deployed; ignore transient errors
## Notes
- Self-hosted Sentry: replace `sentry.io` with your instance URL
- Rate limits: 40 requests/min for free tier, respect `Retry-After` headers
- The CLI respects `.sentryclirc` files for project-level config
- Use `--log-level debug` on any CLI command for troubleshooting
Validate JSON-exported Tailwind CSS configuration files for structural issues, content path problems, theme misconfiguration, and best practices. Use when au...
---
name: tailwind-config-validator
description: Validate JSON-exported Tailwind CSS configuration files for structural issues, content path problems, theme misconfiguration, and best practices. Use when auditing Tailwind configs, preparing production builds, or enforcing CI standards.
---
# Tailwind Config Validator
Validate Tailwind CSS configuration files exported as JSON for structural correctness, content path issues, theme misconfiguration, dark mode problems, plugin hygiene, and best practices. Pure Python 3 stdlib — no external dependencies.
**Note:** Tailwind configs are JS/TS, not directly parseable. This validator works with JSON-exported configs. Export via:
```bash
node -e "console.log(JSON.stringify(require('./tailwind.config.js')))" > config.json
python3 scripts/tailwind_config_validator.py validate config.json
```
## Commands
### validate — Full validation with all rules
```bash
python3 scripts/tailwind_config_validator.py validate config.json
python3 scripts/tailwind_config_validator.py validate config.json --strict
python3 scripts/tailwind_config_validator.py validate config.json --format json
```
### lint — Run all rules (alias for validate)
```bash
python3 scripts/tailwind_config_validator.py lint config.json
python3 scripts/tailwind_config_validator.py lint config.json --format summary
python3 scripts/tailwind_config_validator.py lint config.json --strict --format json
```
### content — Check content configuration
```bash
python3 scripts/tailwind_config_validator.py content config.json
python3 scripts/tailwind_config_validator.py content config.json --format json
python3 scripts/tailwind_config_validator.py content config.json --format summary
```
### theme — Check theme configuration
```bash
python3 scripts/tailwind_config_validator.py theme config.json
python3 scripts/tailwind_config_validator.py theme config.json --format json
python3 scripts/tailwind_config_validator.py theme config.json --format summary
```
## Flags
| Flag | Description |
|------|-------------|
| `--strict` | Treat warnings as errors — exit code 1 (CI-friendly) |
| `--format text` | Human-readable output (default) |
| `--format json` | Machine-readable JSON |
| `--format summary` | Compact summary with counts |
## Validation Rules (26)
### Structure (5)
| Rule | Severity | Description |
|------|----------|-------------|
| S1 | error | File not found or unreadable |
| S2 | error | Empty config file |
| S3 | error | Invalid JSON syntax |
| S4 | warning/info | Unknown top-level keys (valid: content, theme, plugins, presets, darkMode, prefix, important, separator, corePlugins, safelist, blocklist, future, experimental) |
| S5 | info | JS/TS config detected (hint to export as JSON) |
### Content (5)
| Rule | Severity | Description |
|------|----------|-------------|
| C1 | error | Missing content paths (required for tree-shaking) |
| C2 | warning | Empty content array |
| C3 | warning | Content paths include node_modules (performance) |
| C4 | warning | Content glob too broad (e.g. `**/*` without extension filter) |
| C5 | info | Suspicious content pattern (bare `*.css` or similar) |
### Theme (5)
| Rule | Severity | Description |
|------|----------|-------------|
| T1 | warning | Overriding entire theme key without extend (replaces defaults) |
| T2 | info | Empty theme.extend object |
| T3 | warning | Invalid color values (not strings) |
| T4 | info | Referencing default theme without callback (theme() needed) |
| T5 | warning | Custom screen breakpoints not in ascending order |
### Dark Mode (2)
| Rule | Severity | Description |
|------|----------|-------------|
| D1 | error | Invalid darkMode value |
| D2 | info | darkMode "class" deprecated in v3.4+ (use "selector") |
### Plugins (3)
| Rule | Severity | Description |
|------|----------|-------------|
| P1 | info | Empty plugins array |
| P2 | error | Plugins not an array |
| P3 | info | Deprecated official plugins (built-in in v4) |
### Best Practices (6)
| Rule | Severity | Description |
|------|----------|-------------|
| B1 | error | No content paths defined (tree-shaking broken) |
| B2 | warning | Using important: true globally (anti-pattern) |
| B3 | warning | Prefix with special characters |
| B4 | warning | corePlugins disabled entirely |
| B5 | warning | Large safelist (>50 patterns, bloats CSS) |
| B6 | warning | Missing theme.extend (all customizations override defaults) |
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | No errors (warnings allowed unless `--strict`) |
| 1 | Errors found (or warnings in `--strict` mode) |
| 2 | File not found / parse error |
## CI Integration
```yaml
# GitHub Actions example
- name: Validate Tailwind config
run: |
node -e "console.log(JSON.stringify(require('./tailwind.config.js')))" > /tmp/tw-config.json
python3 scripts/tailwind_config_validator.py validate /tmp/tw-config.json --strict --format json
```
## Example Output
```
tailwind.config validate — config.json
=======================================
[ERROR ] C1: Missing 'content' paths — required for tree-shaking
Without content paths, Tailwind cannot purge unused CSS. Add content: ['./src/**/*.{html,js,ts,jsx,tsx}'].
[WARNING] T1: theme.colors overrides all default colors — use theme.extend.colors instead
Placing keys directly under 'theme' replaces the entire default set. Move to 'theme.extend' to merge with defaults.
[WARNING] B2: important: true applies !important to all utilities (anti-pattern)
Prefer important: '#app' to scope specificity to a root selector instead of global !important.
[INFO ] P3: Plugin '@tailwindcss/forms' is built-in since Tailwind v4
In Tailwind v4, @tailwindcss/forms functionality is included by default. Remove the plugin if upgrading.
[INFO ] D2: darkMode 'class' is deprecated since v3.4 — use 'selector' instead
The 'class' strategy still works but 'selector' is the recommended replacement.
Result: INVALID
Summary: 1 error(s), 2 warning(s), 2 info
```
FILE:scripts/tailwind_config_validator.py
#!/usr/bin/env python3
"""
Tailwind Config Validator
Validate JSON-exported Tailwind CSS configuration files for structural correctness,
content path issues, theme misconfiguration, dark mode problems, plugin hygiene,
and best practices.
Usage: python3 tailwind_config_validator.py <command> <file> [--strict] [--format text|json|summary]
Commands: validate, lint, content, theme
Note: Tailwind configs are JS/TS. This validator works with JSON-exported configs.
Export via: node -e "console.log(JSON.stringify(require('./tailwind.config.js')))"
"""
import sys
import os
import re
import json
import argparse
from typing import Any
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
KNOWN_TOP_LEVEL_KEYS = {
"content", "theme", "plugins", "presets", "darkMode", "prefix",
"important", "separator", "corePlugins", "safelist", "blocklist",
"future", "experimental",
}
VALID_DARK_MODE_VALUES = {"media", "class", "selector"}
DEPRECATED_OFFICIAL_PLUGINS = {
"@tailwindcss/forms": "In Tailwind v4, @tailwindcss/forms functionality is included by default.",
"@tailwindcss/typography": "In Tailwind v4, @tailwindcss/typography functionality is included by default.",
"@tailwindcss/aspect-ratio": "In Tailwind v4, aspect-ratio utilities are built-in.",
"@tailwindcss/line-clamp": "Since Tailwind v3.3, line-clamp utilities are built-in.",
}
JS_TS_EXTENSIONS = {".js", ".ts", ".cjs", ".mjs"}
# Theme keys that are commonly overridden by mistake (losing all defaults)
THEME_OVERRIDE_WARN_KEYS = {
"colors", "spacing", "fontFamily", "fontSize", "fontWeight",
"borderRadius", "screens", "zIndex", "opacity", "lineHeight",
"letterSpacing", "maxWidth", "minHeight", "width", "height",
}
# CSS size units for breakpoint parsing
SIZE_UNIT_RE = re.compile(r'^(\d+(?:\.\d+)?)(px|em|rem)$')
# ---------------------------------------------------------------------------
# Finding class
# ---------------------------------------------------------------------------
class Finding:
"""A single validation finding."""
SEVERITIES = ("error", "warning", "info")
def __init__(self, rule_id: str, severity: str, message: str, detail: str = ""):
assert severity in self.SEVERITIES, f"Invalid severity: {severity}"
self.rule_id = rule_id
self.severity = severity
self.message = message
self.detail = detail
def to_dict(self) -> dict:
d = {
"rule_id": self.rule_id,
"severity": self.severity,
"message": self.message,
}
if self.detail:
d["detail"] = self.detail
return d
def __repr__(self):
return f"Finding({self.rule_id}, {self.severity}, {self.message!r})"
# ---------------------------------------------------------------------------
# JSON loading
# ---------------------------------------------------------------------------
def load_config(path: str) -> tuple[dict | None, Finding | None]:
"""Load and parse a JSON Tailwind config file. Returns (data, error_finding)."""
# S1: File not found or unreadable
if not os.path.exists(path):
return None, Finding("S1", "error", f"File not found: {path}")
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
except OSError as e:
return None, Finding("S1", "error", f"Cannot read file: {e}")
# S2: Empty config
if len(content.strip()) == 0:
return None, Finding("S2", "error", "Config file is empty")
# S5: Detect JS/TS config (not JSON)
stripped = content.strip()
if (stripped.startswith("module.exports") or
stripped.startswith("export default") or
stripped.startswith("export const") or
stripped.startswith("const ") or
stripped.startswith("import ") or
stripped.startswith("/** @type")):
return None, Finding("S5", "error",
"File appears to be JS/TS, not JSON",
"Export your Tailwind config as JSON first: "
"node -e \"console.log(JSON.stringify(require('./tailwind.config.js')))\" > config.json")
# S3: Invalid JSON syntax
try:
data = json.loads(content)
except json.JSONDecodeError as e:
return None, Finding("S3", "error", f"Invalid JSON syntax: {e}")
if not isinstance(data, dict):
return None, Finding("S3", "error",
f"Expected a JSON object at top level, got {type(data).__name__}")
# S2 (variant): Empty object
if len(data) == 0:
return None, Finding("S2", "error",
"Config is an empty object — no configuration defined",
"Add at least a 'content' array for Tailwind to work.")
return data, None
# ---------------------------------------------------------------------------
# Individual check functions
# ---------------------------------------------------------------------------
def check_structure(data: dict) -> list[Finding]:
"""S4: Check for unknown top-level keys."""
findings: list[Finding] = []
for key in data:
if key not in KNOWN_TOP_LEVEL_KEYS:
findings.append(Finding("S4", "warning",
f"Unknown top-level key '{key}' — may be a typo or unsupported property",
f"Valid top-level keys: {', '.join(sorted(KNOWN_TOP_LEVEL_KEYS))}."))
return findings
def check_content(data: dict) -> list[Finding]:
"""C1-C5: Check content configuration."""
findings: list[Finding] = []
content = data.get("content")
# C1 / B1: Missing content paths entirely
if content is None:
findings.append(Finding("C1", "error",
"Missing 'content' paths — required for tree-shaking",
"Without content paths, Tailwind cannot purge unused CSS. "
"Add content: ['./src/**/*.{html,js,ts,jsx,tsx}']."))
return findings
# Content can be an array or an object with "files" key (v3.2+)
content_paths: list = []
if isinstance(content, list):
content_paths = content
elif isinstance(content, dict):
files = content.get("files", [])
if isinstance(files, list):
content_paths = files
else:
findings.append(Finding("C1", "error",
"content.files is not an array",
"content.files must be an array of glob patterns."))
return findings
else:
findings.append(Finding("C1", "error",
f"'content' must be an array or object, got {type(content).__name__}",
"Set content to an array of glob patterns, "
"e.g. ['./src/**/*.{html,js,ts,jsx,tsx}']."))
return findings
# C2: Empty content array
if len(content_paths) == 0:
findings.append(Finding("C2", "warning",
"Content array is empty — no files will be scanned for class usage",
"Add glob patterns to content, e.g. ['./src/**/*.{html,js,ts,jsx,tsx}']."))
return findings
for i, pattern in enumerate(content_paths):
if not isinstance(pattern, str):
continue
# C3: Content paths including node_modules
if "node_modules" in pattern:
findings.append(Finding("C3", "warning",
f"Content path [{i}] includes node_modules: '{pattern}'",
"Scanning node_modules severely impacts build performance. "
"Only include specific packages if needed."))
# C4: Glob too broad (e.g. **/* without extension filter)
if "**/*" in pattern:
# Check if there's an extension filter after the glob
after_glob = pattern.split("**/*")[-1]
if not after_glob or after_glob == "/":
findings.append(Finding("C4", "warning",
f"Content path [{i}] uses overly broad glob: '{pattern}'",
"Add an extension filter, e.g. '**/*.{{html,js,ts,jsx,tsx}}' "
"to avoid scanning unnecessary files."))
# C5: Suspicious patterns
if re.match(r'^\*\.\w+$', pattern):
findings.append(Finding("C5", "info",
f"Content path [{i}] is a shallow glob: '{pattern}'",
"This only matches files in the current directory. "
"Use './**/*.ext' or './src/**/*.ext' for recursive matching."))
# C5: Bare *.css is suspicious — Tailwind doesn't scan CSS for class names
if pattern.endswith("*.css") or pattern.endswith(".css"):
findings.append(Finding("C5", "info",
f"Content path [{i}] targets CSS files: '{pattern}'",
"Tailwind scans content files for class names used in markup/JS. "
"CSS files typically don't contain Tailwind class references."))
return findings
def check_theme(data: dict) -> list[Finding]:
"""T1-T5: Check theme configuration."""
findings: list[Finding] = []
theme = data.get("theme")
if not isinstance(theme, dict):
return findings
extend = theme.get("extend")
has_extend = "extend" in theme and isinstance(extend, dict)
# T2: Empty theme.extend
if has_extend and len(extend) == 0:
findings.append(Finding("T2", "info",
"theme.extend is an empty object — no customizations defined",
"Add custom values to theme.extend to merge with Tailwind defaults, "
"or remove the empty extend block."))
# T1: Overriding theme keys directly (without extend)
for key in theme:
if key == "extend":
continue
if key in THEME_OVERRIDE_WARN_KEYS:
findings.append(Finding("T1", "warning",
f"theme.{key} overrides all default {key} — "
f"use theme.extend.{key} instead",
"Placing keys directly under 'theme' replaces the entire default set. "
"Move to 'theme.extend' to merge with defaults."))
# T3: Invalid color values (check both theme.colors and theme.extend.colors)
_check_color_values(theme.get("colors"), "theme.colors", findings)
if has_extend:
_check_color_values(extend.get("colors"), "theme.extend.colors", findings)
# T4: String references that look like they need theme() callback
_check_theme_references(theme, "theme", findings)
if has_extend:
_check_theme_references(extend, "theme.extend", findings)
# T5: Screen breakpoints not in ascending order
_check_screen_order(theme.get("screens"), "theme.screens", findings)
if has_extend:
_check_screen_order(extend.get("screens"), "theme.extend.screens", findings)
return findings
def _check_color_values(colors: Any, path: str, findings: list[Finding]) -> None:
"""T3: Check that color values are strings (or nested color objects)."""
if not isinstance(colors, dict):
return
for key, val in colors.items():
if isinstance(val, dict):
# Nested shade object (e.g. blue: { 100: "#...", 500: "#..." })
for shade, shade_val in val.items():
if not isinstance(shade_val, str):
findings.append(Finding("T3", "warning",
f"Invalid color value at {path}.{key}.{shade} "
f"— expected string, got {type(shade_val).__name__}",
"Color values must be CSS color strings "
"(hex, rgb, hsl, etc)."))
elif not isinstance(val, str):
findings.append(Finding("T3", "warning",
f"Invalid color value at {path}.{key} "
f"— expected string, got {type(val).__name__}",
"Color values must be CSS color strings "
"(hex, rgb, hsl, etc)."))
def _check_theme_references(theme_section: dict, path: str,
findings: list[Finding]) -> None:
"""T4: Detect string values that look like theme references without callback."""
if not isinstance(theme_section, dict):
return
for key, val in theme_section.items():
if key == "extend":
continue
if isinstance(val, dict):
for sub_key, sub_val in val.items():
if isinstance(sub_val, str) and "theme(" in sub_val:
findings.append(Finding("T4", "info",
f"String 'theme(...)' reference at {path}.{key}.{sub_key}",
"In JSON-exported configs, theme() callbacks are lost. "
"This string is likely non-functional — "
"replace with the actual resolved value."))
def _parse_breakpoint_px(value: Any) -> float | None:
"""Parse a breakpoint value to px. Returns None if unparseable."""
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
match = SIZE_UNIT_RE.match(value.strip())
if match:
num = float(match.group(1))
unit = match.group(2)
if unit == "px":
return num
elif unit == "em":
return num * 16
elif unit == "rem":
return num * 16
if isinstance(value, dict):
# { min: "640px" } form
min_val = value.get("min")
if min_val is not None:
return _parse_breakpoint_px(min_val)
return None
def _check_screen_order(screens: Any, path: str,
findings: list[Finding]) -> None:
"""T5: Check that screen breakpoints are in ascending order."""
if not isinstance(screens, dict):
return
prev_name = None
prev_px = None
for name, value in screens.items():
px = _parse_breakpoint_px(value)
if px is not None and prev_px is not None:
if px < prev_px:
findings.append(Finding("T5", "warning",
f"Screen '{name}' ({value}) is smaller than "
f"preceding '{prev_name}' at {path}",
"Breakpoints should be in ascending order for "
"mobile-first responsive design to work correctly."))
if px is not None:
prev_name = name
prev_px = px
def check_dark_mode(data: dict) -> list[Finding]:
"""D1-D2: Check darkMode configuration."""
findings: list[Finding] = []
dark_mode = data.get("darkMode")
if dark_mode is None:
return findings
# D1: Invalid darkMode value
if isinstance(dark_mode, str):
if dark_mode not in VALID_DARK_MODE_VALUES:
findings.append(Finding("D1", "error",
f"Invalid darkMode value '{dark_mode}'",
"darkMode must be 'media', 'class', or 'selector'. "
"For a custom selector, use ['selector', '.my-class']."))
# D2: "class" deprecated in v3.4+
if dark_mode == "class":
findings.append(Finding("D2", "info",
"darkMode 'class' is deprecated since v3.4 — use 'selector' instead",
"The 'class' strategy still works but 'selector' is the recommended "
"replacement. It provides more flexibility (any CSS selector, not just class)."))
elif isinstance(dark_mode, list):
# ["selector", ".custom-class"] form
if len(dark_mode) != 2:
findings.append(Finding("D1", "error",
f"darkMode array must have exactly 2 elements, got {len(dark_mode)}",
"Use ['selector', '.my-class'] or ['class', '.my-class'] format."))
elif not isinstance(dark_mode[0], str) or dark_mode[0] not in ("selector", "class"):
findings.append(Finding("D1", "error",
f"darkMode array first element must be 'selector' or 'class', "
f"got '{dark_mode[0]}'",
"Use ['selector', '.my-class'] format."))
elif not isinstance(dark_mode[1], str):
findings.append(Finding("D1", "error",
f"darkMode array second element must be a CSS selector string",
"Use ['selector', '.my-class'] format."))
# D2: "class" in array form also deprecated
if len(dark_mode) >= 1 and dark_mode[0] == "class":
findings.append(Finding("D2", "info",
"darkMode ['class', ...] is deprecated since v3.4 — "
"use ['selector', ...] instead",
"Replace 'class' with 'selector' for forward compatibility."))
else:
findings.append(Finding("D1", "error",
f"darkMode must be a string or array, got {type(dark_mode).__name__}",
"Use 'media', 'selector', or ['selector', '.my-class']."))
return findings
def check_plugins(data: dict) -> list[Finding]:
"""P1-P3: Check plugin configuration."""
findings: list[Finding] = []
plugins = data.get("plugins")
if plugins is None:
return findings
# P2: Plugins not an array
if not isinstance(plugins, list):
findings.append(Finding("P2", "error",
f"'plugins' must be an array, got {type(plugins).__name__}",
"Set plugins to an array: plugins: [require('@tailwindcss/forms')]. "
"In JSON export, plugin entries appear as strings or objects."))
return findings
# P1: Empty plugins array
if len(plugins) == 0:
findings.append(Finding("P1", "info",
"plugins array is empty",
"Remove the empty plugins array, or add plugins as needed."))
return findings
# P3: Deprecated official plugins
for i, plugin in enumerate(plugins):
plugin_name = None
if isinstance(plugin, str):
plugin_name = plugin
elif isinstance(plugin, dict):
# JSON-serialized plugin — look for name-like keys
for key in ("name", "_name", "pluginName", "handler", "__plugin"):
val = plugin.get(key)
if isinstance(val, str) and val:
plugin_name = val
break
if plugin_name:
for deprecated, msg in DEPRECATED_OFFICIAL_PLUGINS.items():
if deprecated in plugin_name:
findings.append(Finding("P3", "info",
f"Plugin '{deprecated}' at index {i} is built-in "
f"since Tailwind v4",
msg + " Remove the plugin if upgrading."))
return findings
def check_best_practices(data: dict) -> list[Finding]:
"""B1-B6: Check best practices."""
findings: list[Finding] = []
# B1: No content paths at all (duplicate of C1 for the 'validate' all-rules flow)
content = data.get("content")
if content is None:
findings.append(Finding("B1", "error",
"No content paths defined — tree-shaking will not work",
"Tailwind needs content paths to scan for used classes. "
"Without them, either all utilities are included (huge CSS) "
"or none are (broken styles)."))
elif isinstance(content, list) and len(content) == 0:
findings.append(Finding("B1", "error",
"Content array is empty — tree-shaking will not work",
"Add file glob patterns to content for Tailwind to scan."))
elif isinstance(content, dict):
files = content.get("files", [])
if isinstance(files, list) and len(files) == 0:
findings.append(Finding("B1", "error",
"content.files is empty — tree-shaking will not work",
"Add file glob patterns to content.files for Tailwind to scan."))
# B2: important: true globally
important = data.get("important")
if important is True:
findings.append(Finding("B2", "warning",
"important: true applies !important to all utilities (anti-pattern)",
"Prefer important: '#app' to scope specificity to a root selector "
"instead of global !important. This avoids conflicts with third-party CSS."))
# B3: Prefix with special characters
prefix = data.get("prefix")
if isinstance(prefix, str) and prefix:
if re.search(r'[^a-zA-Z0-9_-]', prefix):
findings.append(Finding("B3", "warning",
f"Prefix '{prefix}' contains special characters",
"Prefixes should only contain alphanumeric characters, hyphens, "
"and underscores. Special characters may cause issues with "
"CSS class name matching."))
# B4: corePlugins disabled entirely
core_plugins = data.get("corePlugins")
if isinstance(core_plugins, dict) and len(core_plugins) > 0:
all_false = all(v is False for v in core_plugins.values())
if all_false:
findings.append(Finding("B4", "warning",
f"All {len(core_plugins)} corePlugins are set to false "
f"— Tailwind generates no utilities",
"This disables all utility generation. If intentional, "
"consider using a minimal CSS framework instead."))
elif core_plugins is False:
findings.append(Finding("B4", "warning",
"corePlugins set to false — all core plugins disabled",
"This disables all utility generation. "
"Use an object to disable specific plugins selectively."))
# B5: Large safelist
safelist = data.get("safelist")
if isinstance(safelist, list) and len(safelist) > 50:
findings.append(Finding("B5", "warning",
f"Large safelist with {len(safelist)} entries — may bloat CSS output",
"Safelisting more than 50 patterns significantly increases CSS size. "
"Review whether all safelisted classes are truly needed. "
"Consider using content paths to auto-detect dynamic classes."))
# B6: Missing theme.extend (all customizations override defaults)
theme = data.get("theme")
if isinstance(theme, dict):
has_custom_keys = any(k != "extend" for k in theme)
has_extend = "extend" in theme and isinstance(theme["extend"], dict)
if has_custom_keys and not has_extend:
findings.append(Finding("B6", "warning",
"theme has custom keys but no 'extend' block — "
"all customizations replace Tailwind defaults",
"Use theme.extend to add custom values while keeping defaults. "
"Keys placed directly under theme (not extend) completely "
"replace the default values for that key."))
return findings
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
def validate_all(data: dict) -> list[Finding]:
"""Run all checks and return combined findings."""
findings: list[Finding] = []
findings.extend(check_structure(data))
findings.extend(check_content(data))
findings.extend(check_theme(data))
findings.extend(check_dark_mode(data))
findings.extend(check_plugins(data))
findings.extend(check_best_practices(data))
return findings
def validate_content_only(data: dict) -> list[Finding]:
"""Run only content-related checks."""
findings: list[Finding] = []
findings.extend(check_content(data))
# Also include B1 (no content paths) from best practices
content = data.get("content")
if content is None:
findings.append(Finding("B1", "error",
"No content paths defined — tree-shaking will not work",
"Tailwind needs content paths to scan for used classes."))
return findings
def validate_theme_only(data: dict) -> list[Finding]:
"""Run only theme-related checks."""
return check_theme(data)
# ---------------------------------------------------------------------------
# Rule explanations
# ---------------------------------------------------------------------------
RULE_EXPLANATIONS: dict[str, dict[str, str]] = {
"S1": {
"name": "File Not Found",
"category": "Structure",
"severity": "error",
"description": "The Tailwind config JSON file does not exist or cannot be read.",
"fix": "Ensure the file path is correct and the file has read permissions. "
"Export with: node -e \"console.log(JSON.stringify(require('./tailwind.config.js')))\" > config.json",
},
"S2": {
"name": "Empty Config",
"category": "Structure",
"severity": "error",
"description": "The config file is empty (zero bytes, whitespace only, or empty object).",
"fix": "Re-export the Tailwind config to JSON. The file must contain a valid JSON object "
"with at least a 'content' array.",
},
"S3": {
"name": "Invalid JSON",
"category": "Structure",
"severity": "error",
"description": "The file contains invalid JSON syntax that cannot be parsed.",
"fix": "Ensure the export produces valid JSON. Functions, require() calls, and "
"RegExp objects are not JSON-serializable.",
},
"S4": {
"name": "Unknown Top-Level Keys",
"category": "Structure",
"severity": "warning",
"description": "Unrecognized top-level key that may be a typo or unsupported property.",
"fix": "Check the Tailwind CSS documentation for valid configuration keys. "
"Valid: content, theme, plugins, presets, darkMode, prefix, important, "
"separator, corePlugins, safelist, blocklist, future, experimental.",
},
"S5": {
"name": "JS/TS Config Detected",
"category": "Structure",
"severity": "error",
"description": "The file appears to be JavaScript/TypeScript, not JSON.",
"fix": "Export as JSON: node -e \"console.log(JSON.stringify(require('./tailwind.config.js')))\" > config.json",
},
"C1": {
"name": "Missing Content Paths",
"category": "Content",
"severity": "error",
"description": "No content paths configured. Required for Tailwind's tree-shaking in v3+.",
"fix": "Add content: ['./src/**/*.{html,js,ts,jsx,tsx}'] to specify which files "
"Tailwind should scan for class names.",
},
"C2": {
"name": "Empty Content Array",
"category": "Content",
"severity": "warning",
"description": "Content array exists but contains no glob patterns.",
"fix": "Add file glob patterns, e.g. './src/**/*.{html,js,ts,jsx,tsx}'.",
},
"C3": {
"name": "Content Includes node_modules",
"category": "Content",
"severity": "warning",
"description": "Content paths include node_modules, severely impacting build performance.",
"fix": "Remove node_modules from content paths. If a specific package uses "
"Tailwind classes, add only that package's path.",
},
"C4": {
"name": "Broad Content Glob",
"category": "Content",
"severity": "warning",
"description": "Content glob is too broad (e.g. **/* without extension filter).",
"fix": "Add extension filters: '**/*.{html,js,ts,jsx,tsx}' instead of '**/*'.",
},
"C5": {
"name": "Suspicious Content Pattern",
"category": "Content",
"severity": "info",
"description": "Content path has a suspicious pattern that may not work as intended.",
"fix": "Review the glob pattern. CSS files don't contain class references. "
"Shallow globs (*.ext) only match the current directory.",
},
"T1": {
"name": "Theme Override Without Extend",
"category": "Theme",
"severity": "warning",
"description": "A theme key directly overrides all Tailwind defaults for that category.",
"fix": "Move the key to theme.extend to merge with defaults instead of replacing them. "
"e.g. theme.extend.colors instead of theme.colors.",
},
"T2": {
"name": "Empty theme.extend",
"category": "Theme",
"severity": "info",
"description": "theme.extend exists but contains no customizations.",
"fix": "Add custom values or remove the empty extend block.",
},
"T3": {
"name": "Invalid Color Value",
"category": "Theme",
"severity": "warning",
"description": "A color value is not a string (must be a CSS color like hex, rgb, hsl).",
"fix": "Ensure all color values are CSS color strings: '#ff0000', 'rgb(255,0,0)', etc.",
},
"T4": {
"name": "Theme Reference Without Callback",
"category": "Theme",
"severity": "info",
"description": "A string containing 'theme(...)' was found, but callbacks are lost in JSON export.",
"fix": "Replace theme() references with the actual resolved values in the JSON export.",
},
"T5": {
"name": "Breakpoints Not Ascending",
"category": "Theme",
"severity": "warning",
"description": "Custom screen breakpoints are not in ascending order.",
"fix": "Reorder breakpoints from smallest to largest for mobile-first design to work correctly.",
},
"D1": {
"name": "Invalid darkMode Value",
"category": "Dark Mode",
"severity": "error",
"description": "darkMode has an invalid value. Must be 'media', 'class', 'selector', "
"or ['selector', '.custom-class'].",
"fix": "Set darkMode to 'media' (OS preference), 'selector' (manual toggle), "
"or ['selector', '.my-dark-class'] for a custom selector.",
},
"D2": {
"name": "darkMode 'class' Deprecated",
"category": "Dark Mode",
"severity": "info",
"description": "darkMode 'class' is deprecated since Tailwind v3.4. Use 'selector' instead.",
"fix": "Replace darkMode: 'class' with darkMode: 'selector'. "
"The 'selector' strategy is more flexible and forward-compatible.",
},
"P1": {
"name": "Empty Plugins Array",
"category": "Plugins",
"severity": "info",
"description": "The plugins array is empty.",
"fix": "Remove the empty array or add plugins as needed.",
},
"P2": {
"name": "Plugins Not Array",
"category": "Plugins",
"severity": "error",
"description": "The plugins field is not an array.",
"fix": "Set plugins to an array: plugins: [require('@tailwindcss/forms')].",
},
"P3": {
"name": "Deprecated Official Plugin",
"category": "Plugins",
"severity": "info",
"description": "An official Tailwind plugin that is built-in since v4.",
"fix": "Remove the plugin when upgrading to Tailwind v4 — the functionality is included by default.",
},
"B1": {
"name": "No Content Paths (Best Practice)",
"category": "Best Practices",
"severity": "error",
"description": "No content paths defined — Tailwind's tree-shaking cannot remove unused CSS.",
"fix": "Add content: ['./src/**/*.{html,js,ts,jsx,tsx}'] to enable tree-shaking.",
},
"B2": {
"name": "Global Important",
"category": "Best Practices",
"severity": "warning",
"description": "important: true makes every utility use !important, an anti-pattern.",
"fix": "Use important: '#app' to scope specificity to a root selector instead.",
},
"B3": {
"name": "Prefix Special Characters",
"category": "Best Practices",
"severity": "warning",
"description": "The prefix contains special characters that may break class matching.",
"fix": "Use only alphanumeric characters, hyphens, and underscores in the prefix.",
},
"B4": {
"name": "All Core Plugins Disabled",
"category": "Best Practices",
"severity": "warning",
"description": "All core plugins are disabled — Tailwind generates no utility classes.",
"fix": "Remove corePlugins or selectively disable only the plugins you don't need.",
},
"B5": {
"name": "Large Safelist",
"category": "Best Practices",
"severity": "warning",
"description": "Safelist has more than 50 entries, significantly bloating CSS output.",
"fix": "Review safelisted classes. Use content path scanning for dynamic classes "
"instead of safelisting.",
},
"B6": {
"name": "No theme.extend",
"category": "Best Practices",
"severity": "warning",
"description": "Theme customizations replace defaults entirely because theme.extend is missing.",
"fix": "Use theme.extend.{key} to add custom values while keeping Tailwind defaults.",
},
}
# ---------------------------------------------------------------------------
# Summary helper
# ---------------------------------------------------------------------------
def _summary_counts(findings: list[Finding]) -> dict:
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
infos = sum(1 for f in findings if f.severity == "info")
return {"errors": errors, "warnings": warnings, "infos": infos, "total": len(findings)}
def _summary_text(findings: list[Finding]) -> str:
c = _summary_counts(findings)
parts = []
if c["errors"]:
parts.append(f"{c['errors']} error(s)")
if c["warnings"]:
parts.append(f"{c['warnings']} warning(s)")
if c["infos"]:
parts.append(f"{c['infos']} info")
return ", ".join(parts) if parts else "No issues found"
def _deduplicate_findings(findings: list[Finding]) -> list[Finding]:
"""Remove duplicate findings (same rule_id + message)."""
seen: set[tuple[str, str]] = set()
deduped: list[Finding] = []
for f in findings:
key = (f.rule_id, f.message)
if key not in seen:
seen.add(key)
deduped.append(f)
return deduped
# ---------------------------------------------------------------------------
# Command handlers
# ---------------------------------------------------------------------------
def cmd_validate(data: dict, path: str) -> dict:
"""Full validation with summary."""
findings = _deduplicate_findings(validate_all(data))
errors = [f for f in findings if f.severity == "error"]
return {
"command": "validate",
"file": path,
"valid": len(errors) == 0,
"findings": [f.to_dict() for f in findings],
"counts": _summary_counts(findings),
"summary": _summary_text(findings),
}
def cmd_lint(data: dict, path: str) -> dict:
"""Run all rules (alias for validate)."""
result = cmd_validate(data, path)
result["command"] = "lint"
return result
def cmd_content(data: dict, path: str) -> dict:
"""Check content configuration only."""
findings = _deduplicate_findings(validate_content_only(data))
errors = [f for f in findings if f.severity == "error"]
return {
"command": "content",
"file": path,
"passed": len(errors) == 0,
"findings": [f.to_dict() for f in findings],
"counts": _summary_counts(findings),
"summary": _summary_text(findings),
}
def cmd_theme(data: dict, path: str) -> dict:
"""Check theme configuration only."""
findings = _deduplicate_findings(validate_theme_only(data))
errors = [f for f in findings if f.severity == "error"]
return {
"command": "theme",
"file": path,
"passed": len(errors) == 0,
"findings": [f.to_dict() for f in findings],
"counts": _summary_counts(findings),
"summary": _summary_text(findings),
}
# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------
def format_text(result: dict) -> str:
cmd = result.get("command", "")
path = result.get("file", "")
lines = []
title = f"tailwind.config {cmd} — {path}"
lines.append(title)
lines.append("=" * len(title))
findings = result.get("findings", [])
if not findings:
lines.append("[OK] No issues found")
else:
for f in findings:
sev = f["severity"].upper().ljust(7)
lines.append(f"[{sev}] {f['rule_id']}: {f['message']}")
if f.get("detail"):
lines.append(f" {f['detail']}")
if "valid" in result:
valid_str = "VALID" if result.get("valid") else "INVALID"
lines.append("")
lines.append(f"Result: {valid_str}")
if "passed" in result:
passed_str = "PASSED" if result.get("passed") else "FAILED"
lines.append("")
lines.append(f"Result: {passed_str}")
summary = result.get("summary")
if summary:
lines.append(f"Summary: {summary}")
return "\n".join(lines)
def format_json(result: dict) -> str:
return json.dumps(result, indent=2)
def format_summary(result: dict) -> str:
cmd = result.get("command", "")
path = result.get("file", "")
lines = []
lines.append(f"tailwind.config {cmd}: {path}")
counts = result.get("counts", {})
lines.append(f"Errors: {counts.get('errors', 0)}")
lines.append(f"Warnings: {counts.get('warnings', 0)}")
lines.append(f"Info: {counts.get('infos', 0)}")
if "valid" in result:
lines.append(f"Valid: {'yes' if result['valid'] else 'no'}")
if "passed" in result:
lines.append(f"Passed: {'yes' if result['passed'] else 'no'}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Validate JSON-exported Tailwind CSS configuration files",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Commands:
validate Full validation with all rules
lint Run all rules (alias for validate)
content Check content configuration
theme Check theme configuration
Note: Tailwind configs are JS/TS. This validator works with JSON-exported configs.
Export via: node -e "console.log(JSON.stringify(require('./tailwind.config.js')))" > config.json
Examples:
python3 tailwind_config_validator.py validate config.json
python3 tailwind_config_validator.py validate config.json --strict --format json
python3 tailwind_config_validator.py lint config.json --format summary
python3 tailwind_config_validator.py content config.json
python3 tailwind_config_validator.py theme config.json --format json
"""
)
parser.add_argument("command", choices=["validate", "lint", "content", "theme"],
help="Command to run")
parser.add_argument("file", help="Path to JSON-exported Tailwind config")
parser.add_argument("--strict", action="store_true",
help="Treat warnings as errors (CI mode)")
parser.add_argument("--format", choices=["text", "json", "summary"], default="text",
help="Output format (default: text)")
args = parser.parse_args()
# Load and parse file
data, parse_error = load_config(args.file)
if parse_error:
result = {
"command": args.command,
"file": args.file,
"findings": [parse_error.to_dict()],
"counts": {"errors": 1, "warnings": 0, "infos": 0, "total": 1},
"summary": "1 error(s)",
}
if args.command in ("validate", "lint"):
result["valid"] = False
elif args.command in ("content", "theme"):
result["passed"] = False
formatter = {"text": format_text, "json": format_json, "summary": format_summary}
print(formatter[args.format](result))
sys.exit(2)
# Run command
if args.command == "validate":
result = cmd_validate(data, args.file)
elif args.command == "lint":
result = cmd_lint(data, args.file)
elif args.command == "content":
result = cmd_content(data, args.file)
elif args.command == "theme":
result = cmd_theme(data, args.file)
# Format output
formatter = {"text": format_text, "json": format_json, "summary": format_summary}
print(formatter[args.format](result))
# Exit code
findings = result.get("findings", [])
has_errors = any(f["severity"] == "error" for f in findings)
has_warnings = any(f["severity"] == "warning" for f in findings)
if has_errors:
sys.exit(1)
if args.strict and has_warnings:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
Validate ESLint v9+ flat config files (JSON-exported) for structural correctness, language options, rules configuration, plugin hygiene, file patterns, and b...
---
name: eslint-flat-config-validator
description: Validate ESLint v9+ flat config files (JSON-exported) for structural correctness, language options, rules configuration, plugin hygiene, file patterns, and best practices. Use when auditing ESLint projects, enforcing config standards in CI, or reviewing eslint.config.js changes.
---
# ESLint Flat Config Validator
Validate ESLint v9+ flat configuration files exported as JSON for structural correctness, language options, rules configuration, plugin setup, file/ignore patterns, and best practices. Uses pure Python 3 stdlib (`json`, `argparse`, `re`, `os`, `sys`) -- no external dependencies.
Since ESLint flat configs are JS/MJS/CJS (`eslint.config.js`), the validator works with JSON-exported snapshots. Export your config first:
```bash
node -e "import('./eslint.config.js').then(m => console.log(JSON.stringify(m.default)))" > eslint.config.json
```
Then validate the JSON output.
## Commands
### validate -- Comprehensive validation with all rules and summary
```bash
python3 scripts/eslint_flat_config_validator.py validate eslint.config.json
python3 scripts/eslint_flat_config_validator.py validate eslint.config.json --strict
python3 scripts/eslint_flat_config_validator.py validate eslint.config.json --format json
```
### lint -- Run all rules
```bash
python3 scripts/eslint_flat_config_validator.py lint eslint.config.json
python3 scripts/eslint_flat_config_validator.py lint eslint.config.json --format summary
```
### rules -- Check rules configuration
```bash
python3 scripts/eslint_flat_config_validator.py rules eslint.config.json
python3 scripts/eslint_flat_config_validator.py rules eslint.config.json --format json
```
### plugins -- Check plugin configuration
```bash
python3 scripts/eslint_flat_config_validator.py plugins eslint.config.json
python3 scripts/eslint_flat_config_validator.py plugins eslint.config.json --format json
```
## Flags
| Flag | Description |
|------|-------------|
| `--strict` | Treat warnings as errors -- exit code 1 (CI-friendly) |
| `--format text` | Human-readable output (default) |
| `--format json` | Machine-readable JSON |
| `--format summary` | Compact summary with counts |
## Validation Rules (25)
### Structure (5)
| Rule | Severity | Description |
|------|----------|-------------|
| S1 | error | File not found or unreadable |
| S2 | error | Empty config (empty array or no objects) |
| S3 | error | JSON syntax errors |
| S4 | error | Not an array (flat config must be an array of config objects) |
| S5 | warning | Unknown top-level keys in config objects (valid: files, ignores, languageOptions, linterOptions, plugins, processor, rules, settings, name) |
### Language Options (5)
| Rule | Severity | Description |
|------|----------|-------------|
| L1 | error | Invalid ecmaVersion (must be number >= 3 or "latest") |
| L2 | error | Invalid sourceType (must be "module", "script", or "commonjs") |
| L3 | warning | Invalid parser value (should be object with parse/parseForESTree, warn if string) |
| L4 | error | globals with invalid values (only "readonly"/"writable"/"off" or true/false/"readable") |
| L5 | info | Missing ecmaVersion (defaults to "latest" in ESLint v9) |
### Rules (5)
| Rule | Severity | Description |
|------|----------|-------------|
| R1 | error | Unknown severity (must be "off"/0, "warn"/1, "error"/2) |
| R2 | warning | Rules with deprecated names |
| R3 | warning | Conflicting rules (e.g., indent + @typescript-eslint/indent) |
| R4 | info | Empty rules object |
| R5 | error | Rule config not array or severity (must be severity or [severity, ...options]) |
### Plugins (3)
| Rule | Severity | Description |
|------|----------|-------------|
| P1 | info | Empty plugins object |
| P2 | error | Plugin value not object (plugin values should be plugin objects) |
| P3 | warning | Duplicate plugin key across config objects |
### Files/Ignores (4)
| Rule | Severity | Description |
|------|----------|-------------|
| F1 | info | Missing files pattern in non-global config (config without files/ignores applies globally) |
| F2 | error | Invalid glob patterns (empty string) |
| F3 | error | files as string instead of array |
| F4 | error | ignores as string instead of array |
### Best Practices (3)
| Rule | Severity | Description |
|------|----------|-------------|
| X1 | warning | No rules defined in any config object |
| X2 | warning | Many config objects (>20) suggest consolidation |
| X3 | info | Missing "name" property (recommended in v9 for debugging) |
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | No errors (warnings allowed unless `--strict`) |
| 1 | Errors found (or warnings in `--strict` mode) |
| 2 | File not found / parse error |
## CI Integration
```yaml
# GitHub Actions
- name: Validate ESLint flat config
run: |
node -e "import('./eslint.config.js').then(m => console.log(JSON.stringify(m.default)))" > /tmp/eslint.config.json
python3 scripts/eslint_flat_config_validator.py validate /tmp/eslint.config.json --strict --format json
```
## Example Output
```
eslint.config validate — eslint.config.json
============================================
[ERROR ] S5: Unknown top-level key in config object #2: 'env'
'env' is not valid in flat config. Valid keys: files, ignores, languageOptions, linterOptions, plugins, processor, rules, settings, name
[ERROR ] R1: Invalid rule severity for 'no-unused-vars': 'on'
Severity must be 'off'/0, 'warn'/1, or 'error'/2.
[WARNING] R2: Deprecated rule 'no-buffer-constructor' in config object #1
This rule was deprecated in ESLint v7. Remove it or replace with the recommended alternative.
[WARNING] X1: No rules defined in any config object
At least one config object should define rules for ESLint to enforce anything.
[INFO ] X3: Config object #3 missing 'name' property
Adding a name helps identify config objects in ESLint's debug output and error messages.
Result: INVALID
Summary: 2 error(s), 2 warning(s), 1 info
```
FILE:scripts/eslint_flat_config_validator.py
#!/usr/bin/env python3
"""
ESLint Flat Config Validator
Validate ESLint v9+ flat configuration files (JSON-exported) for structural
correctness, language options, rules configuration, plugin hygiene, file
patterns, and best practices.
Usage: python3 eslint_flat_config_validator.py <command> <file> [--strict] [--format text|json|summary]
Commands: validate, lint, rules, plugins
"""
import sys
import os
import re
import json
import argparse
from typing import Any
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
VALID_CONFIG_KEYS = {
"files", "ignores", "languageOptions", "linterOptions", "plugins",
"processor", "rules", "settings", "name",
}
VALID_SEVERITY_STRINGS = {"off", "warn", "error"}
VALID_SEVERITY_NUMBERS = {0, 1, 2}
VALID_SOURCE_TYPES = {"module", "script", "commonjs"}
VALID_GLOBAL_VALUES = {"readonly", "writable", "off", "readable"}
VALID_GLOBAL_BOOLEANS = {True, False}
DEPRECATED_RULES = {
"no-buffer-constructor": "Deprecated in ESLint v7. Use Buffer.from() / Buffer.alloc() instead.",
"no-new-require": "Deprecated in ESLint v7. Use ESM imports.",
"no-path-concat": "Deprecated in ESLint v7. Use path.join() or path.resolve().",
"no-process-env": "Deprecated in ESLint v7.",
"no-process-exit": "Deprecated in ESLint v7. Use process.exitCode instead.",
"no-restricted-modules": "Deprecated in ESLint v7. Use no-restricted-imports.",
"no-sync": "Deprecated in ESLint v7.",
"no-mixed-requires": "Deprecated in ESLint v7.",
"callback-return": "Deprecated in ESLint v7.",
"global-require": "Deprecated in ESLint v7. Use ESM imports.",
"handle-callback-err": "Deprecated in ESLint v7.",
"no-catch-shadow": "Deprecated. Use no-shadow instead.",
"no-native-reassign": "Deprecated. Use no-global-assign instead.",
"no-negated-in-lhs": "Deprecated. Use no-unsafe-negation instead.",
"no-spaced-func": "Deprecated. Use func-call-spacing instead.",
"prefer-reflect": "Deprecated in ESLint v7.",
"require-jsdoc": "Deprecated in ESLint v5.10.",
"valid-jsdoc": "Deprecated in ESLint v5.10.",
"id-blacklist": "Deprecated. Use id-denylist instead.",
"no-return-await": "Deprecated in ESLint v8.46. Use @typescript-eslint/return-await for TS.",
}
CONFLICTING_RULE_PAIRS = [
("indent", "@typescript-eslint/indent"),
("no-unused-vars", "@typescript-eslint/no-unused-vars"),
("no-shadow", "@typescript-eslint/no-shadow"),
("no-use-before-define", "@typescript-eslint/no-use-before-define"),
("no-redeclare", "@typescript-eslint/no-redeclare"),
("no-loop-func", "@typescript-eslint/no-loop-func"),
("no-extra-semi", "@typescript-eslint/no-extra-semi"),
("no-empty-function", "@typescript-eslint/no-empty-function"),
("semi", "@typescript-eslint/semi"),
("quotes", "@typescript-eslint/quotes"),
("comma-dangle", "@typescript-eslint/comma-dangle"),
("brace-style", "@typescript-eslint/brace-style"),
("no-array-constructor", "@typescript-eslint/no-array-constructor"),
("no-dupe-class-members", "@typescript-eslint/no-dupe-class-members"),
("no-loss-of-precision", "@typescript-eslint/no-loss-of-precision"),
]
# ---------------------------------------------------------------------------
# Finding class
# ---------------------------------------------------------------------------
class Finding:
"""A single validation finding."""
SEVERITIES = ("error", "warning", "info")
def __init__(self, rule_id: str, severity: str, message: str, detail: str = ""):
assert severity in self.SEVERITIES, f"Invalid severity: {severity}"
self.rule_id = rule_id
self.severity = severity
self.message = message
self.detail = detail
def to_dict(self) -> dict:
d = {
"rule_id": self.rule_id,
"severity": self.severity,
"message": self.message,
}
if self.detail:
d["detail"] = self.detail
return d
def __repr__(self):
return f"Finding({self.rule_id}, {self.severity}, {self.message!r})"
# ---------------------------------------------------------------------------
# JSON loading
# ---------------------------------------------------------------------------
def load_config(path: str) -> tuple:
"""Load and parse an ESLint flat config JSON file. Returns (data, error_finding)."""
# S1: File not found or unreadable
if not os.path.exists(path):
return None, Finding("S1", "error", f"File not found: {path}")
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
except OSError as e:
return None, Finding("S1", "error", f"Cannot read file: {e}")
# S2: Empty config
if len(content.strip()) == 0:
return None, Finding("S2", "error", "Config file is empty")
# S3: Invalid JSON syntax
try:
data = json.loads(content)
except json.JSONDecodeError as e:
return None, Finding("S3", "error", f"Invalid JSON syntax: {e}")
# S4: Not an array (flat config must be array)
if not isinstance(data, list):
return None, Finding("S4", "error",
"Flat config must be a JSON array of config objects (got {})".format(
type(data).__name__))
# S2: Empty array
if len(data) == 0:
return None, Finding("S2", "error",
"Config is an empty array — no config objects defined")
return data, None
# ---------------------------------------------------------------------------
# Individual check functions
# ---------------------------------------------------------------------------
def check_structure(data: list) -> list:
"""S5: Check for unknown top-level keys in config objects."""
findings = []
for i, obj in enumerate(data):
idx = i + 1
if not isinstance(obj, dict):
findings.append(Finding("S4", "error",
f"Config object #{idx} is not an object (got {type(obj).__name__})",
"Each element in the flat config array must be a config object."))
continue
unknown = set(obj.keys()) - VALID_CONFIG_KEYS
if unknown:
for key in sorted(unknown):
findings.append(Finding("S5", "warning",
f"Unknown top-level key in config object #{idx}: '{key}'",
f"'{key}' is not valid in flat config. Valid keys: "
f"{', '.join(sorted(VALID_CONFIG_KEYS))}"))
return findings
def check_language_options(data: list) -> list:
"""L1-L5: Check languageOptions in config objects."""
findings = []
any_ecma_version = False
for i, obj in enumerate(data):
if not isinstance(obj, dict):
continue
idx = i + 1
lang = obj.get("languageOptions")
if lang is None:
continue
if not isinstance(lang, dict):
findings.append(Finding("L1", "error",
f"languageOptions in config object #{idx} must be an object",
"Set languageOptions: { ecmaVersion: 'latest', sourceType: 'module' }"))
continue
# L1: Invalid ecmaVersion
ecma = lang.get("ecmaVersion")
if ecma is not None:
any_ecma_version = True
if isinstance(ecma, str):
if ecma != "latest":
findings.append(Finding("L1", "error",
f"Invalid ecmaVersion in config object #{idx}: '{ecma}'",
"ecmaVersion must be a number >= 3 or 'latest'."))
elif isinstance(ecma, (int, float)):
ecma_int = int(ecma)
if ecma_int < 3:
findings.append(Finding("L1", "error",
f"Invalid ecmaVersion in config object #{idx}: {ecma_int}",
"ecmaVersion must be >= 3. Common values: 2020, 2022, 2024, or 'latest'."))
else:
findings.append(Finding("L1", "error",
f"Invalid ecmaVersion type in config object #{idx}: {type(ecma).__name__}",
"ecmaVersion must be a number >= 3 or 'latest'."))
# L2: Invalid sourceType
source_type = lang.get("sourceType")
if source_type is not None:
if source_type not in VALID_SOURCE_TYPES:
findings.append(Finding("L2", "error",
f"Invalid sourceType in config object #{idx}: '{source_type}'",
"sourceType must be 'module', 'script', or 'commonjs'."))
# L3: Invalid parser value
parser = lang.get("parser")
if parser is not None:
if isinstance(parser, str):
findings.append(Finding("L3", "warning",
f"Parser in config object #{idx} is a string: '{parser}'",
"In flat config, parser should be a parser object (imported module), "
"not a string. Import the parser and pass the object directly."))
elif isinstance(parser, dict):
has_parse = "parse" in parser or "parseForESTree" in parser
if not has_parse:
findings.append(Finding("L3", "warning",
f"Parser object in config object #{idx} missing parse/parseForESTree method",
"A valid parser object should have a 'parse' or 'parseForESTree' method."))
# L4: globals with invalid values
globals_obj = lang.get("globals")
if isinstance(globals_obj, dict):
for glob_name, glob_val in globals_obj.items():
if isinstance(glob_val, str):
if glob_val.lower() not in VALID_GLOBAL_VALUES:
findings.append(Finding("L4", "error",
f"Invalid global value for '{glob_name}' in config object #{idx}: '{glob_val}'",
"Valid global values: 'readonly', 'writable', 'off', 'readable', true, false."))
elif isinstance(glob_val, bool):
pass # true/false are valid
else:
findings.append(Finding("L4", "error",
f"Invalid global value type for '{glob_name}' in config object #{idx}: "
f"{type(glob_val).__name__}",
"Global values must be 'readonly', 'writable', 'off', true, or false."))
# L5: Missing ecmaVersion hint
if not any_ecma_version:
has_any_lang = any(
isinstance(obj, dict) and "languageOptions" in obj
for obj in data
)
if has_any_lang:
findings.append(Finding("L5", "info",
"No ecmaVersion specified in any languageOptions",
"ESLint v9 defaults ecmaVersion to 'latest'. Set it explicitly if you "
"need to target a specific ECMAScript version."))
return findings
def check_rules(data: list) -> list:
"""R1-R5: Check rules configuration."""
findings = []
any_rules = False
all_rule_names = set()
for i, obj in enumerate(data):
if not isinstance(obj, dict):
continue
idx = i + 1
rules = obj.get("rules")
if rules is None:
continue
if not isinstance(rules, dict):
findings.append(Finding("R5", "error",
f"rules in config object #{idx} must be an object",
"Set rules: { 'rule-name': 'error', ... }"))
continue
# R4: Empty rules object
if len(rules) == 0:
findings.append(Finding("R4", "info",
f"Empty rules object in config object #{idx}",
"The rules object is empty. Add rules or remove the key."))
continue
any_rules = True
for rule_name, rule_config in rules.items():
all_rule_names.add(rule_name)
# R5: Rule config must be severity or [severity, ...options]
severity = None
if isinstance(rule_config, (int, float)):
severity = int(rule_config)
elif isinstance(rule_config, str):
severity = rule_config
elif isinstance(rule_config, list):
if len(rule_config) == 0:
findings.append(Finding("R5", "error",
f"Rule '{rule_name}' in config object #{idx} has empty array config",
"Rule config array must have at least a severity: ['error', ...options]"))
continue
severity = rule_config[0]
if isinstance(severity, (int, float)):
severity = int(severity)
else:
findings.append(Finding("R5", "error",
f"Rule '{rule_name}' in config object #{idx} has invalid config type: "
f"{type(rule_config).__name__}",
"Rule config must be a severity ('off'/'warn'/'error' or 0/1/2) "
"or an array [severity, ...options]."))
continue
# R1: Unknown severity
if severity is not None:
valid = False
if isinstance(severity, int) and severity in VALID_SEVERITY_NUMBERS:
valid = True
elif isinstance(severity, str) and severity in VALID_SEVERITY_STRINGS:
valid = True
if not valid:
findings.append(Finding("R1", "error",
f"Invalid rule severity for '{rule_name}' in config object #{idx}: "
f"{severity!r}",
"Severity must be 'off'/0, 'warn'/1, or 'error'/2."))
# R2: Deprecated rule names
if rule_name in DEPRECATED_RULES:
findings.append(Finding("R2", "warning",
f"Deprecated rule '{rule_name}' in config object #{idx}",
DEPRECATED_RULES[rule_name]))
# R3: Conflicting rules (across all config objects)
for base_rule, ts_rule in CONFLICTING_RULE_PAIRS:
if base_rule in all_rule_names and ts_rule in all_rule_names:
findings.append(Finding("R3", "warning",
f"Conflicting rules: '{base_rule}' and '{ts_rule}'",
f"When using '{ts_rule}', disable the base rule '{base_rule}' "
f"to avoid duplicate reports. Set '{base_rule}': 'off'."))
return findings, any_rules
def check_plugins(data: list) -> list:
"""P1-P3: Check plugin configuration."""
findings = []
seen_plugins = {} # plugin_key -> first config object index
for i, obj in enumerate(data):
if not isinstance(obj, dict):
continue
idx = i + 1
plugins = obj.get("plugins")
if plugins is None:
continue
if not isinstance(plugins, dict):
findings.append(Finding("P2", "error",
f"plugins in config object #{idx} must be an object",
"Set plugins: { 'plugin-name': pluginObject, ... }"))
continue
# P1: Empty plugins object
if len(plugins) == 0:
findings.append(Finding("P1", "info",
f"Empty plugins object in config object #{idx}",
"The plugins object is empty. Add plugins or remove the key."))
continue
for plugin_key, plugin_val in plugins.items():
# P2: Plugin value not object
if not isinstance(plugin_val, dict):
findings.append(Finding("P2", "error",
f"Plugin '{plugin_key}' in config object #{idx} is not an object "
f"(got {type(plugin_val).__name__})",
"Plugin values must be plugin objects (imported modules). "
"Import the plugin and pass the object directly."))
# P3: Duplicate plugin across config objects
if plugin_key in seen_plugins:
prev_idx = seen_plugins[plugin_key]
findings.append(Finding("P3", "warning",
f"Duplicate plugin '{plugin_key}' in config object #{idx} "
f"(first seen in #{prev_idx})",
"Plugins only need to be registered once. Registering the same "
"plugin in multiple config objects is redundant."))
else:
seen_plugins[plugin_key] = idx
return findings
def check_files_ignores(data: list) -> list:
"""F1-F4: Check files and ignores configuration."""
findings = []
for i, obj in enumerate(data):
if not isinstance(obj, dict):
continue
idx = i + 1
files = obj.get("files")
ignores = obj.get("ignores")
# F1: Missing files pattern (non-global config hint)
has_files = files is not None
has_ignores = ignores is not None
has_rules = "rules" in obj or "plugins" in obj or "languageOptions" in obj
if not has_files and not has_ignores and has_rules:
findings.append(Finding("F1", "info",
f"Config object #{idx} has no files/ignores — applies globally",
"Config objects without files or ignores apply to all files. "
"Add a files pattern to scope this config (e.g., files: ['**/*.js'])."))
# F3: files as string instead of array
if files is not None:
if isinstance(files, str):
findings.append(Finding("F3", "error",
f"files in config object #{idx} is a string instead of array",
"files must be an array of glob patterns: "
"files: ['**/*.js', '**/*.ts']"))
elif isinstance(files, list):
# F2: Invalid glob patterns
for pattern in files:
if isinstance(pattern, str) and len(pattern.strip()) == 0:
findings.append(Finding("F2", "error",
f"Empty glob pattern in files of config object #{idx}",
"Remove empty strings from the files array."))
elif isinstance(pattern, list):
# Nested array (files can be array of arrays for AND patterns)
for sub in pattern:
if isinstance(sub, str) and len(sub.strip()) == 0:
findings.append(Finding("F2", "error",
f"Empty glob pattern in nested files array of config object #{idx}",
"Remove empty strings from file patterns."))
# F4: ignores as string instead of array
if ignores is not None:
if isinstance(ignores, str):
findings.append(Finding("F4", "error",
f"ignores in config object #{idx} is a string instead of array",
"ignores must be an array of glob patterns: "
"ignores: ['node_modules/**', 'dist/**']"))
elif isinstance(ignores, list):
# F2: Invalid glob patterns in ignores
for pattern in ignores:
if isinstance(pattern, str) and len(pattern.strip()) == 0:
findings.append(Finding("F2", "error",
f"Empty glob pattern in ignores of config object #{idx}",
"Remove empty strings from the ignores array."))
return findings
def check_best_practices(data: list, any_rules: bool) -> list:
"""X1-X3: Check best practices."""
findings = []
# X1: No rules defined in any config object
if not any_rules:
findings.append(Finding("X1", "warning",
"No rules defined in any config object",
"At least one config object should define rules for ESLint to enforce "
"anything. Add a rules object with your desired rule settings."))
# X2: Many config objects (>20) suggest consolidation
obj_count = len(data)
if obj_count > 20:
findings.append(Finding("X2", "warning",
f"Config has {obj_count} objects — consider consolidation",
"Having more than 20 config objects can make the config hard to maintain. "
"Consider merging related configs or using shared config presets."))
# X3: Missing name property
for i, obj in enumerate(data):
if not isinstance(obj, dict):
continue
idx = i + 1
if "name" not in obj:
findings.append(Finding("X3", "info",
f"Config object #{idx} missing 'name' property",
"Adding a name helps identify config objects in ESLint's debug output "
"and error messages. Example: name: 'my-app/base'"))
return findings
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
def validate_all(data: list) -> list:
"""Run all checks and return combined findings."""
findings = []
findings.extend(check_structure(data))
findings.extend(check_language_options(data))
rules_findings, any_rules = check_rules(data)
findings.extend(rules_findings)
findings.extend(check_plugins(data))
findings.extend(check_files_ignores(data))
findings.extend(check_best_practices(data, any_rules))
return findings
def validate_rules_only(data: list) -> list:
"""Run only rules-related checks (R1-R5, plus structure)."""
findings = []
findings.extend(check_structure(data))
rules_findings, _ = check_rules(data)
findings.extend(rules_findings)
return findings
def validate_plugins_only(data: list) -> list:
"""Run only plugin-related checks (P1-P3, plus structure)."""
findings = []
findings.extend(check_structure(data))
findings.extend(check_plugins(data))
return findings
# ---------------------------------------------------------------------------
# Rule explanations
# ---------------------------------------------------------------------------
RULE_EXPLANATIONS = {
"S1": {
"name": "File Not Found",
"category": "Structure",
"severity": "error",
"description": "The config JSON file does not exist or cannot be read.",
"fix": "Ensure the file path is correct and the file has read permissions.",
},
"S2": {
"name": "Empty Config",
"category": "Structure",
"severity": "error",
"description": "The config file is empty or contains an empty array.",
"fix": "Export your ESLint flat config to JSON: node -e \"import('./eslint.config.js').then(m => console.log(JSON.stringify(m.default)))\" > eslint.config.json",
},
"S3": {
"name": "Invalid JSON",
"category": "Structure",
"severity": "error",
"description": "The file contains invalid JSON syntax that cannot be parsed.",
"fix": "Fix the JSON syntax error. Use a JSON linter or re-export from eslint.config.js.",
},
"S4": {
"name": "Not an Array",
"category": "Structure",
"severity": "error",
"description": "Flat config must be an array of config objects. Got a non-array value.",
"fix": "Export the config as an array: export default [{ ... }, { ... }]",
},
"S5": {
"name": "Unknown Config Key",
"category": "Structure",
"severity": "warning",
"description": "A config object contains a key not recognized by ESLint v9 flat config.",
"fix": "Valid keys: files, ignores, languageOptions, linterOptions, plugins, processor, rules, settings, name.",
},
"L1": {
"name": "Invalid ecmaVersion",
"category": "Language Options",
"severity": "error",
"description": "ecmaVersion must be a number >= 3 (e.g., 2020, 2024) or the string 'latest'.",
"fix": "Set ecmaVersion to a year (2020-2024), a version number (3-15), or 'latest'.",
},
"L2": {
"name": "Invalid sourceType",
"category": "Language Options",
"severity": "error",
"description": "sourceType must be 'module', 'script', or 'commonjs'.",
"fix": "Set sourceType: 'module' for ESM, 'commonjs' for CJS, or 'script' for plain scripts.",
},
"L3": {
"name": "Invalid Parser",
"category": "Language Options",
"severity": "warning",
"description": "Parser should be a parser object, not a string. In flat config, import the parser module.",
"fix": "Import the parser: import tsParser from '@typescript-eslint/parser'; then use parser: tsParser.",
},
"L4": {
"name": "Invalid Global Value",
"category": "Language Options",
"severity": "error",
"description": "Global variable values must be 'readonly', 'writable', 'off', 'readable', true, or false.",
"fix": "Use 'readonly'/'writable'/'off' or true (writable) / false (readonly).",
},
"L5": {
"name": "Missing ecmaVersion",
"category": "Language Options",
"severity": "info",
"description": "No ecmaVersion set. ESLint v9 defaults to 'latest'.",
"fix": "Set ecmaVersion explicitly if targeting a specific ECMAScript version.",
},
"R1": {
"name": "Invalid Severity",
"category": "Rules",
"severity": "error",
"description": "Rule severity must be 'off'/0, 'warn'/1, or 'error'/2.",
"fix": "Use 'off' (or 0), 'warn' (or 1), or 'error' (or 2) as the severity.",
},
"R2": {
"name": "Deprecated Rule",
"category": "Rules",
"severity": "warning",
"description": "A deprecated ESLint rule is in use. It may be removed in future versions.",
"fix": "Remove the deprecated rule or replace with the recommended alternative.",
},
"R3": {
"name": "Conflicting Rules",
"category": "Rules",
"severity": "warning",
"description": "Both a base ESLint rule and its TypeScript-ESLint equivalent are enabled, causing duplicate reports.",
"fix": "Disable the base rule when using the @typescript-eslint equivalent.",
},
"R4": {
"name": "Empty Rules Object",
"category": "Rules",
"severity": "info",
"description": "A config object has an empty rules object with no rule definitions.",
"fix": "Add rules or remove the empty rules object.",
},
"R5": {
"name": "Invalid Rule Config",
"category": "Rules",
"severity": "error",
"description": "Rule config must be a severity value or an array with severity as the first element.",
"fix": "Use 'error', ['error', options], or 2 / [2, options].",
},
"P1": {
"name": "Empty Plugins Object",
"category": "Plugins",
"severity": "info",
"description": "A config object has an empty plugins object.",
"fix": "Add plugins or remove the empty plugins object.",
},
"P2": {
"name": "Invalid Plugin Value",
"category": "Plugins",
"severity": "error",
"description": "Plugin values must be plugin objects (imported modules), not strings or other types.",
"fix": "Import the plugin: import pluginName from 'eslint-plugin-name'; then plugins: { name: pluginName }.",
},
"P3": {
"name": "Duplicate Plugin",
"category": "Plugins",
"severity": "warning",
"description": "The same plugin key appears in multiple config objects.",
"fix": "Register each plugin once. Subsequent config objects can reference plugin rules without re-registering.",
},
"F1": {
"name": "Global Config Object",
"category": "Files/Ignores",
"severity": "info",
"description": "A config object has no files or ignores pattern, so it applies globally to all files.",
"fix": "Add files: ['**/*.js'] to scope the config, or leave global intentionally.",
},
"F2": {
"name": "Invalid Glob Pattern",
"category": "Files/Ignores",
"severity": "error",
"description": "An empty string was found in files or ignores glob patterns.",
"fix": "Remove empty strings from the glob pattern array.",
},
"F3": {
"name": "Files as String",
"category": "Files/Ignores",
"severity": "error",
"description": "files must be an array, not a string.",
"fix": "Wrap the pattern in an array: files: ['**/*.js'] instead of files: '**/*.js'.",
},
"F4": {
"name": "Ignores as String",
"category": "Files/Ignores",
"severity": "error",
"description": "ignores must be an array, not a string.",
"fix": "Wrap the pattern in an array: ignores: ['node_modules/**'] instead of ignores: 'node_modules/**'.",
},
"X1": {
"name": "No Rules Defined",
"category": "Best Practices",
"severity": "warning",
"description": "No config object defines any rules. ESLint will not enforce anything.",
"fix": "Add rules to at least one config object, or extend a shared config that includes rules.",
},
"X2": {
"name": "Too Many Config Objects",
"category": "Best Practices",
"severity": "warning",
"description": "The config has more than 20 objects, which can be hard to maintain.",
"fix": "Consolidate related configs or use shared config presets to reduce complexity.",
},
"X3": {
"name": "Missing Name",
"category": "Best Practices",
"severity": "info",
"description": "A config object is missing the 'name' property recommended in ESLint v9.",
"fix": "Add name: 'my-app/base' to help identify configs in debug output.",
},
}
# ---------------------------------------------------------------------------
# Summary helper
# ---------------------------------------------------------------------------
def _summary_counts(findings: list) -> dict:
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
infos = sum(1 for f in findings if f.severity == "info")
return {"errors": errors, "warnings": warnings, "infos": infos, "total": len(findings)}
def _summary_text(findings: list) -> str:
c = _summary_counts(findings)
parts = []
if c["errors"]:
parts.append(f"{c['errors']} error(s)")
if c["warnings"]:
parts.append(f"{c['warnings']} warning(s)")
if c["infos"]:
parts.append(f"{c['infos']} info")
return ", ".join(parts) if parts else "No issues found"
# ---------------------------------------------------------------------------
# Command handlers
# ---------------------------------------------------------------------------
def cmd_validate(data: list, path: str) -> dict:
"""Comprehensive validation with all rules and summary."""
findings = validate_all(data)
errors = [f for f in findings if f.severity == "error"]
return {
"command": "validate",
"file": path,
"valid": len(errors) == 0,
"findings": [f.to_dict() for f in findings],
"counts": _summary_counts(findings),
"summary": _summary_text(findings),
}
def cmd_lint(data: list, path: str) -> dict:
"""Run all rules (same as validate but labeled lint)."""
findings = validate_all(data)
has_errors = any(f.severity == "error" for f in findings)
return {
"command": "lint",
"file": path,
"passed": not has_errors,
"findings": [f.to_dict() for f in findings],
"counts": _summary_counts(findings),
"summary": _summary_text(findings),
}
def cmd_rules(data: list, path: str) -> dict:
"""Check rules configuration only."""
findings = validate_rules_only(data)
has_errors = any(f.severity == "error" for f in findings)
return {
"command": "rules",
"file": path,
"passed": not has_errors,
"findings": [f.to_dict() for f in findings],
"counts": _summary_counts(findings),
"summary": _summary_text(findings),
}
def cmd_plugins(data: list, path: str) -> dict:
"""Check plugin configuration only."""
findings = validate_plugins_only(data)
has_errors = any(f.severity == "error" for f in findings)
return {
"command": "plugins",
"file": path,
"passed": not has_errors,
"findings": [f.to_dict() for f in findings],
"counts": _summary_counts(findings),
"summary": _summary_text(findings),
}
# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------
def format_text(result: dict) -> str:
cmd = result.get("command", "")
path = result.get("file", "")
lines = []
title = f"eslint.config {cmd} — {path}"
lines.append(title)
lines.append("=" * len(title))
findings = result.get("findings", [])
if not findings:
lines.append("[OK] No issues found")
else:
for f in findings:
sev = f["severity"].upper().ljust(7)
lines.append(f"[{sev}] {f['rule_id']}: {f['message']}")
if f.get("detail"):
lines.append(f" {f['detail']}")
if "valid" in result:
valid_str = "VALID" if result.get("valid") else "INVALID"
lines.append("")
lines.append(f"Result: {valid_str}")
if "passed" in result:
passed_str = "PASSED" if result.get("passed") else "FAILED"
lines.append("")
lines.append(f"Result: {passed_str}")
summary = result.get("summary")
if summary:
lines.append(f"Summary: {summary}")
return "\n".join(lines)
def format_json(result: dict) -> str:
return json.dumps(result, indent=2)
def format_summary(result: dict) -> str:
cmd = result.get("command", "")
path = result.get("file", "")
lines = []
lines.append(f"eslint.config {cmd}: {path}")
counts = result.get("counts", {})
lines.append(f"Errors: {counts.get('errors', 0)}")
lines.append(f"Warnings: {counts.get('warnings', 0)}")
lines.append(f"Info: {counts.get('infos', 0)}")
if "valid" in result:
lines.append(f"Valid: {'yes' if result['valid'] else 'no'}")
if "passed" in result:
lines.append(f"Passed: {'yes' if result['passed'] else 'no'}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Validate ESLint v9+ flat configuration files (JSON-exported)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Commands:
validate Comprehensive validation with all rules and summary
lint Run all rules
rules Check rules configuration
plugins Check plugin configuration
Export your eslint.config.js to JSON first:
node -e "import('./eslint.config.js').then(m => console.log(JSON.stringify(m.default)))" > eslint.config.json
Examples:
python3 eslint_flat_config_validator.py validate eslint.config.json
python3 eslint_flat_config_validator.py validate eslint.config.json --strict
python3 eslint_flat_config_validator.py lint eslint.config.json --format json
python3 eslint_flat_config_validator.py rules eslint.config.json
python3 eslint_flat_config_validator.py plugins eslint.config.json --format summary
"""
)
parser.add_argument("command", choices=["validate", "lint", "rules", "plugins"],
help="Command to run")
parser.add_argument("file", help="Path to ESLint flat config JSON file")
parser.add_argument("--strict", action="store_true",
help="Treat warnings as errors (CI mode)")
parser.add_argument("--format", choices=["text", "json", "summary"], default="text",
help="Output format (default: text)")
args = parser.parse_args()
# Load and parse file
data, parse_error = load_config(args.file)
if parse_error:
result = {
"command": args.command,
"file": args.file,
"findings": [parse_error.to_dict()],
"counts": {"errors": 1, "warnings": 0, "infos": 0, "total": 1},
"summary": "1 error(s)",
}
if args.command == "validate":
result["valid"] = False
elif args.command in ("lint", "rules", "plugins"):
result["passed"] = False
formatter = {"text": format_text, "json": format_json, "summary": format_summary}
print(formatter[args.format](result))
sys.exit(2)
# Run command
if args.command == "validate":
result = cmd_validate(data, args.file)
elif args.command == "lint":
result = cmd_lint(data, args.file)
elif args.command == "rules":
result = cmd_rules(data, args.file)
elif args.command == "plugins":
result = cmd_plugins(data, args.file)
# Format output
formatter = {"text": format_text, "json": format_json, "summary": format_summary}
print(formatter[args.format](result))
# Exit code
findings = result.get("findings", [])
has_errors = any(f["severity"] == "error" for f in findings)
has_warnings = any(f["severity"] == "warning" for f in findings)
if has_errors:
sys.exit(1)
if args.strict and has_warnings:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
Validate Vite configuration files (JSON-exported) for structural correctness, build settings, server security, resolve/CSS hygiene, plugin deprecations, and...
---
name: vite-config-validator
description: Validate Vite configuration files (JSON-exported) for structural correctness, build settings, server security, resolve/CSS hygiene, plugin deprecations, and best practices. Use when auditing Vite projects, enforcing config standards in CI, or reviewing vite.config.ts changes.
---
# Vite Config Validator
Validate Vite configuration files exported as JSON for structural correctness, build settings, server configuration, resolve/CSS options, plugin hygiene, and best practices. Uses pure Python 3 stdlib (`json`, `argparse`, `re`, `os`, `sys`) -- no external dependencies.
Since Vite configs are JS/TS (`vite.config.ts`), the validator works with JSON-exported snapshots. Export your config first:
```bash
node -e "import('./vite.config.ts').then(m => console.log(JSON.stringify(m.default)))" > vite.config.json
```
Then validate the JSON output.
## Commands
### validate -- Full validation with all rules
```bash
python3 scripts/vite_config_validator.py validate vite.config.json
python3 scripts/vite_config_validator.py validate vite.config.json --strict
python3 scripts/vite_config_validator.py validate vite.config.json --format json
```
### check -- Quick check (errors and warnings only)
```bash
python3 scripts/vite_config_validator.py check vite.config.json
python3 scripts/vite_config_validator.py check vite.config.json --format summary
```
### explain -- Show all rules with descriptions
```bash
python3 scripts/vite_config_validator.py explain vite.config.json
python3 scripts/vite_config_validator.py explain vite.config.json --format json
```
### suggest -- Run validation and propose fixes
```bash
python3 scripts/vite_config_validator.py suggest vite.config.json
python3 scripts/vite_config_validator.py suggest vite.config.json --format json
```
## Flags
| Flag | Description |
|------|-------------|
| `--strict` | Treat warnings as errors -- exit code 1 (CI-friendly) |
| `--format text` | Human-readable output (default) |
| `--format json` | Machine-readable JSON |
| `--format summary` | Compact summary with counts |
## Validation Rules (25)
### Structure (5)
| Rule | Severity | Description |
|------|----------|-------------|
| S1 | error | File not found or unreadable |
| S2 | error | Empty config file |
| S3 | error | Invalid JSON syntax |
| S4 | warning | Unknown top-level keys (not in Vite's valid config options) |
| S5 | info | defineConfig() wrapper hint (cannot verify in JSON export) |
### Build (5)
| Rule | Severity | Description |
|------|----------|-------------|
| B1 | info | Missing build.outDir (defaults to 'dist') |
| B2 | error | Invalid build.target value |
| B3 | error | Invalid build.minify value (not boolean, 'terser', or 'esbuild') |
| B4 | warning | build.sourcemap set to 'hidden' in development mode |
| B5 | warning | Deprecated Rollup plugins (rollup-plugin-* vs @rollup/plugin-*) |
### Server (4)
| Rule | Severity | Description |
|------|----------|-------------|
| V1 | error/warning | server.port out of valid range or privileged port |
| V2 | warning | server.host set to true/0.0.0.0 (security: exposes to network) |
| V3 | warning | server.proxy with invalid target URLs |
| V4 | warning | server.https without cert/key paths |
### Resolve (3)
| Rule | Severity | Description |
|------|----------|-------------|
| R1 | warning | resolve.alias with absolute paths (portability risk) |
| R2 | info | Missing resolve.extensions for TypeScript projects |
| R3 | warning | resolve.dedupe with empty array |
### CSS (3)
| Rule | Severity | Description |
|------|----------|-------------|
| C1 | info | css.preprocessorOptions without corresponding preprocessor dependency hint |
| C2 | warning | css.modules with invalid or unknown options |
| C3 | warning | css.postcss pointing to non-existent file |
### Plugins (2)
| Rule | Severity | Description |
|------|----------|-------------|
| P1 | info | Empty plugins array |
| P2 | warning | Deprecated Vite plugin names |
### Best Practices (3)
| Rule | Severity | Description |
|------|----------|-------------|
| X1 | info | No mode set in config |
| X2 | info | Missing base path for non-root deployments |
| X3 | warning | build.chunkSizeWarningLimit too high (>2000 kB) |
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | No errors (warnings allowed unless `--strict`) |
| 1 | Errors found (or warnings in `--strict` mode) |
| 2 | File not found / parse error |
## CI Integration
```yaml
# GitHub Actions
- name: Validate Vite config
run: |
node -e "import('./vite.config.ts').then(m => console.log(JSON.stringify(m.default)))" > /tmp/vite.config.json
python3 scripts/vite_config_validator.py validate /tmp/vite.config.json --strict --format json
```
## Example Output
```
vite.config validate -- vite.config.json
=========================================
[ERROR ] B2: Invalid build.target value: 'ie11'
Valid targets: 'modules', 'esnext', 'es20XX', or browser versions like 'chrome87', 'firefox78', 'safari13'.
[WARNING] V2: server.host exposes dev server to all network interfaces
Setting host to true or '0.0.0.0' makes the dev server accessible from any device on the network. Use 'localhost' or '127.0.0.1' for local-only.
[WARNING] X3: build.chunkSizeWarningLimit is very high (5000 kB)
A limit above 2000 kB effectively silences chunk size warnings. Large chunks hurt load performance. Consider code splitting instead of raising the limit. Default is 500 kB.
[INFO ] S5: JSON export cannot verify defineConfig() wrapper
Wrap your config with defineConfig() in vite.config.ts for type safety and IDE autocompletion: export default defineConfig({ ... })
[INFO ] X1: No mode set in config
Vite defaults to 'development' for serve and 'production' for build. Set mode explicitly if you need environment-specific behavior in the config itself.
Result: INVALID
Summary: 1 error(s), 2 warning(s), 2 info
```
FILE:scripts/vite_config_validator.py
#!/usr/bin/env python3
"""
Vite Config Validator
Validate Vite configuration files (JSON-exported) for structural correctness,
build settings, server configuration, resolve/CSS options, plugin hygiene, and
best practices.
Usage: python3 vite_config_validator.py <command> <file> [--strict] [--format text|json|summary]
Commands: validate, check, explain, suggest
"""
import sys
import os
import re
import json
import argparse
from typing import Any
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
VALID_TOP_LEVEL_KEYS = {
"root", "base", "mode", "define", "plugins", "resolve", "css", "json",
"esbuild", "assetsInclude", "server", "build", "preview", "optimizeDeps",
"ssr", "worker", "test",
}
VALID_BUILD_TARGETS = {
"modules", "esnext",
"es2015", "es2016", "es2017", "es2018", "es2019", "es2020",
"es2021", "es2022", "es2023", "es2024",
"chrome87", "chrome88", "chrome89", "chrome90", "chrome91",
"firefox78", "firefox85", "firefox90",
"safari13", "safari14", "safari15",
"edge88", "edge89", "edge90",
"node12", "node14", "node16", "node18", "node20",
}
VALID_MINIFY_VALUES = {True, False, "terser", "esbuild"}
DEPRECATED_ROLLUP_PLUGINS = {
"rollup-plugin-babel": "@rollup/plugin-babel",
"rollup-plugin-node-resolve": "@rollup/plugin-node-resolve",
"rollup-plugin-commonjs": "@rollup/plugin-commonjs",
"rollup-plugin-json": "@rollup/plugin-json",
"rollup-plugin-replace": "@rollup/plugin-replace",
"rollup-plugin-alias": "@rollup/plugin-alias",
"rollup-plugin-typescript": "@rollup/plugin-typescript",
"rollup-plugin-terser": "@rollup/plugin-terser",
"rollup-plugin-url": "@rollup/plugin-url",
"rollup-plugin-image": "@rollup/plugin-image",
}
DEPRECATED_VITE_PLUGINS = {
"vite-plugin-html": "@vitejs/plugin-legacy or vite-plugin-html-config",
"vite-plugin-imp": "vite-plugin-components (auto-import)",
"vite-plugin-style-import": "vite-plugin-lib-css",
"vite-plugin-restart": "built-in Vite 5+ server.watch",
"vite-plugin-mock": "vite-plugin-mock-dev-server",
}
VALID_CSS_MODULES_OPTIONS = {
"scopeBehaviour", "globalModulePaths", "generateScopedName",
"hashPrefix", "localsConvention",
}
# ---------------------------------------------------------------------------
# Finding class
# ---------------------------------------------------------------------------
class Finding:
"""A single validation finding."""
SEVERITIES = ("error", "warning", "info")
def __init__(self, rule_id: str, severity: str, message: str, detail: str = ""):
assert severity in self.SEVERITIES, f"Invalid severity: {severity}"
self.rule_id = rule_id
self.severity = severity
self.message = message
self.detail = detail
def to_dict(self) -> dict:
d = {
"rule_id": self.rule_id,
"severity": self.severity,
"message": self.message,
}
if self.detail:
d["detail"] = self.detail
return d
def __repr__(self):
return f"Finding({self.rule_id}, {self.severity}, {self.message!r})"
# ---------------------------------------------------------------------------
# JSON loading
# ---------------------------------------------------------------------------
def load_config(path: str) -> tuple:
"""Load and parse a Vite config JSON file. Returns (data, error_finding)."""
# S1: File not found or unreadable
if not os.path.exists(path):
return None, Finding("S1", "error", f"File not found: {path}")
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
except OSError as e:
return None, Finding("S1", "error", f"Cannot read file: {e}")
# S2: Empty config
if len(content.strip()) == 0:
return None, Finding("S2", "error", "Config file is empty")
# S3: Invalid JSON syntax
try:
data = json.loads(content)
except json.JSONDecodeError as e:
return None, Finding("S3", "error", f"Invalid JSON syntax: {e}")
if not isinstance(data, dict):
return None, Finding("S3", "error",
"Config must be a JSON object (got {})".format(type(data).__name__))
return data, None
# ---------------------------------------------------------------------------
# Individual check functions
# ---------------------------------------------------------------------------
def check_structure(data: dict) -> list:
"""S4, S5: Check top-level keys and defineConfig hint."""
findings = []
# S4: Unknown top-level keys
unknown = set(data.keys()) - VALID_TOP_LEVEL_KEYS
if unknown:
for key in sorted(unknown):
findings.append(Finding("S4", "warning",
f"Unknown top-level key: '{key}'",
f"Valid keys: {', '.join(sorted(VALID_TOP_LEVEL_KEYS))}"))
# S5: defineConfig wrapper hint
findings.append(Finding("S5", "info",
"JSON export cannot verify defineConfig() wrapper",
"Wrap your config with defineConfig() in vite.config.ts for type safety "
"and IDE autocompletion: export default defineConfig({ ... })"))
return findings
def check_build(data: dict) -> list:
"""B1-B5: Check build configuration."""
findings = []
build = data.get("build")
if build is None:
return findings
if not isinstance(build, dict):
findings.append(Finding("B1", "warning",
"build must be an object",
"Set build: { outDir: 'dist', ... }"))
return findings
# B1: Missing build.outDir (info)
if "outDir" not in build:
findings.append(Finding("B1", "info",
"Missing build.outDir — defaults to 'dist'",
"Explicitly set build.outDir for clarity, especially in monorepos."))
# B2: build.target invalid value
target = build.get("target")
if target is not None:
targets = [target] if isinstance(target, str) else target
if isinstance(targets, list):
for t in targets:
if isinstance(t, str):
t_lower = t.lower()
# Check against known targets or patterns like esNNNN, chromeNN, etc.
matched = False
if t_lower in VALID_BUILD_TARGETS:
matched = True
elif re.match(r'^es20\d{2}$', t_lower):
matched = True
elif re.match(r'^(chrome|firefox|safari|edge|node)\d+$', t_lower):
matched = True
if not matched:
findings.append(Finding("B2", "error",
f"Invalid build.target value: '{t}'",
"Valid targets: 'modules', 'esnext', 'es20XX', or browser versions "
"like 'chrome87', 'firefox78', 'safari13'."))
# B3: build.minify invalid value
minify = build.get("minify")
if minify is not None:
if minify not in (True, False, "terser", "esbuild"):
findings.append(Finding("B3", "error",
f"Invalid build.minify value: {minify!r}",
"Valid values: true, false, 'terser', or 'esbuild'."))
# B4: build.sourcemap with 'hidden' in development
sourcemap = build.get("sourcemap")
mode = data.get("mode", "")
if sourcemap == "hidden" and mode in ("development", "dev"):
findings.append(Finding("B4", "warning",
"build.sourcemap is 'hidden' in development mode",
"Hidden sourcemaps in development make debugging harder. "
"Use true or 'inline' for development builds."))
# B5: Deprecated Rollup plugins in build.rollupOptions
rollup_options = build.get("rollupOptions")
if isinstance(rollup_options, dict):
plugins = rollup_options.get("plugins", [])
if isinstance(plugins, list):
for plugin in plugins:
if isinstance(plugin, (str, dict)):
plugin_name = plugin if isinstance(plugin, str) else plugin.get("name", "")
for deprecated, replacement in DEPRECATED_ROLLUP_PLUGINS.items():
if deprecated in str(plugin_name):
findings.append(Finding("B5", "warning",
f"Deprecated Rollup plugin detected: '{deprecated}'",
f"Migrate to '{replacement}'. The rollup-plugin-* namespace "
"is deprecated in favor of @rollup/plugin-*."))
# Also check for plugin references in output config
output = rollup_options.get("output")
if isinstance(output, dict):
out_plugins = output.get("plugins", [])
if isinstance(out_plugins, list):
for plugin in out_plugins:
if isinstance(plugin, (str, dict)):
plugin_name = plugin if isinstance(plugin, str) else plugin.get("name", "")
for deprecated, replacement in DEPRECATED_ROLLUP_PLUGINS.items():
if deprecated in str(plugin_name):
findings.append(Finding("B5", "warning",
f"Deprecated Rollup plugin in output: '{deprecated}'",
f"Migrate to '{replacement}'."))
return findings
def check_server(data: dict) -> list:
"""V1-V4: Check server configuration."""
findings = []
server = data.get("server")
if server is None:
return findings
if not isinstance(server, dict):
return findings
# V1: server.port out of valid range
port = server.get("port")
if port is not None:
if isinstance(port, (int, float)):
port_int = int(port)
if port_int < 1 or port_int > 65535:
findings.append(Finding("V1", "error",
f"server.port {port_int} is out of valid range (1-65535)",
"Use a port between 1024 and 65535. Common dev ports: 3000, 5173, 8080."))
elif port_int < 1024:
findings.append(Finding("V1", "warning",
f"server.port {port_int} is a privileged port (<1024)",
"Privileged ports require root access. Use a port >= 1024."))
else:
findings.append(Finding("V1", "error",
f"server.port must be a number, got {type(port).__name__}",
"Set server.port to a number like 3000 or 5173."))
# V2: server.host set to true/0.0.0.0 security warning
host = server.get("host")
if host is True or host == "0.0.0.0":
findings.append(Finding("V2", "warning",
"server.host exposes dev server to all network interfaces",
"Setting host to true or '0.0.0.0' makes the dev server accessible "
"from any device on the network. Use 'localhost' or '127.0.0.1' for local-only."))
# V3: server.proxy with invalid target URLs
proxy = server.get("proxy")
if isinstance(proxy, dict):
url_pattern = re.compile(r'^https?://[^\s]+$')
for path_key, proxy_config in proxy.items():
target = None
if isinstance(proxy_config, str):
target = proxy_config
elif isinstance(proxy_config, dict):
target = proxy_config.get("target")
if target is not None and isinstance(target, str):
if not url_pattern.match(target):
findings.append(Finding("V3", "warning",
f"Proxy target for '{path_key}' may be invalid: '{target}'",
"Proxy targets should be valid URLs like 'http://localhost:3001'."))
# V4: server.https without cert/key paths
https_config = server.get("https")
if https_config is True:
findings.append(Finding("V4", "warning",
"server.https enabled without cert/key paths",
"Set server.https to an object with 'key' and 'cert' paths, or use "
"@vitejs/plugin-basic-ssl for auto-generated certificates."))
elif isinstance(https_config, dict):
has_key = "key" in https_config
has_cert = "cert" in https_config
if not has_key or not has_cert:
missing = []
if not has_key:
missing.append("key")
if not has_cert:
missing.append("cert")
findings.append(Finding("V4", "warning",
f"server.https missing: {', '.join(missing)}",
"Both 'key' and 'cert' paths are required for HTTPS."))
return findings
def check_resolve(data: dict) -> list:
"""R1-R3: Check resolve configuration."""
findings = []
resolve = data.get("resolve")
if resolve is None:
return findings
if not isinstance(resolve, dict):
return findings
# R1: resolve.alias with absolute paths (portability)
alias = resolve.get("alias")
if isinstance(alias, dict):
for alias_key, alias_path in alias.items():
if isinstance(alias_path, str):
# Check for absolute paths that aren't using path.resolve() patterns
if alias_path.startswith("/") and not alias_path.startswith("/findings.append(Finding("R1", "warning",
f"resolve.alias '{alias_key' uses absolute path: '{alias_path}'",
"Absolute paths break portability. Use path.resolve() or "
"fileURLToPath(new URL('./src', import.meta.url)) in vite.config.ts."))
elif isinstance(alias, list):
for entry in alias:
if isinstance(entry, dict):
replacement = entry.get("replacement", "")
find = entry.get("find", "")
if isinstance(replacement, str) and replacement.startswith("/"):
findings.append(Finding("R1", "warning",
f"resolve.alias for '{find}' uses absolute path: '{replacement}'",
"Absolute paths break portability across machines/CI."))
# R2: Missing resolve.extensions for TypeScript projects
extensions = resolve.get("extensions")
# Heuristic: if there are .ts/.tsx references in alias or other config hints
has_ts_hints = False
if isinstance(alias, dict):
for v in alias.values():
if isinstance(v, str) and (".ts" in v or "typescript" in v.lower()):
has_ts_hints = True
break
# Also check esbuild or build config for TS hints
esbuild = data.get("esbuild", {})
if isinstance(esbuild, dict) and esbuild.get("loader") in ("tsx", "ts"):
has_ts_hints = True
# Check plugins for @vitejs/plugin-react or similar
plugins = data.get("plugins", [])
if isinstance(plugins, list):
for p in plugins:
if isinstance(p, dict) and "name" in p:
pname = str(p["name"]).lower()
if "react" in pname or "vue" in pname or "svelte" in pname:
has_ts_hints = True
if has_ts_hints and not extensions:
findings.append(Finding("R2", "info",
"TypeScript project detected but resolve.extensions not set",
"Consider adding resolve.extensions: ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'] "
"if you experience module resolution issues."))
# R3: resolve.dedupe with empty array
dedupe = resolve.get("dedupe")
if isinstance(dedupe, list) and len(dedupe) == 0:
findings.append(Finding("R3", "warning",
"resolve.dedupe is an empty array",
"An empty dedupe array has no effect. Either add packages to deduplicate "
"or remove the option entirely."))
return findings
def check_css(data: dict) -> list:
"""C1-C3: Check CSS configuration."""
findings = []
css = data.get("css")
if css is None:
return findings
if not isinstance(css, dict):
return findings
# C1: css.preprocessorOptions without corresponding preprocessor dependency hint
preproc = css.get("preprocessorOptions")
if isinstance(preproc, dict):
preprocessors_used = list(preproc.keys())
dep_map = {
"scss": "sass",
"sass": "sass",
"less": "less",
"styl": "stylus",
"stylus": "stylus",
}
for pp in preprocessors_used:
if pp in dep_map:
dep_name = dep_map[pp]
findings.append(Finding("C1", "info",
f"css.preprocessorOptions.{pp} configured — ensure '{dep_name}' is installed",
f"Vite requires '{dep_name}' as a peer dependency for {pp} preprocessing. "
f"Install with: npm install -D {dep_name}"))
# C2: css.modules with invalid options
modules = css.get("modules")
if isinstance(modules, dict):
unknown_opts = set(modules.keys()) - VALID_CSS_MODULES_OPTIONS
if unknown_opts:
findings.append(Finding("C2", "warning",
f"css.modules has unknown options: {', '.join(sorted(unknown_opts))}",
f"Valid css.modules options: {', '.join(sorted(VALID_CSS_MODULES_OPTIONS))}"))
# Check localsConvention value
convention = modules.get("localsConvention")
if convention is not None:
valid_conventions = {"camelCase", "camelCaseOnly", "dashes", "dashesOnly", None}
if convention not in valid_conventions:
findings.append(Finding("C2", "warning",
f"css.modules.localsConvention has invalid value: '{convention}'",
"Valid values: 'camelCase', 'camelCaseOnly', 'dashes', 'dashesOnly'."))
# C3: css.postcss pointing to non-existent file
postcss = css.get("postcss")
if isinstance(postcss, str):
# It's a path to a PostCSS config file
if not os.path.exists(postcss):
findings.append(Finding("C3", "warning",
f"css.postcss references non-existent file: '{postcss}'",
"Ensure the PostCSS config file exists at the specified path, "
"or use an inline PostCSS config object."))
return findings
def check_plugins(data: dict) -> list:
"""P1-P2: Check plugins configuration."""
findings = []
plugins = data.get("plugins")
if plugins is None:
return findings
if not isinstance(plugins, list):
return findings
# P1: Empty plugins array
if len(plugins) == 0:
findings.append(Finding("P1", "info",
"plugins array is empty",
"An empty plugins array has no effect. Add plugins or remove the key."))
return findings
# P2: Deprecated plugin names
for plugin in plugins:
plugin_name = None
if isinstance(plugin, str):
plugin_name = plugin
elif isinstance(plugin, dict):
plugin_name = plugin.get("name", "")
elif isinstance(plugin, list) and len(plugin) > 0:
# Array form: [pluginName, options]
plugin_name = str(plugin[0]) if plugin[0] else None
if plugin_name and isinstance(plugin_name, str):
for deprecated, replacement in DEPRECATED_VITE_PLUGINS.items():
if deprecated in plugin_name:
findings.append(Finding("P2", "warning",
f"Deprecated Vite plugin detected: '{deprecated}'",
f"Consider migrating to '{replacement}'."))
return findings
def check_best_practices(data: dict) -> list:
"""X1-X3: Check best practices."""
findings = []
# X1: No mode set (info)
if "mode" not in data:
findings.append(Finding("X1", "info",
"No mode set in config",
"Vite defaults to 'development' for serve and 'production' for build. "
"Set mode explicitly if you need environment-specific behavior in the config itself."))
# X2: Missing base for non-root deployments (info)
if "base" not in data:
findings.append(Finding("X2", "info",
"No base path set — defaults to '/'",
"Set base if deploying to a subdirectory (e.g., base: '/my-app/'). "
"Required for GitHub Pages, subpath deployments, etc."))
# X3: build.chunkSizeWarningLimit too high (>2000)
build = data.get("build", {})
if isinstance(build, dict):
chunk_limit = build.get("chunkSizeWarningLimit")
if isinstance(chunk_limit, (int, float)) and chunk_limit > 2000:
findings.append(Finding("X3", "warning",
f"build.chunkSizeWarningLimit is very high ({chunk_limit} kB)",
"A limit above 2000 kB effectively silences chunk size warnings. "
"Large chunks hurt load performance. Consider code splitting instead of "
"raising the limit. Default is 500 kB."))
return findings
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
def validate_all(data: dict) -> list:
"""Run all checks and return combined findings."""
findings = []
findings.extend(check_structure(data))
findings.extend(check_build(data))
findings.extend(check_server(data))
findings.extend(check_resolve(data))
findings.extend(check_css(data))
findings.extend(check_plugins(data))
findings.extend(check_best_practices(data))
return findings
# ---------------------------------------------------------------------------
# Rule explanations (for 'explain' command)
# ---------------------------------------------------------------------------
RULE_EXPLANATIONS = {
"S1": {
"name": "File Not Found",
"category": "Structure",
"severity": "error",
"description": "The config JSON file does not exist or cannot be read.",
"fix": "Ensure the file path is correct and the file has read permissions.",
},
"S2": {
"name": "Empty Config",
"category": "Structure",
"severity": "error",
"description": "The config file is empty (zero bytes or only whitespace).",
"fix": "Export your Vite config to JSON: node -e \"import('./vite.config.ts').then(m => console.log(JSON.stringify(m.default)))\" > vite.config.json",
},
"S3": {
"name": "Invalid JSON",
"category": "Structure",
"severity": "error",
"description": "The file contains invalid JSON syntax that cannot be parsed.",
"fix": "Fix the JSON syntax error reported in the message. Use a JSON linter or re-export from vite.config.ts.",
},
"S4": {
"name": "Unknown Top-Level Key",
"category": "Structure",
"severity": "warning",
"description": "A top-level key is not a recognized Vite config option. May indicate a typo or plugin-specific config placed at the wrong level.",
"fix": "Check the Vite docs for valid top-level keys. Plugin options usually go inside the plugins array, not at the top level.",
},
"S5": {
"name": "defineConfig Wrapper Hint",
"category": "Structure",
"severity": "info",
"description": "JSON export cannot verify that the config uses defineConfig(). This is just a reminder.",
"fix": "Wrap your config: import { defineConfig } from 'vite'; export default defineConfig({ ... })",
},
"B1": {
"name": "Missing outDir",
"category": "Build",
"severity": "info",
"description": "No build.outDir specified — Vite defaults to 'dist'.",
"fix": "Add build.outDir if you need a custom output directory.",
},
"B2": {
"name": "Invalid Build Target",
"category": "Build",
"severity": "error",
"description": "build.target contains an invalid value. Must be an ES version, browser version, or 'modules'/'esnext'.",
"fix": "Use valid targets: 'modules', 'esnext', 'es2020', 'chrome87', 'firefox78', 'safari13', 'node18', etc.",
},
"B3": {
"name": "Invalid Minify Value",
"category": "Build",
"severity": "error",
"description": "build.minify must be true, false, 'terser', or 'esbuild'.",
"fix": "Set build.minify to true, false, 'terser', or 'esbuild'. Default is 'esbuild'.",
},
"B4": {
"name": "Hidden Sourcemap in Development",
"category": "Build",
"severity": "warning",
"description": "Using 'hidden' sourcemaps in development mode makes debugging difficult.",
"fix": "Use build.sourcemap: true or 'inline' for development. Reserve 'hidden' for production.",
},
"B5": {
"name": "Deprecated Rollup Plugin",
"category": "Build",
"severity": "warning",
"description": "Using a deprecated rollup-plugin-* package instead of the scoped @rollup/plugin-* replacement.",
"fix": "Migrate from rollup-plugin-X to @rollup/plugin-X. The old namespace is unmaintained.",
},
"V1": {
"name": "Invalid Server Port",
"category": "Server",
"severity": "error",
"description": "server.port is outside the valid range (1-65535) or is a privileged port (<1024).",
"fix": "Use a port between 1024 and 65535. Common choices: 3000, 5173, 8080.",
},
"V2": {
"name": "Exposed Dev Server",
"category": "Server",
"severity": "warning",
"description": "server.host is true or '0.0.0.0', exposing the dev server to the entire network.",
"fix": "Use 'localhost' or '127.0.0.1' for local-only access. Only expose if you need LAN/mobile testing.",
},
"V3": {
"name": "Invalid Proxy Target",
"category": "Server",
"severity": "warning",
"description": "A proxy target URL does not look like a valid HTTP(S) URL.",
"fix": "Proxy targets should start with http:// or https:// (e.g., 'http://localhost:3001').",
},
"V4": {
"name": "HTTPS Without Certificates",
"category": "Server",
"severity": "warning",
"description": "server.https is enabled but missing key/cert file paths.",
"fix": "Provide key and cert paths, or use @vitejs/plugin-basic-ssl for auto-generated dev certs.",
},
"R1": {
"name": "Absolute Path in Alias",
"category": "Resolve",
"severity": "warning",
"description": "resolve.alias uses an absolute filesystem path, which breaks portability across machines and CI.",
"fix": "Use path.resolve(__dirname, './src') or fileURLToPath(new URL('./src', import.meta.url)).",
},
"R2": {
"name": "Missing Extensions for TypeScript",
"category": "Resolve",
"severity": "info",
"description": "TypeScript project detected but resolve.extensions not explicitly set.",
"fix": "Add resolve.extensions: ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'] if needed.",
},
"R3": {
"name": "Empty Dedupe Array",
"category": "Resolve",
"severity": "warning",
"description": "resolve.dedupe is set to an empty array, which has no effect.",
"fix": "Add packages to deduplicate (e.g., ['react', 'react-dom']) or remove the option.",
},
"C1": {
"name": "Preprocessor Dependency Hint",
"category": "CSS",
"severity": "info",
"description": "CSS preprocessor options are configured but the required preprocessor package may not be installed.",
"fix": "Install the preprocessor as a dev dependency: npm install -D sass/less/stylus.",
},
"C2": {
"name": "Invalid CSS Modules Option",
"category": "CSS",
"severity": "warning",
"description": "css.modules contains an unknown or invalid option.",
"fix": "Valid css.modules options: scopeBehaviour, globalModulePaths, generateScopedName, hashPrefix, localsConvention.",
},
"C3": {
"name": "Missing PostCSS Config",
"category": "CSS",
"severity": "warning",
"description": "css.postcss references a file path that does not exist.",
"fix": "Create the PostCSS config file or use an inline config object.",
},
"P1": {
"name": "Empty Plugins Array",
"category": "Plugins",
"severity": "info",
"description": "The plugins array is empty. It has no effect.",
"fix": "Add plugins or remove the empty array.",
},
"P2": {
"name": "Deprecated Vite Plugin",
"category": "Plugins",
"severity": "warning",
"description": "A known deprecated Vite plugin is in use. It may not work with newer Vite versions.",
"fix": "Migrate to the suggested replacement plugin.",
},
"X1": {
"name": "No Mode Set",
"category": "Best Practices",
"severity": "info",
"description": "No mode is set in the config. Vite defaults to 'development' for serve and 'production' for build.",
"fix": "Set mode explicitly if you need environment-specific behavior in the config.",
},
"X2": {
"name": "Missing Base Path",
"category": "Best Practices",
"severity": "info",
"description": "No base path set. Defaults to '/'. Required for subdirectory deployments.",
"fix": "Set base: '/my-app/' if deploying to a subdirectory (GitHub Pages, etc.).",
},
"X3": {
"name": "Chunk Size Limit Too High",
"category": "Best Practices",
"severity": "warning",
"description": "build.chunkSizeWarningLimit is set above 2000 kB, effectively silencing chunk warnings.",
"fix": "Use code splitting (dynamic imports) instead of raising the limit. Default is 500 kB.",
},
}
# ---------------------------------------------------------------------------
# Suggestion engine (for 'suggest' command)
# ---------------------------------------------------------------------------
def generate_suggestions(data: dict, findings: list) -> list:
"""Generate actionable fix suggestions from findings."""
suggestions = []
for f in findings:
rule = RULE_EXPLANATIONS.get(f.rule_id)
if not rule:
continue
suggestion = {
"rule_id": f.rule_id,
"severity": f.severity,
"problem": f.message,
"fix": rule["fix"],
}
# Add concrete config snippets for common fixes
if f.rule_id == "B1":
suggestion["snippet"] = '"build": { "outDir": "dist" }'
elif f.rule_id == "B3":
suggestion["snippet"] = '"build": { "minify": "esbuild" }'
elif f.rule_id == "X1":
suggestion["snippet"] = '"mode": "development"'
elif f.rule_id == "X2":
suggestion["snippet"] = '"base": "/my-app/"'
elif f.rule_id == "V2":
suggestion["snippet"] = '"server": { "host": "localhost" }'
elif f.rule_id == "R3":
suggestion["snippet"] = '"resolve": { "dedupe": ["react", "react-dom"] }'
elif f.rule_id == "P1":
suggestion["snippet"] = '// Remove empty plugins array or add plugins'
elif f.rule_id == "S5":
suggestion["snippet"] = "import { defineConfig } from 'vite'\nexport default defineConfig({ ... })"
elif f.rule_id == "V4":
suggestion["snippet"] = ('"server": { "https": { '
'"key": "./certs/key.pem", "cert": "./certs/cert.pem" } }')
elif f.rule_id == "X3":
suggestion["snippet"] = '"build": { "chunkSizeWarningLimit": 500 }'
elif f.rule_id == "B4":
suggestion["snippet"] = '"build": { "sourcemap": true }'
suggestions.append(suggestion)
return suggestions
# ---------------------------------------------------------------------------
# Summary helper
# ---------------------------------------------------------------------------
def _summary_counts(findings: list) -> dict:
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
infos = sum(1 for f in findings if f.severity == "info")
return {"errors": errors, "warnings": warnings, "infos": infos, "total": len(findings)}
def _summary_text(findings: list) -> str:
c = _summary_counts(findings)
parts = []
if c["errors"]:
parts.append(f"{c['errors']} error(s)")
if c["warnings"]:
parts.append(f"{c['warnings']} warning(s)")
if c["infos"]:
parts.append(f"{c['infos']} info")
return ", ".join(parts) if parts else "No issues found"
# ---------------------------------------------------------------------------
# Command handlers
# ---------------------------------------------------------------------------
def cmd_validate(data: dict, path: str) -> dict:
"""Full validation with summary."""
findings = validate_all(data)
errors = [f for f in findings if f.severity == "error"]
return {
"command": "validate",
"file": path,
"valid": len(errors) == 0,
"findings": [f.to_dict() for f in findings],
"counts": _summary_counts(findings),
"summary": _summary_text(findings),
}
def cmd_check(data: dict, path: str) -> dict:
"""Quick check — errors and warnings only."""
findings = validate_all(data)
filtered = [f for f in findings if f.severity in ("error", "warning")]
return {
"command": "check",
"file": path,
"passed": all(f.severity != "error" for f in findings),
"findings": [f.to_dict() for f in filtered],
"counts": _summary_counts(filtered),
"summary": _summary_text(filtered),
}
def cmd_explain(data, path: str) -> dict:
"""Explain all rules with their categories and severity."""
rules = []
for rule_id in sorted(RULE_EXPLANATIONS.keys()):
info = RULE_EXPLANATIONS[rule_id]
rules.append({
"rule_id": rule_id,
"name": info["name"],
"category": info["category"],
"severity": info["severity"],
"description": info["description"],
"fix": info["fix"],
})
return {
"command": "explain",
"file": path,
"rules": rules,
"total_rules": len(rules),
}
def cmd_suggest(data: dict, path: str) -> dict:
"""Run validation and generate fix suggestions."""
findings = validate_all(data)
suggestions = generate_suggestions(data, findings)
return {
"command": "suggest",
"file": path,
"suggestions": suggestions,
"total": len(suggestions),
"summary": _summary_text(findings),
}
# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------
def format_text(result: dict) -> str:
cmd = result.get("command", "")
path = result.get("file", "")
lines = []
title = f"vite.config {cmd} — {path}"
lines.append(title)
lines.append("=" * len(title))
if cmd == "explain":
for rule in result.get("rules", []):
lines.append("")
lines.append(f" {rule['rule_id']}: {rule['name']} [{rule['category']}] ({rule['severity']})")
lines.append(f" {rule['description']}")
lines.append(f" Fix: {rule['fix']}")
lines.append("")
lines.append(f"Total rules: {result.get('total_rules', 0)}")
return "\n".join(lines)
if cmd == "suggest":
suggestions = result.get("suggestions", [])
if not suggestions:
lines.append("[OK] No suggestions — Vite config looks good")
else:
for s in suggestions:
sev = s["severity"].upper().ljust(7)
lines.append(f"[{sev}] {s['rule_id']}: {s['problem']}")
lines.append(f" Fix: {s['fix']}")
if "snippet" in s:
lines.append(f" Add: {s['snippet']}")
lines.append("")
lines.append(f"Summary: {result.get('summary', '')}")
return "\n".join(lines)
# validate / check
findings = result.get("findings", [])
if not findings:
lines.append("[OK] No issues found")
else:
for f in findings:
sev = f["severity"].upper().ljust(7)
lines.append(f"[{sev}] {f['rule_id']}: {f['message']}")
if f.get("detail"):
lines.append(f" {f['detail']}")
if cmd == "validate":
valid_str = "VALID" if result.get("valid") else "INVALID"
lines.append("")
lines.append(f"Result: {valid_str}")
if cmd == "check":
passed_str = "PASSED" if result.get("passed") else "FAILED"
lines.append("")
lines.append(f"Result: {passed_str}")
summary = result.get("summary")
if summary:
lines.append(f"Summary: {summary}")
return "\n".join(lines)
def format_json(result: dict) -> str:
return json.dumps(result, indent=2)
def format_summary(result: dict) -> str:
cmd = result.get("command", "")
path = result.get("file", "")
lines = []
lines.append(f"vite.config {cmd}: {path}")
if cmd == "explain":
lines.append(f"Rules: {result.get('total_rules', 0)}")
categories = {}
for rule in result.get("rules", []):
cat = rule["category"]
categories[cat] = categories.get(cat, 0) + 1
for cat, count in sorted(categories.items()):
lines.append(f" {cat}: {count} rules")
return "\n".join(lines)
counts = result.get("counts", {})
lines.append(f"Errors: {counts.get('errors', 0)}")
lines.append(f"Warnings: {counts.get('warnings', 0)}")
lines.append(f"Info: {counts.get('infos', 0)}")
if "valid" in result:
lines.append(f"Valid: {'yes' if result['valid'] else 'no'}")
if "passed" in result:
lines.append(f"Passed: {'yes' if result['passed'] else 'no'}")
if cmd == "suggest":
lines.append(f"Suggestions: {result.get('total', 0)}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Validate Vite configuration files (JSON-exported)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Commands:
validate Full validation with all rules
check Quick check (errors and warnings only)
explain Show all rules with descriptions
suggest Run validation and propose fixes
Export your vite.config.ts to JSON first:
node -e "import('./vite.config.ts').then(m => console.log(JSON.stringify(m.default)))" > vite.config.json
Examples:
python3 vite_config_validator.py validate vite.config.json
python3 vite_config_validator.py validate vite.config.json --strict
python3 vite_config_validator.py check vite.config.json --format json
python3 vite_config_validator.py explain vite.config.json
python3 vite_config_validator.py suggest vite.config.json --format summary
"""
)
parser.add_argument("command", choices=["validate", "check", "explain", "suggest"],
help="Command to run")
parser.add_argument("file", help="Path to Vite config JSON file")
parser.add_argument("--strict", action="store_true",
help="Treat warnings as errors (CI mode)")
parser.add_argument("--format", choices=["text", "json", "summary"], default="text",
help="Output format (default: text)")
args = parser.parse_args()
# For 'explain', we don't need a valid file (but accept the arg for consistency)
if args.command == "explain":
result = cmd_explain(None, args.file)
else:
# Load and parse file
data, parse_error = load_config(args.file)
if parse_error:
result = {
"command": args.command,
"file": args.file,
"findings": [parse_error.to_dict()],
"counts": {"errors": 1, "warnings": 0, "infos": 0, "total": 1},
"summary": "1 error(s)",
}
if args.command == "validate":
result["valid"] = False
elif args.command == "check":
result["passed"] = False
elif args.command == "suggest":
result["suggestions"] = []
result["total"] = 0
formatter = {"text": format_text, "json": format_json, "summary": format_summary}
print(formatter[args.format](result))
sys.exit(2)
# Run command
if args.command == "validate":
result = cmd_validate(data, args.file)
elif args.command == "check":
result = cmd_check(data, args.file)
elif args.command == "suggest":
result = cmd_suggest(data, args.file)
# Format output
formatter = {"text": format_text, "json": format_json, "summary": format_summary}
print(formatter[args.format](result))
# Exit code
if args.command == "explain":
sys.exit(0)
findings = result.get("findings", [])
has_errors = any(f["severity"] == "error" for f in findings)
has_warnings = any(f["severity"] == "warning" for f in findings)
if has_errors:
sys.exit(1)
if args.strict and has_warnings:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
Validate JSON-exported webpack configuration files for structural issues, deprecated loaders/plugins, optimization gaps, and best practices. Use when auditin...
---
name: webpack-config-validator
description: Validate JSON-exported webpack configuration files for structural issues, deprecated loaders/plugins, optimization gaps, and best practices. Use when auditing webpack configs, preparing production builds, or enforcing CI standards.
---
# Webpack Config Validator
Validate webpack configuration files exported as JSON for structural correctness, entry/output issues, deprecated loaders and plugins, optimization configuration, and best practices. Pure Python 3 stdlib — no external dependencies.
**Note:** webpack configs are JS/TS, not directly parseable. This validator works with JSON-exported configs. Export via:
```bash
node -e "console.log(JSON.stringify(require('./webpack.config.js')))" > config.json
python3 scripts/webpack_config_validator.py validate config.json
```
## Commands
### validate — Full validation with all rules
```bash
python3 scripts/webpack_config_validator.py validate config.json
python3 scripts/webpack_config_validator.py validate config.json --strict
python3 scripts/webpack_config_validator.py validate config.json --format json --mode production
```
### check — Quick check (errors and warnings only)
```bash
python3 scripts/webpack_config_validator.py check config.json
python3 scripts/webpack_config_validator.py check config.json --format summary
```
### explain — Show all rules with descriptions
```bash
python3 scripts/webpack_config_validator.py explain config.json
python3 scripts/webpack_config_validator.py explain config.json --format json
```
### suggest — Run validation and propose fixes
```bash
python3 scripts/webpack_config_validator.py suggest config.json
python3 scripts/webpack_config_validator.py suggest config.json --format json
```
## Flags
| Flag | Description |
|------|-------------|
| `--strict` | Treat warnings as errors — exit code 1 (CI-friendly) |
| `--format text` | Human-readable output (default) |
| `--format json` | Machine-readable JSON |
| `--format summary` | Compact summary with counts |
| `--mode production` | Override mode context for mode-specific rules (E3, O3) |
| `--mode development` | Override mode context for mode-specific rules |
## Validation Rules (24)
### Structure (5)
| Rule | Severity | Description |
|------|----------|-------------|
| S1 | error | File not found or unreadable |
| S2 | error | Empty config file |
| S3 | error | Invalid JSON syntax |
| S4 | error | Missing required fields (entry, output) |
| S5 | warning/info | Unknown or deprecated top-level keys |
### Entry/Output (4)
| Rule | Severity | Description |
|------|----------|-------------|
| E1 | error | Empty entry point (empty string, object, or array) |
| E2 | error | Output section missing 'path' property |
| E3 | warning | Output filename without content hash in production mode |
| E4 | warning | publicPath not set |
### Module/Rules (4)
| Rule | Severity | Description |
|------|----------|-------------|
| M1 | warning | Module rule without 'test' pattern |
| M2 | warning | Duplicate loader for same test pattern |
| M3 | warning | Deprecated loaders (raw-loader, url-loader, file-loader, json-loader) |
| M4 | info | No babel-loader/ts-loader/esbuild-loader/swc-loader for JS/TS |
### Plugins (4)
| Rule | Severity | Description |
|------|----------|-------------|
| P1 | error | Deprecated plugins (UglifyJsPlugin, ExtractTextPlugin, CommonsChunkPlugin) |
| P2 | warning | Duplicate plugin instances |
| P3 | info | HtmlWebpackPlugin without explicit template |
| P4 | warning | MiniCssExtractPlugin without corresponding loader in rules |
### Optimization (3)
| Rule | Severity | Description |
|------|----------|-------------|
| O1 | info | Missing splitChunks configuration |
| O2 | info | Missing custom minimizer configuration |
| O3 | warning/info | devtool set to eval or source-map in production mode |
### Best Practices (4)
| Rule | Severity | Description |
|------|----------|-------------|
| B1 | info | Missing resolve.extensions |
| B2 | warning | Hardcoded absolute filesystem paths in config |
| B3 | warning | No mode set (development/production/none) |
| B4 | info | Missing devServer configuration |
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | No errors (warnings allowed unless `--strict`) |
| 1 | Errors found (or warnings in `--strict` mode) |
| 2 | File not found / parse error |
## CI Integration
```yaml
# GitHub Actions example
- name: Validate webpack config
run: |
node -e "console.log(JSON.stringify(require('./webpack.config.js')))" > /tmp/wp-config.json
python3 scripts/webpack_config_validator.py validate /tmp/wp-config.json --strict --mode production --format json
```
## Example Output
```
webpack.config validate — config.json
======================================
[ERROR ] S4: Missing required top-level field(s): output
Every webpack config needs at least 'entry' and 'output'.
[WARNING] E4: output.publicPath not set
Set publicPath to ensure assets are loaded from the correct URL. Common values: '/', '/assets/', 'auto'.
[WARNING] M3: Deprecated loader 'file-loader' in rule 2
Replace with asset/resource (webpack 5 built-in).
[ERROR ] P1: Deprecated plugin 'UglifyJsPlugin' at index 0
Replace with TerserPlugin (terser-webpack-plugin).
[INFO ] O1: No optimization.splitChunks configuration
splitChunks enables automatic code splitting for shared dependencies. Add optimization: { splitChunks: { chunks: 'all' } } for better caching.
[WARNING] B3: No 'mode' set (development/production/none)
Set mode to enable webpack's built-in optimizations. Without mode, webpack defaults to 'production' with a warning.
Result: INVALID
Summary: 2 error(s), 3 warning(s), 1 info
```
FILE:scripts/SKILL.md
---
name: webpack-config-validator
description: Validate JSON-exported webpack configuration files for structural issues, deprecated loaders/plugins, optimization gaps, and best practices. Use when auditing webpack configs, preparing production builds, or enforcing CI standards.
---
# Webpack Config Validator
Validate webpack configuration files exported as JSON for structural correctness, entry/output issues, deprecated loaders and plugins, optimization configuration, and best practices. Pure Python 3 stdlib — no external dependencies.
**Note:** webpack configs are JS/TS, not directly parseable. This validator works with JSON-exported configs. Export via:
```bash
node -e "console.log(JSON.stringify(require('./webpack.config.js')))" > config.json
python3 scripts/webpack_config_validator.py validate config.json
```
## Commands
### validate — Full validation with all rules
```bash
python3 scripts/webpack_config_validator.py validate config.json
python3 scripts/webpack_config_validator.py validate config.json --strict
python3 scripts/webpack_config_validator.py validate config.json --format json --mode production
```
### check — Quick check (errors and warnings only)
```bash
python3 scripts/webpack_config_validator.py check config.json
python3 scripts/webpack_config_validator.py check config.json --format summary
```
### explain — Show all rules with descriptions
```bash
python3 scripts/webpack_config_validator.py explain config.json
python3 scripts/webpack_config_validator.py explain config.json --format json
```
### suggest — Run validation and propose fixes
```bash
python3 scripts/webpack_config_validator.py suggest config.json
python3 scripts/webpack_config_validator.py suggest config.json --format json
```
## Flags
| Flag | Description |
|------|-------------|
| `--strict` | Treat warnings as errors — exit code 1 (CI-friendly) |
| `--format text` | Human-readable output (default) |
| `--format json` | Machine-readable JSON |
| `--format summary` | Compact summary with counts |
| `--mode production` | Override mode context for mode-specific rules (E3, O3) |
| `--mode development` | Override mode context for mode-specific rules |
## Validation Rules (24)
### Structure (5)
| Rule | Severity | Description |
|------|----------|-------------|
| S1 | error | File not found or unreadable |
| S2 | error | Empty config file |
| S3 | error | Invalid JSON syntax |
| S4 | error | Missing required fields (entry, output) |
| S5 | warning/info | Unknown or deprecated top-level keys |
### Entry/Output (4)
| Rule | Severity | Description |
|------|----------|-------------|
| E1 | error | Empty entry point (empty string, object, or array) |
| E2 | error | Output section missing 'path' property |
| E3 | warning | Output filename without content hash in production mode |
| E4 | warning | publicPath not set |
### Module/Rules (4)
| Rule | Severity | Description |
|------|----------|-------------|
| M1 | warning | Module rule without 'test' pattern |
| M2 | warning | Duplicate loader for same test pattern |
| M3 | warning | Deprecated loaders (raw-loader, url-loader, file-loader, json-loader) |
| M4 | info | No babel-loader/ts-loader/esbuild-loader/swc-loader for JS/TS |
### Plugins (4)
| Rule | Severity | Description |
|------|----------|-------------|
| P1 | error | Deprecated plugins (UglifyJsPlugin, ExtractTextPlugin, CommonsChunkPlugin) |
| P2 | warning | Duplicate plugin instances |
| P3 | info | HtmlWebpackPlugin without explicit template |
| P4 | warning | MiniCssExtractPlugin without corresponding loader in rules |
### Optimization (3)
| Rule | Severity | Description |
|------|----------|-------------|
| O1 | info | Missing splitChunks configuration |
| O2 | info | Missing custom minimizer configuration |
| O3 | warning/info | devtool set to eval or source-map in production mode |
### Best Practices (4)
| Rule | Severity | Description |
|------|----------|-------------|
| B1 | info | Missing resolve.extensions |
| B2 | warning | Hardcoded absolute filesystem paths in config |
| B3 | warning | No mode set (development/production/none) |
| B4 | info | Missing devServer configuration |
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | No errors (warnings allowed unless `--strict`) |
| 1 | Errors found (or warnings in `--strict` mode) |
| 2 | File not found / parse error |
## CI Integration
```yaml
# GitHub Actions example
- name: Validate webpack config
run: |
node -e "console.log(JSON.stringify(require('./webpack.config.js')))" > /tmp/wp-config.json
python3 scripts/webpack_config_validator.py validate /tmp/wp-config.json --strict --mode production --format json
```
## Example Output
```
webpack.config validate — config.json
======================================
[ERROR ] S4: Missing required top-level field(s): output
Every webpack config needs at least 'entry' and 'output'.
[WARNING] E4: output.publicPath not set
Set publicPath to ensure assets are loaded from the correct URL. Common values: '/', '/assets/', 'auto'.
[WARNING] M3: Deprecated loader 'file-loader' in rule 2
Replace with asset/resource (webpack 5 built-in).
[ERROR ] P1: Deprecated plugin 'UglifyJsPlugin' at index 0
Replace with TerserPlugin (terser-webpack-plugin).
[INFO ] O1: No optimization.splitChunks configuration
splitChunks enables automatic code splitting for shared dependencies. Add optimization: { splitChunks: { chunks: 'all' } } for better caching.
[WARNING] B3: No 'mode' set (development/production/none)
Set mode to enable webpack's built-in optimizations. Without mode, webpack defaults to 'production' with a warning.
Result: INVALID
Summary: 2 error(s), 3 warning(s), 1 info
```
FILE:scripts/webpack_config_validator.py
#!/usr/bin/env python3
"""
Webpack Config Validator
Validate JSON-exported webpack configuration files for structural correctness,
entry/output issues, module/loader problems, plugin hygiene, optimization hints,
and best practices.
Usage: python3 webpack_config_validator.py <command> <file> [--strict] [--format text|json|summary] [--mode production|development]
Commands: validate, check, explain, suggest
Note: webpack configs are JS/TS. This validator works with JSON-exported configs.
Export via: node -e "console.log(JSON.stringify(require('./webpack.config.js')))"
"""
import sys
import os
import re
import json
import argparse
from typing import Any
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
KNOWN_TOP_LEVEL_KEYS = {
"entry", "output", "module", "plugins", "resolve", "optimization",
"devServer", "devtool", "mode", "target", "externals", "context",
"node", "performance", "stats", "watch", "watchOptions",
"experiments", "infrastructureLogging", "cache", "snapshot",
"name", "dependencies", "loader", "parallelism", "profile",
"recordsPath", "recordsInputPath", "recordsOutputPath",
"amd", "bail", "ignoreWarnings",
}
DEPRECATED_TOP_LEVEL_KEYS = {
"loaders": "Use 'module.rules' instead (deprecated since webpack 2).",
}
DEPRECATED_LOADERS: dict[str, str] = {
"raw-loader": "asset/source (webpack 5 built-in)",
"url-loader": "asset (webpack 5 built-in)",
"file-loader": "asset/resource (webpack 5 built-in)",
"json-loader": "built-in since webpack 2 (remove entirely)",
}
DEPRECATED_PLUGINS: dict[str, str] = {
"UglifyJsPlugin": "TerserPlugin (terser-webpack-plugin)",
"UglifyJSPlugin": "TerserPlugin (terser-webpack-plugin)",
"ExtractTextPlugin": "MiniCssExtractPlugin (mini-css-extract-plugin)",
"CommonsChunkPlugin": "optimization.splitChunks (webpack 4+ built-in)",
}
# ---------------------------------------------------------------------------
# Finding class
# ---------------------------------------------------------------------------
class Finding:
"""A single validation finding."""
SEVERITIES = ("error", "warning", "info")
def __init__(self, rule_id: str, severity: str, message: str, detail: str = ""):
assert severity in self.SEVERITIES, f"Invalid severity: {severity}"
self.rule_id = rule_id
self.severity = severity
self.message = message
self.detail = detail
def to_dict(self) -> dict:
d = {
"rule_id": self.rule_id,
"severity": self.severity,
"message": self.message,
}
if self.detail:
d["detail"] = self.detail
return d
def __repr__(self):
return f"Finding({self.rule_id}, {self.severity}, {self.message!r})"
# ---------------------------------------------------------------------------
# JSON loading
# ---------------------------------------------------------------------------
def load_config(path: str) -> tuple[dict | None, Finding | None]:
"""Load and parse a JSON webpack config file. Returns (data, error_finding)."""
# S1: File not found or unreadable
if not os.path.exists(path):
return None, Finding("S1", "error", f"File not found: {path}")
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
except OSError as e:
return None, Finding("S1", "error", f"Cannot read file: {e}")
# S2: Empty config
if len(content.strip()) == 0:
return None, Finding("S2", "error", "Config file is empty")
# S3: Invalid JSON syntax
try:
data = json.loads(content)
except json.JSONDecodeError as e:
return None, Finding("S3", "error", f"Invalid JSON syntax: {e}")
if not isinstance(data, dict):
return None, Finding("S3", "error",
f"Expected a JSON object at top level, got {type(data).__name__}")
return data, None
# ---------------------------------------------------------------------------
# Individual check functions
# ---------------------------------------------------------------------------
def check_structure(data: dict) -> list[Finding]:
"""S4, S5: Check required fields and unknown/deprecated top-level keys."""
findings: list[Finding] = []
# S4: Missing required fields (entry, output)
missing = []
if "entry" not in data:
missing.append("entry")
if "output" not in data:
missing.append("output")
if missing:
findings.append(Finding("S4", "error",
f"Missing required top-level field(s): {', '.join(missing)}",
"Every webpack config needs at least 'entry' and 'output'."))
# S5: Unknown/deprecated top-level keys
for key in data:
if key in DEPRECATED_TOP_LEVEL_KEYS:
findings.append(Finding("S5", "warning",
f"Deprecated top-level key '{key}'",
DEPRECATED_TOP_LEVEL_KEYS[key]))
elif key not in KNOWN_TOP_LEVEL_KEYS:
findings.append(Finding("S5", "info",
f"Unknown top-level key '{key}' — may be a typo or custom property",
"Check webpack documentation for valid configuration keys."))
return findings
def check_entry_output(data: dict, mode: str | None) -> list[Finding]:
"""E1-E4: Check entry and output configuration."""
findings: list[Finding] = []
# E1: Missing entry point
entry = data.get("entry")
if entry is not None:
if isinstance(entry, str) and entry.strip() == "":
findings.append(Finding("E1", "error",
"Entry point is an empty string",
"Specify a valid entry file, e.g. './src/index.js'."))
elif isinstance(entry, dict) and len(entry) == 0:
findings.append(Finding("E1", "error",
"Entry is an empty object — no entry points defined",
"Add at least one entry point, e.g. { \"main\": \"./src/index.js\" }."))
elif isinstance(entry, list) and len(entry) == 0:
findings.append(Finding("E1", "error",
"Entry is an empty array — no entry points defined",
"Add at least one entry file to the array."))
# E2: Output without path
output = data.get("output")
if isinstance(output, dict):
if "path" not in output:
findings.append(Finding("E2", "error",
"Output section missing 'path' property",
"Add output.path — e.g. path.resolve(__dirname, 'dist'). "
"In JSON export this appears as an absolute path string."))
# E3: Output filename without hash for production
filename = output.get("filename", "")
effective_mode = mode or data.get("mode")
if isinstance(filename, str) and effective_mode == "production":
has_hash = any(h in filename for h in [
"[hash]", "[contenthash]", "[chunkhash]", "[fullhash]"
])
if not has_hash and filename:
findings.append(Finding("E3", "warning",
f"Output filename '{filename}' has no content hash for production",
"Use [contenthash] in filename for long-term caching, "
"e.g. '[name].[contenthash].js'."))
# E4: publicPath not set
if "publicPath" not in output:
findings.append(Finding("E4", "warning",
"output.publicPath not set",
"Set publicPath to ensure assets are loaded from the correct URL. "
"Common values: '/', '/assets/', 'auto'."))
return findings
def _extract_loaders(rule: dict) -> list[str]:
"""Extract loader names from a module rule."""
loaders: list[str] = []
# use / loader (single)
for key in ("use", "loader"):
val = rule.get(key)
if isinstance(val, str):
loaders.append(val)
elif isinstance(val, list):
for item in val:
if isinstance(item, str):
loaders.append(item)
elif isinstance(item, dict):
loader_name = item.get("loader", "")
if isinstance(loader_name, str) and loader_name:
loaders.append(loader_name)
elif isinstance(val, dict):
loader_name = val.get("loader", "")
if isinstance(loader_name, str) and loader_name:
loaders.append(loader_name)
return loaders
def _extract_test_pattern(rule: dict) -> str | None:
"""Extract the test regex pattern from a rule (as string)."""
test_val = rule.get("test")
if test_val is None:
return None
if isinstance(test_val, str):
return test_val
if isinstance(test_val, dict):
# JSON-serialized RegExp sometimes appears as { "source": "...", "flags": "..." }
return test_val.get("source", str(test_val))
return str(test_val)
def check_module_rules(data: dict) -> list[Finding]:
"""M1-M4: Check module.rules configuration."""
findings: list[Finding] = []
module = data.get("module")
if not isinstance(module, dict):
return findings
rules = module.get("rules", [])
if not isinstance(rules, list):
return findings
seen_tests: dict[str, list[str]] = {} # test_pattern -> [loaders]
all_loaders: list[str] = []
has_js_ts_loader = False
for i, rule in enumerate(rules):
if not isinstance(rule, dict):
continue
# M1: Rules without test pattern
test_pattern = _extract_test_pattern(rule)
if test_pattern is None:
# oneOf / rules nesting is valid without test
if "oneOf" not in rule and "rules" not in rule:
findings.append(Finding("M1", "warning",
f"Rule at index {i} has no 'test' pattern",
"Every rule should have a 'test' to match file types, "
"e.g. test: /\\.js$/."))
# Extract loaders for further checks
loaders = _extract_loaders(rule)
all_loaders.extend(loaders)
# Track test -> loaders for M2
if test_pattern is not None:
key = test_pattern
if key not in seen_tests:
seen_tests[key] = []
seen_tests[key].extend(loaders)
# M3: Deprecated loaders
for loader in loaders:
# Normalize loader name (strip -loader suffix variations, query params)
loader_base = loader.split("?")[0].split("!")[0].strip()
if loader_base in DEPRECATED_LOADERS:
replacement = DEPRECATED_LOADERS[loader_base]
findings.append(Finding("M3", "warning",
f"Deprecated loader '{loader_base}' in rule {i}",
f"Replace with {replacement}."))
# Check if this rule handles JS/TS
if test_pattern is not None:
test_str = str(test_pattern).lower()
if any(ext in test_str for ext in [".js", ".jsx", ".ts", ".tsx", "js", "ts"]):
for loader in loaders:
loader_base = loader.split("?")[0].split("!")[0].strip()
if loader_base in ("babel-loader", "ts-loader", "esbuild-loader",
"swc-loader", "@babel/register"):
has_js_ts_loader = True
# Check nested oneOf rules
one_of = rule.get("oneOf", [])
if isinstance(one_of, list):
for j, sub_rule in enumerate(one_of):
if isinstance(sub_rule, dict):
sub_loaders = _extract_loaders(sub_rule)
all_loaders.extend(sub_loaders)
for loader in sub_loaders:
loader_base = loader.split("?")[0].split("!")[0].strip()
if loader_base in DEPRECATED_LOADERS:
replacement = DEPRECATED_LOADERS[loader_base]
findings.append(Finding("M3", "warning",
f"Deprecated loader '{loader_base}' in rule {i} oneOf[{j}]",
f"Replace with {replacement}."))
if loader_base in ("babel-loader", "ts-loader", "esbuild-loader",
"swc-loader", "@babel/register"):
has_js_ts_loader = True
# M2: Duplicate loader for same test
for test_pattern, loaders in seen_tests.items():
loader_counts: dict[str, int] = {}
for loader in loaders:
loader_base = loader.split("?")[0].split("!")[0].strip()
loader_counts[loader_base] = loader_counts.get(loader_base, 0) + 1
for loader_name, count in loader_counts.items():
if count > 1 and loader_name:
findings.append(Finding("M2", "warning",
f"Loader '{loader_name}' appears {count} times for test '{test_pattern}'",
"Duplicate loaders cause double processing. Remove the duplicate."))
# M4: Missing babel-loader or ts-loader for JS/TS files
if rules and not has_js_ts_loader:
findings.append(Finding("M4", "info",
"No babel-loader, ts-loader, esbuild-loader, or swc-loader found for JS/TS files",
"If your project uses modern JS/TS, add a transpilation loader. "
"This may be intentional if using only vanilla JS."))
return findings
def _extract_plugin_name(plugin: Any) -> str | None:
"""Extract plugin constructor name from JSON-serialized plugin."""
if isinstance(plugin, dict):
# Common JSON serialization patterns:
# { "constructor": "HtmlWebpackPlugin", ... }
# { "_pluginName": "HtmlWebpackPlugin", ... }
# { "pluginName": "HtmlWebpackPlugin", ... }
for key in ("constructor", "_pluginName", "pluginName", "__pluginName",
"name", "_name"):
val = plugin.get(key)
if isinstance(val, str) and val:
return val
# Fallback: check if there's a key matching *Plugin pattern
for key in plugin:
if isinstance(key, str) and key.endswith("Plugin"):
return key
if isinstance(plugin, str):
return plugin
return None
def check_plugins(data: dict) -> list[Finding]:
"""P1-P4: Check plugin configuration."""
findings: list[Finding] = []
plugins = data.get("plugins")
if not isinstance(plugins, list):
return findings
seen_plugins: dict[str, int] = {}
has_mini_css_plugin = False
has_html_plugin = False
html_plugin_has_template = False
for i, plugin in enumerate(plugins):
name = _extract_plugin_name(plugin)
if name:
# P1: Deprecated plugins
if name in DEPRECATED_PLUGINS:
replacement = DEPRECATED_PLUGINS[name]
findings.append(Finding("P1", "error",
f"Deprecated plugin '{name}' at index {i}",
f"Replace with {replacement}."))
# Track for P2 duplicate check
seen_plugins[name] = seen_plugins.get(name, 0) + 1
# P3: HtmlWebpackPlugin without template
if "HtmlWebpackPlugin" in name or "html-webpack-plugin" in name.lower():
has_html_plugin = True
if isinstance(plugin, dict):
options = plugin.get("options", plugin.get("userOptions", plugin))
if isinstance(options, dict) and "template" in options:
html_plugin_has_template = True
# Track MiniCssExtractPlugin
if "MiniCssExtractPlugin" in name or "mini-css-extract-plugin" in name.lower():
has_mini_css_plugin = True
# P2: Duplicate plugin instances
for name, count in seen_plugins.items():
if count > 1:
findings.append(Finding("P2", "warning",
f"Plugin '{name}' instantiated {count} times",
"Multiple instances of the same plugin can cause conflicts. "
"Usually only one instance is needed."))
# P3: HtmlWebpackPlugin without template
if has_html_plugin and not html_plugin_has_template:
findings.append(Finding("P3", "info",
"HtmlWebpackPlugin used without explicit template",
"Without a template, the default HTML is generated. "
"Consider specifying template: './src/index.html' for control."))
# P4: MiniCssExtractPlugin without corresponding loader
if has_mini_css_plugin:
module = data.get("module", {})
rules = module.get("rules", []) if isinstance(module, dict) else []
has_extract_loader = False
def _check_loaders_for_extract(rule_list: list) -> bool:
for rule in rule_list:
if not isinstance(rule, dict):
continue
loaders = _extract_loaders(rule)
for loader in loaders:
loader_base = loader.split("?")[0].split("!")[0].strip()
if "mini-css-extract-plugin" in loader_base.lower() or \
"MiniCssExtractPlugin" in loader:
return True
# Check oneOf
one_of = rule.get("oneOf", [])
if isinstance(one_of, list) and _check_loaders_for_extract(one_of):
return True
return False
has_extract_loader = _check_loaders_for_extract(rules)
if not has_extract_loader:
findings.append(Finding("P4", "warning",
"MiniCssExtractPlugin present but no corresponding loader in module.rules",
"Add MiniCssExtractPlugin.loader to your CSS rule's 'use' array "
"to extract CSS into separate files."))
return findings
def check_optimization(data: dict, mode: str | None) -> list[Finding]:
"""O1-O3: Check optimization configuration."""
findings: list[Finding] = []
optimization = data.get("optimization", {})
effective_mode = mode or data.get("mode")
# O1: Missing splitChunks
if not isinstance(optimization, dict) or "splitChunks" not in optimization:
findings.append(Finding("O1", "info",
"No optimization.splitChunks configuration",
"splitChunks enables automatic code splitting for shared dependencies. "
"Add optimization: { splitChunks: { chunks: 'all' } } for better caching."))
# O2: Missing minimizer
if not isinstance(optimization, dict) or "minimizer" not in optimization:
findings.append(Finding("O2", "info",
"No custom optimization.minimizer configured",
"Webpack uses TerserPlugin by default in production. "
"Customize minimizer to add CSS minification or tune settings."))
# O3: devtool set to eval/source-map in production
devtool = data.get("devtool")
if devtool is not None and effective_mode == "production":
devtool_str = str(devtool).lower()
if "eval" in devtool_str:
findings.append(Finding("O3", "warning",
f"devtool '{devtool}' uses eval in production mode",
"eval-based source maps expose source code and are slow. "
"Use 'source-map' or 'hidden-source-map' for production."))
elif devtool_str == "source-map":
findings.append(Finding("O3", "info",
"devtool 'source-map' in production exposes full source maps",
"Consider 'hidden-source-map' or 'nosources-source-map' "
"to avoid exposing source code to end users."))
return findings
def check_best_practices(data: dict) -> list[Finding]:
"""B1-B4: Check best practices."""
findings: list[Finding] = []
# B1: Missing resolve.extensions
resolve = data.get("resolve", {})
if isinstance(resolve, dict):
if "extensions" not in resolve:
findings.append(Finding("B1", "info",
"Missing resolve.extensions",
"Set resolve.extensions to auto-resolve imports without file extensions, "
"e.g. ['.js', '.jsx', '.ts', '.tsx', '.json']."))
else:
findings.append(Finding("B1", "info",
"Missing resolve configuration",
"Add resolve.extensions to auto-resolve imports, "
"e.g. resolve: { extensions: ['.js', '.jsx', '.ts', '.tsx'] }."))
# B2: Absolute paths in config (outside of output.path which is expected)
_check_absolute_paths(data, findings, path_prefix="", skip_keys={"path"})
# B3: No mode set
if "mode" not in data:
findings.append(Finding("B3", "warning",
"No 'mode' set (development/production/none)",
"Set mode to enable webpack's built-in optimizations. "
"Without mode, webpack defaults to 'production' with a warning."))
# B4: Missing devServer hint
if "devServer" not in data:
findings.append(Finding("B4", "info",
"No devServer configuration",
"Add devServer for local development with hot module replacement. "
"Example: devServer: { port: 3000, hot: true }."))
return findings
def _check_absolute_paths(data: Any, findings: list[Finding], path_prefix: str,
skip_keys: set[str] | None = None, depth: int = 0) -> None:
"""Recursively check for hardcoded absolute paths in config values."""
if depth > 10: # prevent deep recursion
return
if skip_keys is None:
skip_keys = set()
if isinstance(data, dict):
for key, val in data.items():
if key in skip_keys and path_prefix == "output":
continue # output.path is expected to be absolute
current_path = f"{path_prefix}.{key}" if path_prefix else key
_check_absolute_paths(val, findings, current_path, skip_keys, depth + 1)
elif isinstance(data, list):
for i, val in enumerate(data):
current_path = f"{path_prefix}[{i}]"
_check_absolute_paths(val, findings, current_path, skip_keys, depth + 1)
elif isinstance(data, str):
# Check for absolute filesystem paths (not URLs)
if re.match(r'^(/[a-zA-Z]|[A-Z]:\\)', data) and not data.startswith("http"):
# Skip output.path — it's supposed to be absolute
if path_prefix.startswith("output.path"):
return
findings.append(Finding("B2", "warning",
f"Hardcoded absolute path at '{path_prefix}': {data[:80]}",
"Use path.resolve() or relative paths for portability across machines."))
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
def validate_all(data: dict, mode: str | None = None) -> list[Finding]:
"""Run all checks and return combined findings."""
findings: list[Finding] = []
findings.extend(check_structure(data))
findings.extend(check_entry_output(data, mode))
findings.extend(check_module_rules(data))
findings.extend(check_plugins(data))
findings.extend(check_optimization(data, mode))
findings.extend(check_best_practices(data))
return findings
# ---------------------------------------------------------------------------
# Rule explanations (for 'explain' command)
# ---------------------------------------------------------------------------
RULE_EXPLANATIONS: dict[str, dict[str, str]] = {
"S1": {
"name": "File Not Found",
"category": "Structure",
"severity": "error",
"description": "The webpack config JSON file does not exist or cannot be read.",
"fix": "Ensure the file path is correct and the file has read permissions. "
"Export with: node -e \"console.log(JSON.stringify(require('./webpack.config.js')))\" > config.json",
},
"S2": {
"name": "Empty Config",
"category": "Structure",
"severity": "error",
"description": "The config file is empty (zero bytes or only whitespace).",
"fix": "Re-export the webpack config to JSON. The file must contain a valid JSON object.",
},
"S3": {
"name": "Invalid JSON",
"category": "Structure",
"severity": "error",
"description": "The file contains invalid JSON syntax that cannot be parsed.",
"fix": "Ensure the export produces valid JSON. Functions and RegExp objects are not JSON-serializable "
"and must be converted to strings.",
},
"S4": {
"name": "Missing Required Fields",
"category": "Structure",
"severity": "error",
"description": "Missing 'entry' or 'output' — the two essential webpack config fields.",
"fix": "Add entry (string, array, or object) and output (object with path and filename).",
},
"S5": {
"name": "Unknown/Deprecated Keys",
"category": "Structure",
"severity": "warning/info",
"description": "Top-level key is deprecated (e.g. 'loaders') or not recognized by webpack.",
"fix": "Remove or rename deprecated keys. Check webpack docs for valid configuration options.",
},
"E1": {
"name": "Empty Entry Point",
"category": "Entry/Output",
"severity": "error",
"description": "Entry point is defined but empty (empty string, object, or array).",
"fix": "Specify at least one entry file, e.g. entry: './src/index.js'.",
},
"E2": {
"name": "Output Missing Path",
"category": "Entry/Output",
"severity": "error",
"description": "Output section exists but has no 'path' property.",
"fix": "Add output.path with an absolute directory path for the build output.",
},
"E3": {
"name": "No Content Hash in Production",
"category": "Entry/Output",
"severity": "warning",
"description": "Output filename lacks a content hash token in production mode.",
"fix": "Use [contenthash] in output.filename for cache busting, e.g. '[name].[contenthash].js'.",
},
"E4": {
"name": "publicPath Not Set",
"category": "Entry/Output",
"severity": "warning",
"description": "output.publicPath is not configured, which can cause asset loading issues.",
"fix": "Set output.publicPath to the URL path where assets will be served, e.g. '/' or '/assets/'.",
},
"M1": {
"name": "Rule Without Test",
"category": "Module/Rules",
"severity": "warning",
"description": "A module rule has no 'test' pattern to match files.",
"fix": "Add a test property with a regex pattern, e.g. test: /\\.js$/.",
},
"M2": {
"name": "Duplicate Loader",
"category": "Module/Rules",
"severity": "warning",
"description": "Same loader appears multiple times for the same test pattern.",
"fix": "Remove the duplicate loader to avoid double processing.",
},
"M3": {
"name": "Deprecated Loader",
"category": "Module/Rules",
"severity": "warning",
"description": "Using a loader that is deprecated in webpack 5 (raw-loader, url-loader, file-loader, json-loader).",
"fix": "Replace with webpack 5 asset modules: asset/source, asset, asset/resource.",
},
"M4": {
"name": "No JS/TS Transpilation Loader",
"category": "Module/Rules",
"severity": "info",
"description": "No babel-loader, ts-loader, esbuild-loader, or swc-loader found for JS/TS files.",
"fix": "Add a transpilation loader if using modern JS/TS syntax. "
"Example: { test: /\\.tsx?$/, use: 'ts-loader' }.",
},
"P1": {
"name": "Deprecated Plugin",
"category": "Plugins",
"severity": "error",
"description": "Using a plugin that is deprecated or removed in webpack 4/5.",
"fix": "Replace with the modern equivalent (TerserPlugin, MiniCssExtractPlugin, splitChunks).",
},
"P2": {
"name": "Duplicate Plugin",
"category": "Plugins",
"severity": "warning",
"description": "Same plugin instantiated multiple times, which can cause conflicts.",
"fix": "Remove duplicate plugin instances. Usually only one instance per plugin is needed.",
},
"P3": {
"name": "HtmlWebpackPlugin Without Template",
"category": "Plugins",
"severity": "info",
"description": "HtmlWebpackPlugin is used without an explicit template file.",
"fix": "Add template: './src/index.html' to HtmlWebpackPlugin options for control over the HTML.",
},
"P4": {
"name": "MiniCssExtractPlugin Without Loader",
"category": "Plugins",
"severity": "warning",
"description": "MiniCssExtractPlugin is present but its loader is not found in module.rules.",
"fix": "Add MiniCssExtractPlugin.loader to your CSS rule's 'use' array to extract CSS into files.",
},
"O1": {
"name": "No splitChunks",
"category": "Optimization",
"severity": "info",
"description": "No optimization.splitChunks configuration for code splitting.",
"fix": "Add optimization.splitChunks: { chunks: 'all' } for automatic vendor splitting.",
},
"O2": {
"name": "No Custom Minimizer",
"category": "Optimization",
"severity": "info",
"description": "No custom minimizer configured. Webpack uses TerserPlugin by default in production.",
"fix": "Customize minimizer to add CSS minification or tune JS minification settings.",
},
"O3": {
"name": "Devtool in Production",
"category": "Optimization",
"severity": "warning/info",
"description": "devtool setting may expose source code or slow down production builds.",
"fix": "Use 'hidden-source-map' or 'nosources-source-map' for production.",
},
"B1": {
"name": "Missing resolve.extensions",
"category": "Best Practices",
"severity": "info",
"description": "resolve.extensions not configured — imports require full file extensions.",
"fix": "Add resolve.extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'].",
},
"B2": {
"name": "Absolute Paths",
"category": "Best Practices",
"severity": "warning",
"description": "Hardcoded absolute filesystem paths reduce config portability.",
"fix": "Use path.resolve(__dirname, 'relative/path') or relative paths instead.",
},
"B3": {
"name": "No Mode Set",
"category": "Best Practices",
"severity": "warning",
"description": "No mode set (development/production/none). Webpack defaults to 'production' with a warning.",
"fix": "Set mode: 'production' or mode: 'development' explicitly.",
},
"B4": {
"name": "No devServer Config",
"category": "Best Practices",
"severity": "info",
"description": "No devServer configuration for local development.",
"fix": "Add devServer: { port: 3000, hot: true } for hot module replacement during development.",
},
}
# ---------------------------------------------------------------------------
# Suggestion engine (for 'suggest' command)
# ---------------------------------------------------------------------------
def generate_suggestions(data: dict, findings: list[Finding]) -> list[dict]:
"""Generate actionable fix suggestions from findings."""
suggestions: list[dict] = []
for f in findings:
rule = RULE_EXPLANATIONS.get(f.rule_id)
if not rule:
continue
suggestion = {
"rule_id": f.rule_id,
"severity": f.severity,
"problem": f.message,
"fix": rule["fix"],
}
# Add concrete JSON snippets for common fixes
if f.rule_id == "S4":
suggestion["snippet"] = '{ "entry": "./src/index.js", "output": { "path": "/absolute/dist", "filename": "bundle.js" } }'
elif f.rule_id == "E1":
suggestion["snippet"] = '"entry": "./src/index.js"'
elif f.rule_id == "E2":
suggestion["snippet"] = '"output": { "path": "/absolute/path/to/dist", "filename": "[name].[contenthash].js" }'
elif f.rule_id == "E3":
suggestion["snippet"] = '"filename": "[name].[contenthash].js"'
elif f.rule_id == "E4":
suggestion["snippet"] = '"publicPath": "/"'
elif f.rule_id == "B1":
suggestion["snippet"] = '"resolve": { "extensions": [".js", ".jsx", ".ts", ".tsx", ".json"] }'
elif f.rule_id == "B3":
suggestion["snippet"] = '"mode": "production"'
elif f.rule_id == "B4":
suggestion["snippet"] = '"devServer": { "port": 3000, "hot": true }'
elif f.rule_id == "O1":
suggestion["snippet"] = '"optimization": { "splitChunks": { "chunks": "all" } }'
elif f.rule_id == "M3":
# Extract deprecated loader name
match = re.search(r"'([^']+)'", f.message)
if match:
loader = match.group(1)
if loader in DEPRECATED_LOADERS:
suggestion["snippet"] = f'"type": "{DEPRECATED_LOADERS[loader].split(" ")[0]}" // replaces {loader}'
elif f.rule_id == "P1":
match = re.search(r"'([^']+)'", f.message)
if match:
plugin = match.group(1)
if plugin in DEPRECATED_PLUGINS:
suggestion["snippet"] = f'// Replace {plugin} with {DEPRECATED_PLUGINS[plugin]}'
suggestions.append(suggestion)
return suggestions
# ---------------------------------------------------------------------------
# Summary helper
# ---------------------------------------------------------------------------
def _summary_counts(findings: list[Finding]) -> dict:
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
infos = sum(1 for f in findings if f.severity == "info")
return {"errors": errors, "warnings": warnings, "infos": infos, "total": len(findings)}
def _summary_text(findings: list[Finding]) -> str:
c = _summary_counts(findings)
parts = []
if c["errors"]:
parts.append(f"{c['errors']} error(s)")
if c["warnings"]:
parts.append(f"{c['warnings']} warning(s)")
if c["infos"]:
parts.append(f"{c['infos']} info")
return ", ".join(parts) if parts else "No issues found"
# ---------------------------------------------------------------------------
# Command handlers
# ---------------------------------------------------------------------------
def cmd_validate(data: dict, path: str, mode: str | None) -> dict:
"""Full validation with summary."""
findings = validate_all(data, mode)
errors = [f for f in findings if f.severity == "error"]
return {
"command": "validate",
"file": path,
"valid": len(errors) == 0,
"findings": [f.to_dict() for f in findings],
"counts": _summary_counts(findings),
"summary": _summary_text(findings),
}
def cmd_check(data: dict, path: str, mode: str | None) -> dict:
"""Quick check — errors and warnings only."""
findings = validate_all(data, mode)
filtered = [f for f in findings if f.severity in ("error", "warning")]
return {
"command": "check",
"file": path,
"passed": all(f.severity != "error" for f in findings),
"findings": [f.to_dict() for f in filtered],
"counts": _summary_counts(filtered),
"summary": _summary_text(filtered),
}
def cmd_explain(data: dict | None, path: str) -> dict:
"""Explain all rules with their categories and severity."""
rules = []
for rule_id in sorted(RULE_EXPLANATIONS.keys()):
info = RULE_EXPLANATIONS[rule_id]
rules.append({
"rule_id": rule_id,
"name": info["name"],
"category": info["category"],
"severity": info["severity"],
"description": info["description"],
"fix": info["fix"],
})
return {
"command": "explain",
"file": path,
"rules": rules,
"total_rules": len(rules),
}
def cmd_suggest(data: dict, path: str, mode: str | None) -> dict:
"""Run validation and generate fix suggestions."""
findings = validate_all(data, mode)
suggestions = generate_suggestions(data, findings)
return {
"command": "suggest",
"file": path,
"suggestions": suggestions,
"total": len(suggestions),
"summary": _summary_text(findings),
}
# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------
def format_text(result: dict) -> str:
cmd = result.get("command", "")
path = result.get("file", "")
lines = []
title = f"webpack.config {cmd} — {path}"
lines.append(title)
lines.append("=" * len(title))
if cmd == "explain":
for rule in result.get("rules", []):
lines.append("")
lines.append(f" {rule['rule_id']}: {rule['name']} [{rule['category']}] ({rule['severity']})")
lines.append(f" {rule['description']}")
lines.append(f" Fix: {rule['fix']}")
lines.append("")
lines.append(f"Total rules: {result.get('total_rules', 0)}")
return "\n".join(lines)
if cmd == "suggest":
suggestions = result.get("suggestions", [])
if not suggestions:
lines.append("[OK] No suggestions — webpack config looks good")
else:
for s in suggestions:
sev = s["severity"].upper().ljust(7)
lines.append(f"[{sev}] {s['rule_id']}: {s['problem']}")
lines.append(f" Fix: {s['fix']}")
if "snippet" in s:
lines.append(f" Add: {s['snippet']}")
lines.append("")
lines.append(f"Summary: {result.get('summary', '')}")
return "\n".join(lines)
# validate / check
findings = result.get("findings", [])
if not findings:
lines.append("[OK] No issues found")
else:
for f in findings:
sev = f["severity"].upper().ljust(7)
lines.append(f"[{sev}] {f['rule_id']}: {f['message']}")
if f.get("detail"):
lines.append(f" {f['detail']}")
if cmd == "validate":
valid_str = "VALID" if result.get("valid") else "INVALID"
lines.append("")
lines.append(f"Result: {valid_str}")
if cmd == "check":
passed_str = "PASSED" if result.get("passed") else "FAILED"
lines.append("")
lines.append(f"Result: {passed_str}")
summary = result.get("summary")
if summary:
lines.append(f"Summary: {summary}")
return "\n".join(lines)
def format_json(result: dict) -> str:
return json.dumps(result, indent=2)
def format_summary(result: dict) -> str:
cmd = result.get("command", "")
path = result.get("file", "")
lines = []
lines.append(f"webpack.config {cmd}: {path}")
if cmd == "explain":
lines.append(f"Rules: {result.get('total_rules', 0)}")
categories: dict[str, int] = {}
for rule in result.get("rules", []):
cat = rule["category"]
categories[cat] = categories.get(cat, 0) + 1
for cat, count in sorted(categories.items()):
lines.append(f" {cat}: {count} rules")
return "\n".join(lines)
counts = result.get("counts", {})
lines.append(f"Errors: {counts.get('errors', 0)}")
lines.append(f"Warnings: {counts.get('warnings', 0)}")
lines.append(f"Info: {counts.get('infos', 0)}")
if "valid" in result:
lines.append(f"Valid: {'yes' if result['valid'] else 'no'}")
if "passed" in result:
lines.append(f"Passed: {'yes' if result['passed'] else 'no'}")
if cmd == "suggest":
lines.append(f"Suggestions: {result.get('total', 0)}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Validate JSON-exported webpack configuration files",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Commands:
validate Full validation with all rules
check Quick check (errors and warnings only)
explain Show all rules with descriptions
suggest Run validation and propose fixes
Note: webpack configs are JS/TS. This validator works with JSON-exported configs.
Export via: node -e "console.log(JSON.stringify(require('./webpack.config.js')))" > config.json
Examples:
python3 webpack_config_validator.py validate config.json
python3 webpack_config_validator.py validate config.json --strict --mode production
python3 webpack_config_validator.py check config.json --format json
python3 webpack_config_validator.py explain config.json
python3 webpack_config_validator.py suggest config.json --format summary
"""
)
parser.add_argument("command", choices=["validate", "check", "explain", "suggest"],
help="Command to run")
parser.add_argument("file", help="Path to JSON-exported webpack config")
parser.add_argument("--strict", action="store_true",
help="Treat warnings as errors (CI mode)")
parser.add_argument("--format", choices=["text", "json", "summary"], default="text",
help="Output format (default: text)")
parser.add_argument("--mode", choices=["production", "development"], default=None,
help="Override mode context for mode-specific rules")
args = parser.parse_args()
# For 'explain', we don't need a valid file (but accept the arg for consistency)
if args.command == "explain":
result = cmd_explain(None, args.file)
else:
# Load and parse file
data, parse_error = load_config(args.file)
if parse_error:
result = {
"command": args.command,
"file": args.file,
"findings": [parse_error.to_dict()],
"counts": {"errors": 1, "warnings": 0, "infos": 0, "total": 1},
"summary": "1 error(s)",
}
if args.command == "validate":
result["valid"] = False
elif args.command == "check":
result["passed"] = False
elif args.command == "suggest":
result["suggestions"] = []
result["total"] = 0
formatter = {"text": format_text, "json": format_json, "summary": format_summary}
print(formatter[args.format](result))
sys.exit(2)
# Run command
if args.command == "validate":
result = cmd_validate(data, args.file, args.mode)
elif args.command == "check":
result = cmd_check(data, args.file, args.mode)
elif args.command == "suggest":
result = cmd_suggest(data, args.file, args.mode)
# Format output
formatter = {"text": format_text, "json": format_json, "summary": format_summary}
print(formatter[args.format](result))
# Exit code
if args.command == "explain":
sys.exit(0)
findings = result.get("findings", [])
has_errors = any(f["severity"] == "error" for f in findings)
has_warnings = any(f["severity"] == "warning" for f in findings)
if has_errors:
sys.exit(1)
if args.strict and has_warnings:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
Validate Rust Cargo.toml manifests for dependency issues, missing metadata, feature conflicts, workspace config, and crates.io publishing readiness. Use when...
---
name: cargo-toml-validator
description: Validate Rust Cargo.toml manifests for dependency issues, missing metadata, feature conflicts, workspace config, and crates.io publishing readiness. Use when validating Rust project configs, auditing dependencies, or preparing crates for publishing.
---
# Cargo.toml Validator
Validate Rust `Cargo.toml` manifest files for structural correctness, dependency hygiene, feature configuration, workspace setup, and crates.io publishing readiness. Uses Python 3.11+ `tomllib` for native TOML parsing — no external dependencies.
## Commands
### validate — Full validation with all rules
```bash
python3 scripts/cargo_toml_validator.py validate Cargo.toml
python3 scripts/cargo_toml_validator.py validate Cargo.toml --strict
python3 scripts/cargo_toml_validator.py validate Cargo.toml --format json
```
### check — Quick check (errors and warnings only)
```bash
python3 scripts/cargo_toml_validator.py check Cargo.toml
python3 scripts/cargo_toml_validator.py check Cargo.toml --format summary
```
### explain — Show all rules with descriptions
```bash
python3 scripts/cargo_toml_validator.py explain Cargo.toml
python3 scripts/cargo_toml_validator.py explain Cargo.toml --format json
```
### suggest — Run validation and propose fixes
```bash
python3 scripts/cargo_toml_validator.py suggest Cargo.toml
python3 scripts/cargo_toml_validator.py suggest Cargo.toml --format json
```
## Flags
| Flag | Description |
|------|-------------|
| `--strict` | Treat warnings as errors — exit code 1 (CI-friendly) |
| `--format text` | Human-readable output (default) |
| `--format json` | Machine-readable JSON |
| `--format summary` | Compact summary with counts |
## Validation Rules (24)
### Structure (5)
| Rule | Severity | Description |
|------|----------|-------------|
| S1 | error | File not found or unreadable |
| S2 | error | Empty file |
| S3 | error | Invalid TOML syntax |
| S4 | error/warning | Missing [package] section (error for bins/libs, warning for virtual workspaces) |
| S5 | error/warning | Missing required fields: name, version (error), edition (warning) |
### Package Metadata (4)
| Rule | Severity | Description |
|------|----------|-------------|
| M1 | warning | Missing edition field (defaults to 2015) |
| M2 | info | Outdated edition (2015/2018 when 2021/2024 available) |
| M3 | warning | Missing license or license-file for crates.io |
| M4 | warning | Missing description for crates.io |
### Dependencies (6)
| Rule | Severity | Description |
|------|----------|-------------|
| D1 | error | Wildcard version `*` |
| D2 | warning | Unpinned dependency without version specifier |
| D3 | warning | Git dependency without rev/tag/branch pin |
| D4 | info | Path dependency (blocks crates.io publish) |
| D5 | warning | Duplicate dep in [dependencies] and [dev-dependencies] with different versions |
| D6 | info | Deprecated crate name (failure, error-chain, iron, rustc-serialize, old hyper/tokio/actix-web/rocket/time) |
### Features (3)
| Rule | Severity | Description |
|------|----------|-------------|
| F1 | error | Feature enables non-existent dependency |
| F2 | warning | Empty feature (no deps or sub-features) |
| F3 | error | Circular feature dependencies |
### Workspace (3)
| Rule | Severity | Description |
|------|----------|-------------|
| W1 | warning | [workspace] with no members |
| W2 | info | Both [package] and [workspace] without members (ambiguous) |
| W3 | info | [workspace.dependencies] detected — hint about workspace = true in members |
### Best Practices (3+)
| Rule | Severity | Description |
|------|----------|-------------|
| B1 | info | Missing documentation link for published crates |
| B2 | info | Build script without [build-dependencies] |
| B3 | info | Very large number of dependencies (>30) |
| B4 | info | Missing repository/homepage URL |
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | No errors (warnings allowed unless `--strict`) |
| 1 | Errors found (or warnings in `--strict` mode) |
| 2 | File not found / parse error |
## Example Output
```
Cargo.toml validate — Cargo.toml
=================================
[ERROR ] S5: Missing required field: package.version
Set version directly or use version.workspace = true.
[WARNING] M1: Missing edition field — defaults to 2015
Add edition = "2021" or edition = "2024" to [package].
[WARNING] D3: Git dependency 'my-fork' in [dependencies] is not pinned
Pin to a rev, tag, or branch for reproducibility. URL: https://github.com/user/fork
[INFO ] D4: Path dependency 'utils' in [dependencies] — blocks crates.io publish
Path: ../utils. Fine for local dev, but won't work on crates.io.
[INFO ] B4: Missing repository and homepage URL
Add repository = "https://github.com/..." to [package] for crates.io visibility.
Result: INVALID
Summary: 1 error(s), 2 warning(s), 2 info
```
FILE:scripts/cargo_toml_validator.py
#!/usr/bin/env python3
"""
Cargo.toml Validator
Validate Rust Cargo.toml manifests for dependency issues, missing metadata,
feature conflicts, workspace config, and crates.io publishing readiness.
Usage: python3 cargo_toml_validator.py <command> <file> [--strict] [--format text|json|summary]
Commands: validate, check, explain, suggest
"""
import sys
import os
import re
import json
import argparse
import tomllib
from typing import Any
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
VALID_EDITIONS = {"2015", "2018", "2021", "2024"}
CURRENT_EDITIONS = {"2021", "2024"}
OUTDATED_EDITIONS = {"2015", "2018"}
# D6: Deprecated crate names -> suggested replacements
DEPRECATED_CRATES: dict[str, str] = {
"failure": "anyhow or thiserror",
"error-chain": "thiserror",
"iron": "actix-web or axum",
"rustc-serialize": "serde",
"hyper 0.11": "hyper 1.x",
"tokio 0.1": "tokio 1.x",
"actix-web 3": "actix-web 4",
"rocket 0.4": "rocket 0.5",
}
# Deprecated crate names that can be matched by name alone (no version check)
DEPRECATED_BY_NAME: dict[str, str] = {
"failure": "anyhow or thiserror",
"error-chain": "thiserror",
"iron": "actix-web or axum",
"rustc-serialize": "serde",
}
# Deprecated crate names that require version prefix check
DEPRECATED_BY_VERSION: dict[str, dict[str, str]] = {
"hyper": {"0.11": "hyper 1.x"},
"tokio": {"0.1": "tokio 1.x"},
"actix-web": {"3": "actix-web 4"},
"rocket": {"0.4": "rocket 0.5"},
}
# time crate: old versions (0.1, 0.2) -> suggest chrono or time 0.3+
# Handled separately since "time" is also a valid modern crate
DEPENDENCY_SECTIONS = [
"dependencies",
"dev-dependencies",
"build-dependencies",
"target",
]
# ---------------------------------------------------------------------------
# Finding class
# ---------------------------------------------------------------------------
class Finding:
"""A single validation finding."""
SEVERITIES = ("error", "warning", "info")
def __init__(self, rule_id: str, severity: str, message: str, detail: str = ""):
assert severity in self.SEVERITIES, f"Invalid severity: {severity}"
self.rule_id = rule_id
self.severity = severity
self.message = message
self.detail = detail
def to_dict(self) -> dict:
d = {
"rule_id": self.rule_id,
"severity": self.severity,
"message": self.message,
}
if self.detail:
d["detail"] = self.detail
return d
def __repr__(self):
return f"Finding({self.rule_id}, {self.severity}, {self.message!r})"
# ---------------------------------------------------------------------------
# TOML loading
# ---------------------------------------------------------------------------
def load_config(path: str) -> tuple[dict | None, Finding | None]:
"""Load and parse a Cargo.toml file. Returns (data, error_finding)."""
# S1: File not found or unreadable
if not os.path.exists(path):
return None, Finding("S1", "error", f"File not found: {path}")
try:
with open(path, "rb") as f:
content = f.read()
except OSError as e:
return None, Finding("S1", "error", f"Cannot read file: {e}")
# S2: Empty file
if len(content.strip()) == 0:
return None, Finding("S2", "error", "File is empty")
# S3: Invalid TOML syntax
try:
data = tomllib.loads(content.decode("utf-8"))
except tomllib.TOMLDecodeError as e:
return None, Finding("S3", "error", f"Invalid TOML syntax: {e}")
except UnicodeDecodeError as e:
return None, Finding("S3", "error", f"File encoding error: {e}")
return data, None
# ---------------------------------------------------------------------------
# Individual check functions
# ---------------------------------------------------------------------------
def check_structure(data: dict) -> list[Finding]:
"""S4, S5: Check for [package] section and required fields."""
findings: list[Finding] = []
has_package = "package" in data
has_workspace = "workspace" in data
is_virtual_workspace = has_workspace and not has_package
# S4: Missing [package] section
if not has_package:
if is_virtual_workspace:
findings.append(Finding("S4", "warning",
"No [package] section (virtual workspace)",
"Virtual workspaces use [workspace] only. This is fine if intentional."))
else:
findings.append(Finding("S4", "error",
"Missing [package] section",
"Every binary or library crate needs a [package] section."))
return findings
# S5: Missing required fields in [package]
pkg = data["package"]
if "name" not in pkg:
findings.append(Finding("S5", "error",
"Missing required field: package.name"))
if "version" not in pkg:
# version can be inherited from workspace, check for that
if not (isinstance(pkg.get("version"), dict) and pkg["version"].get("workspace")):
findings.append(Finding("S5", "error",
"Missing required field: package.version",
"Set version directly or use version.workspace = true."))
if "edition" not in pkg:
findings.append(Finding("S5", "warning",
"Missing recommended field: package.edition",
"Without edition, Rust defaults to 2015. Almost certainly wrong for new projects."))
return findings
def check_package_metadata(data: dict) -> list[Finding]:
"""M1-M4: Check package metadata quality."""
findings: list[Finding] = []
pkg = data.get("package", {})
if not pkg:
return findings
# M1: Missing edition field
edition = pkg.get("edition")
if edition is None:
findings.append(Finding("M1", "warning",
"Missing edition field — defaults to 2015",
"Add edition = \"2021\" or edition = \"2024\" to [package]."))
# M2: Outdated edition
if edition is not None:
edition_str = str(edition)
if edition_str in OUTDATED_EDITIONS:
findings.append(Finding("M2", "info",
f"Outdated edition '{edition_str}' — consider upgrading to 2021 or 2024",
"Run 'cargo fix --edition' to migrate."))
elif edition_str not in VALID_EDITIONS:
findings.append(Finding("M2", "warning",
f"Unknown edition '{edition_str}' — valid editions: {', '.join(sorted(VALID_EDITIONS))}"))
# M3: Missing license or license-file
if "license" not in pkg and "license-file" not in pkg:
findings.append(Finding("M3", "warning",
"Missing license or license-file for crates.io publishing",
"Add license = \"MIT\" or license-file = \"LICENSE\" to [package]."))
# M4: Missing description
if "description" not in pkg:
findings.append(Finding("M4", "warning",
"Missing description for crates.io publishing",
"Add description = \"...\" to [package]. Required for crates.io."))
return findings
def _extract_all_dependencies(data: dict) -> dict[str, dict[str, Any]]:
"""Extract all dependency sections. Returns {section_name: {dep_name: dep_spec}}."""
result = {}
for section in ["dependencies", "dev-dependencies", "build-dependencies"]:
if section in data:
result[section] = data[section]
# Also extract target-specific dependencies
targets = data.get("target", {})
if isinstance(targets, dict):
for target_name, target_data in targets.items():
if isinstance(target_data, dict):
for section in ["dependencies", "dev-dependencies", "build-dependencies"]:
if section in target_data:
key = f"target.{target_name}.{section}"
result[key] = target_data[section]
return result
def _parse_version_from_dep(dep_spec: Any) -> str | None:
"""Extract version string from a dependency specification."""
if isinstance(dep_spec, str):
return dep_spec
if isinstance(dep_spec, dict):
return dep_spec.get("version")
return None
def _is_git_dep(dep_spec: Any) -> bool:
"""Check if dependency uses git source."""
if isinstance(dep_spec, dict):
return "git" in dep_spec
return False
def _is_path_dep(dep_spec: Any) -> bool:
"""Check if dependency uses path source."""
if isinstance(dep_spec, dict):
return "path" in dep_spec
return False
def _git_is_pinned(dep_spec: dict) -> bool:
"""Check if a git dependency is pinned to rev, tag, or branch."""
return any(k in dep_spec for k in ("rev", "tag", "branch"))
def check_dependencies(data: dict) -> list[Finding]:
"""D1-D6: Check dependency declarations."""
findings: list[Finding] = []
all_deps = _extract_all_dependencies(data)
for section_name, deps in all_deps.items():
if not isinstance(deps, dict):
continue
for dep_name, dep_spec in deps.items():
version = _parse_version_from_dep(dep_spec)
# D1: Wildcard version
if version is not None and version.strip() == "*":
findings.append(Finding("D1", "error",
f"Wildcard version '*' for '{dep_name}' in [{section_name}]",
"Wildcard dependencies are highly discouraged. Pin to a version range."))
# D2: Unpinned dependency (empty version in table form)
if isinstance(dep_spec, dict) and "version" not in dep_spec:
if not _is_git_dep(dep_spec) and not _is_path_dep(dep_spec):
# Check for workspace inheritance
if not dep_spec.get("workspace"):
findings.append(Finding("D2", "warning",
f"Dependency '{dep_name}' in [{section_name}] has no version specifier",
"Add a version field or use workspace = true."))
# D3: Git dependency without pin
if _is_git_dep(dep_spec) and isinstance(dep_spec, dict):
if not _git_is_pinned(dep_spec):
git_url = dep_spec.get("git", "")
findings.append(Finding("D3", "warning",
f"Git dependency '{dep_name}' in [{section_name}] is not pinned",
f"Pin to a rev, tag, or branch for reproducibility. URL: {git_url}"))
# D4: Path dependency
if _is_path_dep(dep_spec) and isinstance(dep_spec, dict):
path_val = dep_spec.get("path", "")
findings.append(Finding("D4", "info",
f"Path dependency '{dep_name}' in [{section_name}] — blocks crates.io publish",
f"Path: {path_val}. Fine for local dev, but won't work on crates.io."))
# D6: Deprecated crate names (by name alone)
if dep_name in DEPRECATED_BY_NAME:
replacement = DEPRECATED_BY_NAME[dep_name]
findings.append(Finding("D6", "info",
f"Deprecated crate '{dep_name}' in [{section_name}] — consider '{replacement}'",
f"'{dep_name}' is unmaintained. Migrate to {replacement}."))
# D6: Deprecated crate names (by version prefix)
if dep_name in DEPRECATED_BY_VERSION and version is not None:
for prefix, replacement in DEPRECATED_BY_VERSION[dep_name].items():
# Check if version starts with the deprecated major version
clean_ver = version.lstrip("^~>=<! ")
if clean_ver.startswith(prefix):
findings.append(Finding("D6", "info",
f"Outdated version of '{dep_name}' ({version}) in [{section_name}] — consider '{replacement}'",
f"Upgrade to {replacement} for latest features and fixes."))
break
# D6: Special handling for 'time' crate
if dep_name == "time" and version is not None:
clean_ver = version.lstrip("^~>=<! ")
if clean_ver.startswith("0.1") or clean_ver.startswith("0.2"):
findings.append(Finding("D6", "info",
f"Outdated 'time' crate ({version}) in [{section_name}] — consider chrono or time 0.3+",
"time 0.1/0.2 are unmaintained. Use chrono or time 0.3+."))
# D5: Duplicate dependency in [dependencies] and [dev-dependencies] with different versions
main_deps = all_deps.get("dependencies", {})
dev_deps = all_deps.get("dev-dependencies", {})
if main_deps and dev_deps:
overlap = set(main_deps.keys()) & set(dev_deps.keys())
for dep_name in sorted(overlap):
main_ver = _parse_version_from_dep(main_deps[dep_name])
dev_ver = _parse_version_from_dep(dev_deps[dep_name])
if main_ver != dev_ver:
findings.append(Finding("D5", "warning",
f"'{dep_name}' in both [dependencies] ({main_ver}) and [dev-dependencies] ({dev_ver}) with different versions",
"This can cause confusing build behavior. Align versions or use features."))
return findings
def check_features(data: dict) -> list[Finding]:
"""F1-F3: Check feature declarations."""
findings: list[Finding] = []
features = data.get("features", {})
if not features:
return findings
# Build set of known dependency names (for F1 check)
known_deps: set[str] = set()
all_deps = _extract_all_dependencies(data)
for section_deps in all_deps.values():
if isinstance(section_deps, dict):
known_deps.update(section_deps.keys())
# Also add optional dependencies (they can be enabled as features)
main_deps = data.get("dependencies", {})
optional_deps: set[str] = set()
if isinstance(main_deps, dict):
for dep_name, dep_spec in main_deps.items():
if isinstance(dep_spec, dict) and dep_spec.get("optional"):
optional_deps.add(dep_name)
# All feature names are also valid as feature references
feature_names = set(features.keys())
for feat_name, feat_values in features.items():
if not isinstance(feat_values, list):
continue
# F2: Empty feature
if len(feat_values) == 0:
findings.append(Finding("F2", "warning",
f"Feature '{feat_name}' is empty (no dependencies or sub-features)",
"Empty features serve no purpose. Add entries or remove."))
continue
# F1: Feature enables non-existent dependency
for entry in feat_values:
if not isinstance(entry, str):
continue
# Features can reference:
# - "dep:crate_name" (explicit dep activation)
# - "crate_name/feature" (enable feature on a dep)
# - "another_feature" (enable another feature)
if entry.startswith("dep:"):
dep_ref = entry[4:]
if dep_ref not in known_deps:
findings.append(Finding("F1", "error",
f"Feature '{feat_name}' enables non-existent dependency 'dep:{dep_ref}'",
f"No dependency named '{dep_ref}' found in any dependency section."))
elif "/" in entry:
dep_ref = entry.split("/")[0]
if dep_ref not in known_deps and dep_ref not in feature_names:
findings.append(Finding("F1", "error",
f"Feature '{feat_name}' references unknown dependency '{dep_ref}' in '{entry}'",
f"No dependency or feature named '{dep_ref}' found."))
else:
# Could be a feature name or an optional dep name
if entry not in feature_names and entry not in optional_deps and entry not in known_deps:
findings.append(Finding("F1", "error",
f"Feature '{feat_name}' references unknown item '{entry}'",
f"'{entry}' is not a known feature, dependency, or optional dependency."))
# F3: Circular feature dependencies
# Build adjacency graph and detect cycles
graph: dict[str, list[str]] = {}
for feat_name, feat_values in features.items():
if not isinstance(feat_values, list):
continue
deps_list: list[str] = []
for entry in feat_values:
if isinstance(entry, str) and not entry.startswith("dep:") and "/" not in entry:
# This could be a reference to another feature
if entry in feature_names:
deps_list.append(entry)
graph[feat_name] = deps_list
# DFS cycle detection
visited: set[str] = set()
rec_stack: set[str] = set()
cycles_found: set[str] = set()
def _dfs(node: str, path: list[str]) -> bool:
visited.add(node)
rec_stack.add(node)
for neighbor in graph.get(node, []):
if neighbor not in visited:
if _dfs(neighbor, path + [neighbor]):
return True
elif neighbor in rec_stack:
cycle_key = "->".join(path + [neighbor])
if cycle_key not in cycles_found:
cycles_found.add(cycle_key)
findings.append(Finding("F3", "error",
f"Circular feature dependency detected: {' -> '.join(path + [neighbor])}",
"Circular features cause compilation errors."))
return True
rec_stack.discard(node)
return False
for feat in graph:
if feat not in visited:
_dfs(feat, [feat])
return findings
def check_workspace(data: dict) -> list[Finding]:
"""W1-W3: Check workspace configuration."""
findings: list[Finding] = []
workspace = data.get("workspace")
has_package = "package" in data
if workspace is None:
return findings
if not isinstance(workspace, dict):
return findings
members = workspace.get("members", [])
# W1: Workspace with no members
if not members:
findings.append(Finding("W1", "warning",
"[workspace] has no members defined",
"Add members = [\"crate-a\", \"crate-b\"] to list workspace crates."))
# W2: Both [package] and [workspace] without workspace.members
if has_package and not members:
findings.append(Finding("W2", "info",
"Both [package] and [workspace] present but no workspace.members",
"This is a single-crate workspace. Add members if you intend a multi-crate workspace."))
# W3: Workspace dependencies hint
ws_deps = workspace.get("dependencies")
if ws_deps and isinstance(ws_deps, dict):
dep_names = ", ".join(sorted(list(ws_deps.keys())[:5]))
total = len(ws_deps)
suffix = f" (and {total - 5} more)" if total > 5 else ""
findings.append(Finding("W3", "info",
f"[workspace.dependencies] defines {total} shared dependencies: {dep_names}{suffix}",
"Member crates must use dep_name.workspace = true to inherit these. "
"Cannot verify from a single Cargo.toml alone."))
return findings
def check_best_practices(data: dict) -> list[Finding]:
"""B1-B4: Check best practices."""
findings: list[Finding] = []
pkg = data.get("package", {})
if not pkg:
return findings
# B1: Missing documentation link
metadata = pkg.get("metadata", {})
has_docs = "documentation" in pkg
has_docs_meta = isinstance(metadata, dict) and "docs" in metadata
if not has_docs and not has_docs_meta:
findings.append(Finding("B1", "info",
"Missing documentation link for published crates",
"Add documentation = \"https://docs.rs/your-crate\" to [package]."))
# B2: build.rs without [build-dependencies]
build_script = pkg.get("build")
has_build_deps = "build-dependencies" in data
if build_script is not None and not has_build_deps:
findings.append(Finding("B2", "info",
f"Build script declared (build = \"{build_script}\") but no [build-dependencies] section",
"If your build script uses external crates, declare them in [build-dependencies]."))
# B3: Large number of dependencies
main_deps = data.get("dependencies", {})
dep_count = len(main_deps) if isinstance(main_deps, dict) else 0
if dep_count > 30:
findings.append(Finding("B3", "info",
f"Large number of dependencies ({dep_count}) — potential bloat",
"Consider auditing dependencies for unused or redundant crates. "
"Use 'cargo udeps' to find unused dependencies."))
# B4: Missing repository/homepage URL
has_repo = "repository" in pkg
has_homepage = "homepage" in pkg
if not has_repo and not has_homepage:
findings.append(Finding("B4", "info",
"Missing repository and homepage URL",
"Add repository = \"https://github.com/...\" to [package] for crates.io visibility."))
return findings
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
def validate_all(data: dict) -> list[Finding]:
"""Run all checks and return combined findings."""
findings: list[Finding] = []
findings.extend(check_structure(data))
findings.extend(check_package_metadata(data))
findings.extend(check_dependencies(data))
findings.extend(check_features(data))
findings.extend(check_workspace(data))
findings.extend(check_best_practices(data))
return findings
# ---------------------------------------------------------------------------
# Rule explanations (for 'explain' command)
# ---------------------------------------------------------------------------
RULE_EXPLANATIONS: dict[str, dict[str, str]] = {
"S1": {
"name": "File Not Found",
"category": "Structure",
"severity": "error",
"description": "The Cargo.toml file does not exist or cannot be read.",
"fix": "Ensure the file path is correct and the file has read permissions.",
},
"S2": {
"name": "Empty File",
"category": "Structure",
"severity": "error",
"description": "The Cargo.toml file is empty (zero bytes or only whitespace).",
"fix": "Add at minimum a [package] section with name, version, and edition.",
},
"S3": {
"name": "Invalid TOML",
"category": "Structure",
"severity": "error",
"description": "The file contains invalid TOML syntax that cannot be parsed.",
"fix": "Fix the TOML syntax error reported in the message. Use a TOML linter.",
},
"S4": {
"name": "Missing [package]",
"category": "Structure",
"severity": "error/warning",
"description": "No [package] section found. Required for binary/library crates, optional for virtual workspaces.",
"fix": "Add [package] with name, version, and edition. Virtual workspaces use [workspace] only.",
},
"S5": {
"name": "Missing Required Fields",
"category": "Structure",
"severity": "error/warning",
"description": "Missing name, version (error), or edition (warning) in [package].",
"fix": "Add the missing fields. Example: name = \"my-crate\", version = \"0.1.0\", edition = \"2021\".",
},
"M1": {
"name": "Missing Edition",
"category": "Package Metadata",
"severity": "warning",
"description": "No edition field — Rust defaults to 2015, which is almost certainly wrong for new projects.",
"fix": "Add edition = \"2021\" or edition = \"2024\" to [package].",
},
"M2": {
"name": "Outdated Edition",
"category": "Package Metadata",
"severity": "info",
"description": "Using an old Rust edition (2015 or 2018) when 2021/2024 are available.",
"fix": "Run 'cargo fix --edition' to migrate, then update edition in Cargo.toml.",
},
"M3": {
"name": "Missing License",
"category": "Package Metadata",
"severity": "warning",
"description": "No license or license-file field. Required for crates.io publishing.",
"fix": "Add license = \"MIT\" (or your license) or license-file = \"LICENSE\".",
},
"M4": {
"name": "Missing Description",
"category": "Package Metadata",
"severity": "warning",
"description": "No description field. Required for crates.io publishing.",
"fix": "Add description = \"A short description of your crate.\" to [package].",
},
"D1": {
"name": "Wildcard Version",
"category": "Dependencies",
"severity": "error",
"description": "Using '*' as a version — allows any version, including breaking changes.",
"fix": "Pin to a version range: serde = \"1\" or serde = \"^1.0\".",
},
"D2": {
"name": "Unpinned Dependency",
"category": "Dependencies",
"severity": "warning",
"description": "Dependency declared as a table but has no version, git, path, or workspace source.",
"fix": "Add version = \"x.y\" or use workspace = true to inherit from workspace.",
},
"D3": {
"name": "Unpinned Git Dependency",
"category": "Dependencies",
"severity": "warning",
"description": "Git dependency without rev, tag, or branch — tracks default branch HEAD.",
"fix": "Add rev = \"abc123\", tag = \"v1.0\", or branch = \"main\" for reproducibility.",
},
"D4": {
"name": "Path Dependency",
"category": "Dependencies",
"severity": "info",
"description": "Using a path dependency. Works locally but blocks crates.io publishing.",
"fix": "For publishing, add a version field alongside path for fallback.",
},
"D5": {
"name": "Duplicate Dependency Versions",
"category": "Dependencies",
"severity": "warning",
"description": "Same crate in [dependencies] and [dev-dependencies] with different version specs.",
"fix": "Align the version specs or use features to differentiate test vs runtime needs.",
},
"D6": {
"name": "Deprecated Crate",
"category": "Dependencies",
"severity": "info",
"description": "Using a crate that is deprecated or has a well-known successor.",
"fix": "Migrate to the suggested replacement crate.",
},
"F1": {
"name": "Non-existent Feature Dependency",
"category": "Features",
"severity": "error",
"description": "A feature references a dependency or feature that doesn't exist.",
"fix": "Add the missing dependency or correct the feature reference.",
},
"F2": {
"name": "Empty Feature",
"category": "Features",
"severity": "warning",
"description": "A feature is defined with no entries — it enables nothing.",
"fix": "Add dependency or feature entries, or remove the empty feature.",
},
"F3": {
"name": "Circular Feature",
"category": "Features",
"severity": "error",
"description": "Features reference each other in a cycle, which causes compilation errors.",
"fix": "Break the cycle by restructuring feature dependencies.",
},
"W1": {
"name": "Empty Workspace",
"category": "Workspace",
"severity": "warning",
"description": "[workspace] section exists but has no members listed.",
"fix": "Add members = [\"crate-a\", \"crate-b\"] to [workspace].",
},
"W2": {
"name": "Ambiguous Workspace",
"category": "Workspace",
"severity": "info",
"description": "Both [package] and [workspace] present without workspace.members.",
"fix": "Add members if multi-crate workspace, or remove [workspace] if single crate.",
},
"W3": {
"name": "Workspace Dependencies Hint",
"category": "Workspace",
"severity": "info",
"description": "[workspace.dependencies] found — member crates must use workspace = true to inherit.",
"fix": "In member Cargo.toml files, use: dep_name = { workspace = true }.",
},
"B1": {
"name": "Missing Docs Link",
"category": "Best Practices",
"severity": "info",
"description": "No documentation URL for published crates.",
"fix": "Add documentation = \"https://docs.rs/your-crate\" to [package].",
},
"B2": {
"name": "Build Script Without Build-Dependencies",
"category": "Best Practices",
"severity": "info",
"description": "A build script is declared but no [build-dependencies] section exists.",
"fix": "If the build script uses external crates, declare them in [build-dependencies].",
},
"B3": {
"name": "Dependency Bloat",
"category": "Best Practices",
"severity": "info",
"description": "More than 30 dependencies — may indicate bloat.",
"fix": "Audit with 'cargo udeps' for unused deps. Consider feature flags for optional deps.",
},
"B4": {
"name": "Missing Repository URL",
"category": "Best Practices",
"severity": "info",
"description": "No repository or homepage URL in [package].",
"fix": "Add repository = \"https://github.com/user/repo\" to [package].",
},
}
# ---------------------------------------------------------------------------
# Suggestion engine (for 'suggest' command)
# ---------------------------------------------------------------------------
def generate_suggestions(data: dict, findings: list[Finding]) -> list[dict]:
"""Generate actionable fix suggestions from findings."""
suggestions: list[dict] = []
for f in findings:
rule = RULE_EXPLANATIONS.get(f.rule_id)
if not rule:
continue
suggestion = {
"rule_id": f.rule_id,
"severity": f.severity,
"problem": f.message,
"fix": rule["fix"],
}
# Add concrete TOML snippets for common fixes
pkg = data.get("package", {})
pkg_name = pkg.get("name", "my-crate")
if f.rule_id == "S5" and "name" in f.message:
suggestion["snippet"] = '[package]\nname = "my-crate"'
elif f.rule_id == "S5" and "version" in f.message:
suggestion["snippet"] = 'version = "0.1.0"'
elif f.rule_id == "S5" and "edition" in f.message:
suggestion["snippet"] = 'edition = "2021"'
elif f.rule_id == "M1":
suggestion["snippet"] = 'edition = "2021"'
elif f.rule_id == "M3":
suggestion["snippet"] = 'license = "MIT"'
elif f.rule_id == "M4":
suggestion["snippet"] = f'description = "A Rust crate for ..."'
elif f.rule_id == "B4":
suggestion["snippet"] = f'repository = "https://github.com/user/{pkg_name}"'
elif f.rule_id == "D1":
# Extract dep name from message (second quoted string — first is '*')
dep_matches = re.findall(r"'([^']+)'", f.message)
if len(dep_matches) >= 2:
dep = dep_matches[1]
suggestion["snippet"] = f'{dep} = "1" # pin to a version'
suggestions.append(suggestion)
return suggestions
# ---------------------------------------------------------------------------
# Summary helper
# ---------------------------------------------------------------------------
def _summary_counts(findings: list[Finding]) -> dict:
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
infos = sum(1 for f in findings if f.severity == "info")
return {"errors": errors, "warnings": warnings, "infos": infos, "total": len(findings)}
def _summary_text(findings: list[Finding]) -> str:
c = _summary_counts(findings)
parts = []
if c["errors"]:
parts.append(f"{c['errors']} error(s)")
if c["warnings"]:
parts.append(f"{c['warnings']} warning(s)")
if c["infos"]:
parts.append(f"{c['infos']} info")
return ", ".join(parts) if parts else "No issues found"
# ---------------------------------------------------------------------------
# Command handlers
# ---------------------------------------------------------------------------
def cmd_validate(data: dict, path: str) -> dict:
"""Full validation with summary."""
findings = validate_all(data)
errors = [f for f in findings if f.severity == "error"]
return {
"command": "validate",
"file": path,
"valid": len(errors) == 0,
"findings": [f.to_dict() for f in findings],
"counts": _summary_counts(findings),
"summary": _summary_text(findings),
}
def cmd_check(data: dict, path: str) -> dict:
"""Quick check — errors and warnings only."""
findings = validate_all(data)
filtered = [f for f in findings if f.severity in ("error", "warning")]
return {
"command": "check",
"file": path,
"passed": all(f.severity != "error" for f in findings),
"findings": [f.to_dict() for f in filtered],
"counts": _summary_counts(filtered),
"summary": _summary_text(filtered),
}
def cmd_explain(data: dict | None, path: str) -> dict:
"""Explain all rules with their categories and severity."""
rules = []
for rule_id in sorted(RULE_EXPLANATIONS.keys()):
info = RULE_EXPLANATIONS[rule_id]
rules.append({
"rule_id": rule_id,
"name": info["name"],
"category": info["category"],
"severity": info["severity"],
"description": info["description"],
"fix": info["fix"],
})
return {
"command": "explain",
"file": path,
"rules": rules,
"total_rules": len(rules),
}
def cmd_suggest(data: dict, path: str) -> dict:
"""Run validation and generate fix suggestions."""
findings = validate_all(data)
suggestions = generate_suggestions(data, findings)
return {
"command": "suggest",
"file": path,
"suggestions": suggestions,
"total": len(suggestions),
"summary": _summary_text(findings),
}
# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------
def format_text(result: dict) -> str:
cmd = result.get("command", "")
path = result.get("file", "")
lines = []
title = f"Cargo.toml {cmd} — {path}"
lines.append(title)
lines.append("=" * len(title))
if cmd == "explain":
for rule in result.get("rules", []):
lines.append("")
lines.append(f" {rule['rule_id']}: {rule['name']} [{rule['category']}] ({rule['severity']})")
lines.append(f" {rule['description']}")
lines.append(f" Fix: {rule['fix']}")
lines.append("")
lines.append(f"Total rules: {result.get('total_rules', 0)}")
return "\n".join(lines)
if cmd == "suggest":
suggestions = result.get("suggestions", [])
if not suggestions:
lines.append("[OK] No suggestions — Cargo.toml looks good")
else:
for s in suggestions:
sev = s["severity"].upper().ljust(7)
lines.append(f"[{sev}] {s['rule_id']}: {s['problem']}")
lines.append(f" Fix: {s['fix']}")
if "snippet" in s:
lines.append(f" Add: {s['snippet']}")
lines.append("")
lines.append(f"Summary: {result.get('summary', '')}")
return "\n".join(lines)
# validate / check
findings = result.get("findings", [])
if not findings:
lines.append("[OK] No issues found")
else:
for f in findings:
sev = f["severity"].upper().ljust(7)
lines.append(f"[{sev}] {f['rule_id']}: {f['message']}")
if f.get("detail"):
lines.append(f" {f['detail']}")
if cmd == "validate":
valid_str = "VALID" if result.get("valid") else "INVALID"
lines.append("")
lines.append(f"Result: {valid_str}")
if cmd == "check":
passed_str = "PASSED" if result.get("passed") else "FAILED"
lines.append("")
lines.append(f"Result: {passed_str}")
summary = result.get("summary")
if summary:
lines.append(f"Summary: {summary}")
return "\n".join(lines)
def format_json(result: dict) -> str:
return json.dumps(result, indent=2)
def format_summary(result: dict) -> str:
cmd = result.get("command", "")
path = result.get("file", "")
lines = []
lines.append(f"Cargo.toml {cmd}: {path}")
if cmd == "explain":
lines.append(f"Rules: {result.get('total_rules', 0)}")
categories: dict[str, int] = {}
for rule in result.get("rules", []):
cat = rule["category"]
categories[cat] = categories.get(cat, 0) + 1
for cat, count in sorted(categories.items()):
lines.append(f" {cat}: {count} rules")
return "\n".join(lines)
counts = result.get("counts", {})
lines.append(f"Errors: {counts.get('errors', 0)}")
lines.append(f"Warnings: {counts.get('warnings', 0)}")
lines.append(f"Info: {counts.get('infos', 0)}")
if "valid" in result:
lines.append(f"Valid: {'yes' if result['valid'] else 'no'}")
if "passed" in result:
lines.append(f"Passed: {'yes' if result['passed'] else 'no'}")
if cmd == "suggest":
lines.append(f"Suggestions: {result.get('total', 0)}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Validate Rust Cargo.toml manifests",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Commands:
validate Full validation with all rules
check Quick check (errors and warnings only)
explain Show all rules with descriptions
suggest Run validation and propose fixes
Examples:
python3 cargo_toml_validator.py validate Cargo.toml
python3 cargo_toml_validator.py validate Cargo.toml --strict
python3 cargo_toml_validator.py check Cargo.toml --format json
python3 cargo_toml_validator.py explain Cargo.toml
python3 cargo_toml_validator.py suggest Cargo.toml --format summary
"""
)
parser.add_argument("command", choices=["validate", "check", "explain", "suggest"],
help="Command to run")
parser.add_argument("file", help="Path to Cargo.toml")
parser.add_argument("--strict", action="store_true",
help="Treat warnings as errors (CI mode)")
parser.add_argument("--format", choices=["text", "json", "summary"], default="text",
help="Output format (default: text)")
args = parser.parse_args()
# For 'explain', we don't need a valid file (but accept the arg for consistency)
if args.command == "explain":
result = cmd_explain(None, args.file)
else:
# Load and parse file
data, parse_error = load_config(args.file)
if parse_error:
result = {
"command": args.command,
"file": args.file,
"findings": [parse_error.to_dict()],
"counts": {"errors": 1, "warnings": 0, "infos": 0, "total": 1},
"summary": "1 error(s)",
}
if args.command == "validate":
result["valid"] = False
elif args.command == "check":
result["passed"] = False
elif args.command == "suggest":
result["suggestions"] = []
result["total"] = 0
formatter = {"text": format_text, "json": format_json, "summary": format_summary}
print(formatter[args.format](result))
sys.exit(2)
# Run command
if args.command == "validate":
result = cmd_validate(data, args.file)
elif args.command == "check":
result = cmd_check(data, args.file)
elif args.command == "suggest":
result = cmd_suggest(data, args.file)
# Format output
formatter = {"text": format_text, "json": format_json, "summary": format_summary}
print(formatter[args.format](result))
# Exit code
if args.command == "explain":
sys.exit(0)
findings = result.get("findings", [])
has_errors = any(f["severity"] == "error" for f in findings)
has_warnings = any(f["severity"] == "warning" for f in findings)
if has_errors:
sys.exit(1)
if args.strict and has_warnings:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
Validate PostCSS config files (.postcssrc, postcss.config.js, package.json#postcss) for plugin ordering, deprecated plugins, Tailwind integration, and best p...
---
name: postcss-config-validator
description: Validate PostCSS config files (.postcssrc, postcss.config.js, package.json#postcss) for plugin ordering, deprecated plugins, Tailwind integration, and best practices. Use when validating CSS processing configs, auditing PostCSS setups, or linting frontend build configuration.
---
# PostCSS Config Validator
Validate `.postcssrc`, `.postcssrc.json`, `postcss.config.js`, `postcss.config.ts`, and `package.json#postcss` for deprecated plugins, ordering issues, Tailwind integration problems, parser misconfiguration, and best practices. JS/TS configs are detected but cannot be statically validated. Supports text, JSON, and summary output formats with CI-friendly exit codes.
## Commands
```bash
# Full validation (all 22+ rules)
python3 scripts/postcss_config_validator.py validate .postcssrc
# Quick structure-only check
python3 scripts/postcss_config_validator.py check .postcssrc.json
# Explain config in human-readable form
python3 scripts/postcss_config_validator.py explain package.json
# Suggest improvements
python3 scripts/postcss_config_validator.py suggest .postcssrc
# JSON output (CI-friendly)
python3 scripts/postcss_config_validator.py validate .postcssrc --format json
# Summary only (pass/fail + counts)
python3 scripts/postcss_config_validator.py validate .postcssrc --format summary
# Strict mode (warnings and infos become errors)
python3 scripts/postcss_config_validator.py validate .postcssrc --strict
```
## Rules (22+)
| # | ID | Category | Severity | Rule |
|---|-----|----------|----------|------|
| 1 | S1 | Structure | Error | File not found or unreadable |
| 2 | S2 | Structure | Error | Empty config file |
| 3 | S3 | Structure | Error | Invalid JSON syntax |
| 4 | S4 | Structure | Warning | Unknown top-level keys (valid: plugins, parser, syntax, stringifier, map, from, to) |
| 5 | S5 | Structure | Info | JS/TS config detected — cannot statically validate |
| 6 | P1 | Plugins | Warning | Empty plugins object/array |
| 7 | P2 | Plugins | Warning | Deprecated plugin (autoprefixer-core, postcss-cssnext, lost, postcss-sprites) |
| 8 | P3 | Plugins | Warning | Duplicate plugins |
| 9 | P4 | Plugins | Info | Plugin ordering issues (autoprefixer after preset-env, cssnano last) |
| 10 | P5 | Plugins | Info | postcss-import should be first plugin |
| 11 | P6 | Plugins | Info | Unknown/uncommon plugin name (not in top 50 list) |
| 12 | T1 | Tailwind | Info | tailwindcss without nesting plugin |
| 13 | T2 | Tailwind | Warning | tailwindcss after autoprefixer (wrong order) |
| 14 | T3 | Tailwind | Info | postcss-preset-env with tailwindcss (potential conflict) |
| 15 | X1 | Syntax/Parser | Warning | Both parser and syntax specified |
| 16 | X2 | Syntax/Parser | Info | Unknown parser value |
| 17 | X3 | Syntax/Parser | Info | Parser set but no matching preprocessor plugin |
| 18 | M1 | Source Maps | Info | Source maps disabled (map: false) |
| 19 | M2 | Source Maps | Info | Inline source maps enabled (map.inline: true) |
| 20 | B1 | Best Practices | Warning | No plugins configured |
| 21 | B2 | Best Practices | Info | Using postcss-preset-env AND individual feature plugins it includes |
| 22 | B3 | Best Practices | Info | Very large number of plugins (>15) |
## Output Formats
- `text` (default): Human-readable with severity icons
- `json`: Machine-parseable JSON with findings array and summary
- `summary`: Pass/fail with error/warning/info counts
## Exit Codes
- `0`: No errors (warnings/infos only or clean)
- `1`: One or more errors found
- `2`: File not found or invalid input
## Requirements
- Python 3.8+
- No external dependencies (pure stdlib)
FILE:scripts/postcss_config_validator.py
#!/usr/bin/env python3
"""PostCSS Config Validator — validate .postcssrc, .postcssrc.json, package.json#postcss, and detect JS/TS configs."""
import json
import os
import sys
from pathlib import Path
# --- Constants ---
VALID_TOP_KEYS = {"plugins", "parser", "syntax", "stringifier", "map", "from", "to"}
JS_TS_EXTENSIONS = {".js", ".cjs", ".mjs", ".ts"}
JS_TS_CONFIG_NAMES = {
"postcss.config.js", "postcss.config.cjs", "postcss.config.mjs", "postcss.config.ts",
}
JSON_CONFIG_NAMES = {".postcssrc", ".postcssrc.json"}
ALL_CONFIG_NAMES = JS_TS_CONFIG_NAMES | JSON_CONFIG_NAMES
DEPRECATED_PLUGINS = {
"autoprefixer-core": "Renamed to 'autoprefixer' since v6",
"postcss-cssnext": "Replaced by 'postcss-preset-env'",
"lost": "Unmaintained — consider 'postcss-grid' or CSS Grid",
"postcss-sprites": "Deprecated — use CSS image-set() or a bundler sprite plugin",
"cssnext": "Renamed to 'postcss-cssnext', then replaced by 'postcss-preset-env'",
}
KNOWN_PARSERS = {
"postcss-scss", "postcss-less", "postcss-html", "sugarss", "postcss-styl",
}
# Top ~50 well-known PostCSS plugins for P6 info check
TOP_PLUGINS = {
"autoprefixer", "postcss-preset-env", "postcss-import", "postcss-nested",
"postcss-nesting", "postcss-custom-properties", "postcss-custom-media",
"postcss-mixins", "postcss-simple-vars", "postcss-extend", "postcss-extend-rule",
"postcss-url", "postcss-assets", "postcss-modules", "postcss-color-function",
"postcss-color-mod-function", "postcss-calc", "postcss-flexbugs-fixes",
"postcss-normalize", "postcss-reporter", "postcss-browser-reporter",
"postcss-sorting", "postcss-utilities", "postcss-font-magician",
"postcss-pxtorem", "postcss-px-to-viewport", "postcss-rem",
"postcss-responsive-type", "postcss-write-svg", "postcss-svgo",
"postcss-inline-svg", "postcss-logical", "postcss-dir-pseudo-class",
"postcss-gap-properties", "postcss-overflow-shorthand", "postcss-place",
"cssnano", "postcss-clean", "postcss-discard-comments", "postcss-discard-duplicates",
"postcss-merge-rules", "postcss-minify-selectors", "postcss-normalize-url",
"tailwindcss", "@tailwindcss/nesting", "postcss-scss", "postcss-less",
"postcss-html", "sugarss", "postcss-styl", "postcss-focus-visible",
"postcss-focus-within",
}
# Features included in postcss-preset-env that people sometimes add separately
PRESET_ENV_INCLUDED_PLUGINS = {
"postcss-custom-properties", "postcss-custom-media", "postcss-nesting",
"postcss-color-function", "postcss-color-mod-function", "postcss-logical",
"postcss-dir-pseudo-class", "postcss-gap-properties", "postcss-overflow-shorthand",
"postcss-place", "postcss-focus-visible", "postcss-focus-within",
"postcss-custom-selectors", "postcss-media-minmax", "postcss-lab-function",
"postcss-color-functional-notation",
}
class Finding:
def __init__(self, rule_id, severity, message, detail=None):
self.rule_id = rule_id
self.severity = severity
self.message = message
self.detail = detail
def to_dict(self):
d = {"rule": self.rule_id, "severity": self.severity, "message": self.message}
if self.detail:
d["detail"] = self.detail
return d
def to_text(self):
if self.severity == "error":
icon = "❌"
elif self.severity == "warning":
icon = "⚠️"
else:
icon = "ℹ️"
s = f"{icon} [{self.rule_id}] {self.message}"
if self.detail:
s += f"\n → {self.detail}"
return s
# --- Config loading ---
def detect_config_type(filepath):
"""Return 'js_ts', 'json', or 'package_json'."""
p = Path(filepath)
if p.name in JS_TS_CONFIG_NAMES:
return "js_ts"
if p.name == "package.json":
return "package_json"
# .postcssrc, .postcssrc.json, or anything else — treat as JSON
return "json"
def load_config(filepath):
"""Load and parse a PostCSS config. Returns (config_dict, error_string, config_type)."""
p = Path(filepath)
config_type = detect_config_type(filepath)
if not p.exists():
return None, f"File not found: {filepath}", config_type
# JS/TS detection before reading content — these can't be statically validated regardless
if config_type == "js_ts":
return None, "JS_TS_DETECTED", config_type
try:
text = p.read_text(encoding="utf-8").strip()
except Exception as e:
return None, f"Cannot read file: {e}", config_type
if not text:
return None, "File is empty", config_type
if config_type == "package_json":
try:
pkg = json.loads(text)
except json.JSONDecodeError as e:
return None, f"Invalid JSON syntax: {e}", config_type
if "postcss" not in pkg:
return None, "No 'postcss' key in package.json", config_type
postcss = pkg["postcss"]
if not isinstance(postcss, dict):
return None, "package.json#postcss must be an object", config_type
return postcss, None, config_type
# JSON config (.postcssrc, .postcssrc.json)
try:
config = json.loads(text)
except json.JSONDecodeError as e:
return None, f"Invalid JSON syntax: {e}", config_type
if not isinstance(config, dict):
return None, "PostCSS config must be a JSON object", config_type
return config, None, config_type
def find_sibling_configs(filepath):
"""Find other PostCSS config files in the same directory."""
directory = Path(filepath).parent
siblings = []
all_names = list(ALL_CONFIG_NAMES)
for name in all_names:
p = directory / name
if p.exists() and str(p.resolve()) != str(Path(filepath).resolve()):
siblings.append(name)
# Check package.json#postcss
pkg = directory / "package.json"
if pkg.exists() and Path(filepath).name != "package.json":
try:
d = json.loads(pkg.read_text(encoding="utf-8"))
if "postcss" in d:
siblings.append("package.json#postcss")
except Exception:
pass
return siblings
def get_plugin_name(entry):
"""Extract plugin name from various plugin formats: string, [name, opts], key in dict."""
if isinstance(entry, str):
return entry
if isinstance(entry, list) and len(entry) > 0:
return str(entry[0])
return ""
def get_plugins_list(config):
"""Extract a normalized list of plugin names from config.plugins (object or array)."""
plugins = config.get("plugins")
if plugins is None:
return []
if isinstance(plugins, dict):
return list(plugins.keys())
if isinstance(plugins, list):
return [get_plugin_name(p) for p in plugins]
return []
def get_plugins_ordered(config):
"""Get ordered list of (index, name) for ordering checks."""
plugins = config.get("plugins")
if plugins is None:
return []
if isinstance(plugins, dict):
return list(enumerate(plugins.keys()))
if isinstance(plugins, list):
return [(i, get_plugin_name(p)) for i, p in enumerate(plugins)]
return []
# --- Rule checks ---
def check_structure(config, filepath, config_type):
"""Rules S4: unknown top-level keys."""
findings = []
unknown = set(config.keys()) - VALID_TOP_KEYS
if unknown:
findings.append(Finding("S4", "warning",
f"Unknown top-level keys: {', '.join(sorted(unknown))}",
f"Valid keys: {', '.join(sorted(VALID_TOP_KEYS))}"))
return findings
def check_plugins(config):
"""Rules P1-P6: plugin issues."""
findings = []
plugins_raw = config.get("plugins")
plugin_names = get_plugins_list(config)
# P1: empty plugins
if plugins_raw is not None:
is_empty = False
if isinstance(plugins_raw, dict) and len(plugins_raw) == 0:
is_empty = True
elif isinstance(plugins_raw, list) and len(plugins_raw) == 0:
is_empty = True
if is_empty:
findings.append(Finding("P1", "warning",
"Empty plugins object/array",
"Plugins section exists but contains no plugins"))
# P2: deprecated plugins
for pn in plugin_names:
if pn in DEPRECATED_PLUGINS:
findings.append(Finding("P2", "warning",
f"Deprecated plugin: {pn}",
DEPRECATED_PLUGINS[pn]))
# P3: duplicate plugins
seen = {}
for pn in plugin_names:
if pn:
if pn in seen:
findings.append(Finding("P3", "warning",
f"Duplicate plugin: {pn}"))
seen[pn] = True
# Ordering checks (P4, P5) — need indexed list
ordered = get_plugins_ordered(config)
name_index = {name: idx for idx, name in ordered}
# P4: autoprefixer should be after postcss-preset-env
if "autoprefixer" in name_index and "postcss-preset-env" in name_index:
if name_index["autoprefixer"] < name_index["postcss-preset-env"]:
findings.append(Finding("P4", "info",
"autoprefixer is before postcss-preset-env",
"autoprefixer should run after postcss-preset-env for best results"))
# P4 also: cssnano should be last
if "cssnano" in name_index:
cssnano_idx = name_index["cssnano"]
max_idx = max(idx for idx, _ in ordered) if ordered else 0
if cssnano_idx < max_idx:
findings.append(Finding("P4", "info",
"cssnano is not the last plugin",
"cssnano (minifier) should be the last plugin in the chain"))
# P5: postcss-import should be first
if "postcss-import" in name_index:
if name_index["postcss-import"] != 0:
findings.append(Finding("P5", "info",
"postcss-import is not the first plugin",
"postcss-import must be first so that @import statements are resolved before other transforms"))
# P6: unknown/uncommon plugin names
for pn in plugin_names:
if pn and pn not in TOP_PLUGINS and pn not in DEPRECATED_PLUGINS:
# Only flag if it looks like a real package name (not a local path)
if not pn.startswith("./") and not pn.startswith("../") and not pn.startswith("/"):
findings.append(Finding("P6", "info",
f"Uncommon plugin: {pn}",
"Not in top 50 PostCSS plugins list — verify the name is correct"))
return findings
def check_tailwind(config):
"""Rules T1-T3: Tailwind integration."""
findings = []
plugin_names = set(get_plugins_list(config))
if "tailwindcss" not in plugin_names:
return findings
# T1: tailwindcss without nesting plugin
has_nesting = ("@tailwindcss/nesting" in plugin_names or
"postcss-nesting" in plugin_names or
"postcss-nested" in plugin_names)
if not has_nesting:
findings.append(Finding("T1", "info",
"tailwindcss without a nesting plugin",
"If you use CSS nesting, add @tailwindcss/nesting or postcss-nesting before tailwindcss"))
# T2: tailwindcss after autoprefixer (wrong order)
ordered = get_plugins_ordered(config)
name_index = {name: idx for idx, name in ordered}
if "autoprefixer" in name_index and "tailwindcss" in name_index:
if name_index["tailwindcss"] > name_index["autoprefixer"]:
findings.append(Finding("T2", "warning",
"tailwindcss is after autoprefixer",
"tailwindcss should come before autoprefixer in the plugin chain"))
# T3: postcss-preset-env with tailwindcss
if "postcss-preset-env" in plugin_names:
findings.append(Finding("T3", "info",
"postcss-preset-env used alongside tailwindcss",
"Tailwind handles most modern CSS features; postcss-preset-env may conflict or be redundant"))
return findings
def check_syntax_parser(config):
"""Rules X1-X3: parser/syntax issues."""
findings = []
parser = config.get("parser")
syntax = config.get("syntax")
# X1: both parser and syntax
if parser and syntax:
findings.append(Finding("X1", "warning",
"Both 'parser' and 'syntax' are set",
"Only one should be specified — 'syntax' sets both parser and stringifier, 'parser' sets only the parser"))
# X2: unknown parser
if parser and isinstance(parser, str) and parser not in KNOWN_PARSERS:
findings.append(Finding("X2", "info",
f"Unknown parser: {parser}",
f"Known parsers: {', '.join(sorted(KNOWN_PARSERS))}"))
# X3: parser set but no matching preprocessor plugin
if parser and isinstance(parser, str):
plugin_names = set(get_plugins_list(config))
preprocessor_indicators = {
"postcss-scss": {"postcss-scss", "@csstools/postcss-sass"},
"postcss-less": {"postcss-less", "less"},
"postcss-html": {"postcss-html"},
"sugarss": {"sugarss"},
"postcss-styl": {"postcss-styl"},
}
expected = preprocessor_indicators.get(parser, set())
# If parser is known and there are expected companion plugins, check
if expected and not (expected & plugin_names):
findings.append(Finding("X3", "info",
f"Parser '{parser}' set but no related preprocessor plugin found",
"This may be intentional, but verify the parser matches your file types"))
return findings
def check_source_maps(config):
"""Rules M1-M2: source map settings."""
findings = []
map_val = config.get("map")
if map_val is False:
findings.append(Finding("M1", "info",
"Source maps disabled (map: false)",
"Consider enabling source maps in development for easier debugging"))
if isinstance(map_val, dict):
if map_val.get("inline") is True:
findings.append(Finding("M2", "info",
"Inline source maps enabled (map.inline: true)",
"Inline source maps increase file size — consider external maps for production"))
return findings
def check_best_practices(config):
"""Rules B1-B3: best practices."""
findings = []
plugins_raw = config.get("plugins")
plugin_names = get_plugins_list(config)
# B1: no plugins configured at all
if plugins_raw is None:
findings.append(Finding("B1", "warning",
"No 'plugins' key in config",
"A PostCSS config without plugins does nothing — add at least autoprefixer"))
# B2: postcss-preset-env AND individual feature plugins it includes
if "postcss-preset-env" in plugin_names:
redundant = [pn for pn in plugin_names if pn in PRESET_ENV_INCLUDED_PLUGINS]
if redundant:
findings.append(Finding("B2", "info",
f"Plugins redundant with postcss-preset-env: {', '.join(redundant)}",
"postcss-preset-env already includes these features"))
# B3: very large number of plugins
if len(plugin_names) > 15:
findings.append(Finding("B3", "info",
f"Large number of plugins ({len(plugin_names)})",
"More than 15 plugins may impact build performance — consider consolidating"))
return findings
# --- Orchestrators ---
def validate_all(config, filepath, config_type):
"""Run all checks and return combined findings."""
findings = []
findings.extend(check_structure(config, filepath, config_type))
findings.extend(check_plugins(config))
findings.extend(check_tailwind(config))
findings.extend(check_syntax_parser(config))
findings.extend(check_source_maps(config))
findings.extend(check_best_practices(config))
return findings
def check_structure_only(config, filepath, config_type):
"""Run structure checks only (for 'check' command)."""
return check_structure(config, filepath, config_type)
# --- Output formatting ---
def format_text(findings, filepath):
if not findings:
return f"✅ {filepath}: No issues found"
lines = [f"\U0001f4cb {filepath}: {len(findings)} issue(s) found\n"]
for f in findings:
lines.append(f.to_text())
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
infos = sum(1 for f in findings if f.severity == "info")
if errors:
icon = "❌"
elif warnings:
icon = "⚠️"
else:
icon = "ℹ️"
lines.append(f"\n{icon} {errors} error(s), {warnings} warning(s), {infos} info(s)")
return "\n".join(lines)
def format_json(findings, filepath):
return json.dumps({
"file": filepath,
"findings": [f.to_dict() for f in findings],
"summary": {
"errors": sum(1 for f in findings if f.severity == "error"),
"warnings": sum(1 for f in findings if f.severity == "warning"),
"infos": sum(1 for f in findings if f.severity == "info"),
"total": len(findings),
}
}, indent=2)
def format_summary(findings, filepath):
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
infos = sum(1 for f in findings if f.severity == "info")
status = "FAIL" if errors else ("WARN" if warnings else "PASS")
return f"{status} | {filepath} | {errors} errors, {warnings} warnings, {infos} infos"
def format_output(findings, filepath, fmt):
if fmt == "json":
return format_json(findings, filepath)
elif fmt == "summary":
return format_summary(findings, filepath)
else:
return format_text(findings, filepath)
# --- explain / suggest commands ---
def explain_config(config, filepath, config_type):
lines = [f"\U0001f4d6 PostCSS Config Explanation: {filepath}\n"]
if config_type == "package_json":
lines.append("Source: package.json#postcss\n")
parser = config.get("parser")
if parser:
lines.append(f"Parser: {parser}")
if parser in KNOWN_PARSERS:
parser_desc = {
"postcss-scss": "SCSS syntax (allows SCSS-style comments, variables, etc.)",
"postcss-less": "Less syntax support",
"postcss-html": "Parse CSS inside HTML/Vue/Svelte files",
"sugarss": "Indent-based CSS syntax (like Sass without braces)",
"postcss-styl": "Stylus syntax support",
}
lines.append(f" {parser_desc.get(parser, '')}")
syntax = config.get("syntax")
if syntax:
lines.append(f"Syntax: {syntax} (sets both parser and stringifier)")
stringifier = config.get("stringifier")
if stringifier:
lines.append(f"Stringifier: {stringifier}")
map_val = config.get("map")
if map_val is not None:
if map_val is False:
lines.append("\nSource maps: Disabled")
elif map_val is True:
lines.append("\nSource maps: Enabled (default external)")
elif isinstance(map_val, dict):
inline = "inline" if map_val.get("inline") else "external"
lines.append(f"\nSource maps: Enabled ({inline})")
plugin_names = get_plugins_list(config)
if plugin_names:
lines.append(f"\nPlugins ({len(plugin_names)}, applied in order):")
for pn in plugin_names:
desc = ""
if pn == "autoprefixer":
desc = " - adds vendor prefixes"
elif pn == "postcss-preset-env":
desc = " - modern CSS features with polyfills"
elif pn == "postcss-import":
desc = " - resolves @import statements"
elif pn == "postcss-nested" or pn == "postcss-nesting":
desc = " - CSS nesting support"
elif pn == "cssnano":
desc = " - CSS minification"
elif pn == "tailwindcss":
desc = " - Tailwind CSS utility framework"
elif pn == "@tailwindcss/nesting":
desc = " - Tailwind-compatible nesting"
elif pn == "postcss-mixins":
desc = " - Sass-like mixins"
elif pn == "postcss-simple-vars":
desc = " - Sass-like variables"
lines.append(f" {pn}{desc}")
else:
lines.append("\nNo plugins configured.")
from_val = config.get("from")
to_val = config.get("to")
if from_val:
lines.append(f"\nInput: {from_val}")
if to_val:
lines.append(f"Output: {to_val}")
return "\n".join(lines)
def suggest_improvements(config, filepath):
lines = [f"\U0001f4a1 Suggestions for {filepath}\n"]
suggestions = []
plugin_names = set(get_plugins_list(config))
# No plugins at all
if not plugin_names:
suggestions.append("Add plugins to your PostCSS config — without them it does nothing. Start with 'autoprefixer'.")
# Missing autoprefixer
if plugin_names and "autoprefixer" not in plugin_names:
suggestions.append("Add 'autoprefixer' — it's the most common PostCSS plugin and handles vendor prefixes automatically")
# Using deprecated plugins
for pn in plugin_names:
if pn in DEPRECATED_PLUGINS:
suggestions.append(f"Replace '{pn}': {DEPRECATED_PLUGINS[pn]}")
# postcss-cssnext -> postcss-preset-env
if "postcss-cssnext" in plugin_names and "postcss-preset-env" not in plugin_names:
suggestions.append("Replace 'postcss-cssnext' with 'postcss-preset-env' (actively maintained successor)")
# Consider postcss-preset-env
individual_features = plugin_names & PRESET_ENV_INCLUDED_PLUGINS
if individual_features and "postcss-preset-env" not in plugin_names:
suggestions.append(
f"Consider using 'postcss-preset-env' instead of individual plugins: {', '.join(sorted(individual_features))}")
# Tailwind without nesting
if "tailwindcss" in plugin_names:
has_nesting = ("@tailwindcss/nesting" in plugin_names or
"postcss-nesting" in plugin_names)
if not has_nesting:
suggestions.append("Add '@tailwindcss/nesting' before 'tailwindcss' if you use CSS nesting")
# postcss-import missing when using other plugins
if plugin_names and "postcss-import" not in plugin_names and len(plugin_names) > 2:
suggestions.append("Consider adding 'postcss-import' to resolve @import statements before other transforms")
# Source maps not configured
if "map" not in config:
suggestions.append("Consider setting 'map' for source map generation (helps debugging)")
if not suggestions:
lines.append("No suggestions — config looks good!")
else:
for s in suggestions:
lines.append(f" • {s}")
return "\n".join(lines)
# --- CLI ---
def main():
if len(sys.argv) < 3:
print("Usage: postcss_config_validator.py <command> <file> [--format text|json|summary] [--strict]")
print("Commands: validate, check, explain, suggest")
sys.exit(2)
command = sys.argv[1]
filepath = sys.argv[2]
fmt = "text"
strict = False
i = 3
while i < len(sys.argv):
if sys.argv[i] == "--format" and i + 1 < len(sys.argv):
fmt = sys.argv[i + 1]
i += 2
elif sys.argv[i] == "--strict":
strict = True
i += 1
else:
i += 1
if command not in ("validate", "check", "explain", "suggest"):
print(f"❌ Unknown command: {command}")
print("Valid commands: validate, check, explain, suggest")
sys.exit(2)
config, error, config_type = load_config(filepath)
# Handle load errors
if error:
# S5: JS/TS config detected
if error == "JS_TS_DETECTED":
finding = Finding("S5", "info",
f"JS/TS config detected: {Path(filepath).name}",
"JavaScript/TypeScript configs cannot be statically validated — export your config as .postcssrc.json for validation")
if command in ("validate", "check"):
print(format_output([finding], filepath, fmt))
sys.exit(0)
else:
print(finding.to_text())
sys.exit(0)
# S1: file not found / unreadable
if "not found" in error or "Cannot read" in error:
finding = Finding("S1", "error", error)
if command in ("validate", "check"):
print(format_output([finding], filepath, fmt))
else:
print(finding.to_text())
sys.exit(2)
# S2: empty config
if "empty" in error.lower():
finding = Finding("S2", "error", error)
if command in ("validate", "check"):
print(format_output([finding], filepath, fmt))
else:
print(finding.to_text())
sys.exit(1)
# S3: invalid JSON
if "Invalid JSON" in error:
finding = Finding("S3", "error", error)
if command in ("validate", "check"):
print(format_output([finding], filepath, fmt))
else:
print(finding.to_text())
sys.exit(1)
# Any other load error
finding = Finding("S1", "error", error)
if command in ("validate", "check"):
print(format_output([finding], filepath, fmt))
else:
print(finding.to_text())
sys.exit(1)
# Config loaded successfully
if command == "explain":
print(explain_config(config, filepath, config_type))
sys.exit(0)
if command == "suggest":
print(suggest_improvements(config, filepath))
sys.exit(0)
# validate or check
if command == "check":
findings = check_structure_only(config, filepath, config_type)
else:
findings = validate_all(config, filepath, config_type)
# --strict: promote warnings and infos to errors
if strict:
for f in findings:
if f.severity in ("warning", "info"):
f.severity = "error"
print(format_output(findings, filepath, fmt))
errors = sum(1 for f in findings if f.severity == "error")
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()
Validate nodemon config files (nodemon.json, .nodemonrc, package.json#nodemonConfig) for watch settings, ignore patterns, exec conflicts, and best practices....
---
name: nodemon-config-validator
description: Validate nodemon config files (nodemon.json, .nodemonrc, package.json#nodemonConfig) for watch settings, ignore patterns, exec conflicts, and best practices. Use when validating Node.js development configs, auditing watch setups, or linting nodemon configuration.
---
# Nodemon Config Validator
Validate `nodemon.json`, `.nodemonrc`, `.nodemonrc.json`, and `package.json#nodemonConfig` for watch performance issues, ignore patterns, exec conflicts, delay settings, and best practices. Supports text, JSON, and summary output formats with CI-friendly exit codes.
## Commands
```bash
# Full validation (all 22+ rules)
python3 scripts/nodemon_config_validator.py validate nodemon.json
# Quick syntax-only check (structure rules only)
python3 scripts/nodemon_config_validator.py check .nodemonrc
# Explain config in human-readable form
python3 scripts/nodemon_config_validator.py explain nodemon.json
# Suggest improvements
python3 scripts/nodemon_config_validator.py suggest package.json
# JSON output (CI-friendly)
python3 scripts/nodemon_config_validator.py validate nodemon.json --format json
# Summary only (pass/fail + counts)
python3 scripts/nodemon_config_validator.py validate nodemon.json --format summary
# Strict mode (warnings become errors)
python3 scripts/nodemon_config_validator.py validate nodemon.json --strict
```
## Rules (22+)
| # | Category | Severity | Rule |
|---|----------|----------|------|
| S1 | Structure | Error | File not found or unreadable |
| S2 | Structure | Error | Empty config |
| S3 | Structure | Error | Invalid JSON syntax |
| S4 | Structure | Warning | Unknown top-level keys |
| S5 | Structure | Warning | Both nodemon.json and .nodemonrc present (conflict) |
| W1 | Watch | Warning | Empty watch array |
| W2 | Watch | Info | Watch path uses absolute path (portability) |
| W3 | Watch | Error | Watching node_modules (severe performance issue) |
| W4 | Watch | Info | No watch or ext specified (relying on defaults) |
| E1 | Extensions | Warning | Empty ext string |
| E2 | Extensions | Warning | Watching too many extensions (>10, performance) |
| E3 | Extensions | Info | Missing common extensions for detected project type |
| I1 | Ignore | Warning | Empty ignore array |
| I2 | Ignore | Info | node_modules not explicitly ignored |
| I3 | Ignore | Warning | Overly broad ignore patterns (e.g. "*") |
| X1 | Exec | Warning | exec command with shell injection risk |
| X2 | Exec | Warning | Both exec and script specified (conflict) |
| X3 | Exec | Info | execMap with unusual/unknown extension |
| D1 | Delay | Warning | Delay too low (<100ms, rapid restarts) |
| D2 | Delay | Warning | Delay too high (>10000ms, slow feedback) |
| B1 | Best Practices | Info | verbose not set (useful for debugging) |
| B2 | Best Practices | Warning | No ignore patterns at all |
## Output Formats
- `text` (default): Human-readable with severity icons
- `json`: Machine-parseable JSON array of findings
- `summary`: Pass/fail with error/warning/info counts
## Exit Codes
- `0`: No errors (warnings/infos only or clean)
- `1`: One or more errors found
- `2`: File not found or invalid input
## Requirements
- Python 3.8+
- No external dependencies (pure stdlib)
FILE:STATUS.md
# nodemon-config-validator — Status
- **Status:** Ready
- **Price:** $49
- **Built:** 2026-04-22
- **Rules:** 22+
- **Lines:** ~350-450
- **Dependencies:** Pure Python stdlib
FILE:scripts/nodemon_config_validator.py
#!/usr/bin/env python3
"""Nodemon Config Validator — validate nodemon.json, .nodemonrc, .nodemonrc.json, package.json#nodemonConfig."""
import json
import os
import re
import sys
from pathlib import Path
VALID_TOP_KEYS = {
"restartable", "ignore", "verbose", "execMap", "watch", "stdin",
"runOnChangeOnly", "ext", "delay", "legacyWatch", "colours", "cwd",
"exec", "script", "args", "nodeArgs", "events", "signal", "env",
"pollingInterval", "dump", "spawn", "quiet",
}
# Extensions typically expected per inferred project type
COMMON_EXTENSIONS_BY_TYPE = {
"typescript": {"ts", "tsx"},
"react": {"jsx", "tsx"},
"javascript": {"js", "mjs", "cjs"},
}
SHELL_INJECTION_PATTERNS = re.compile(
r'(\$\(|`[^`]+`|&&|\|\||\||;|\beval\b|\bexec\b)',
)
def load_config(filepath):
path = Path(filepath)
if not path.exists():
return None, f"File not found: {filepath}"
try:
text = path.read_text(encoding="utf-8").strip()
except Exception as e:
return None, f"Cannot read file: {e}"
if not text:
return None, "File is empty"
if path.name == "package.json":
try:
pkg = json.loads(text)
if "nodemonConfig" not in pkg:
return None, "No 'nodemonConfig' key in package.json"
return pkg["nodemonConfig"], None
except json.JSONDecodeError as e:
return None, f"Invalid JSON: {e}"
else:
# .nodemonrc may be JSON with comments stripped
comment_re = re.compile(r'//.*?$|/\*.*?\*/', re.MULTILINE | re.DOTALL)
cleaned = comment_re.sub('', text)
cleaned = re.sub(r',(\s*[}\]])', r'\1', cleaned)
try:
return json.loads(cleaned), None
except json.JSONDecodeError as e:
return None, f"Invalid JSON: {e}"
def find_sibling_nodemon_configs(filepath):
"""Return other nodemon config files found in the same directory."""
directory = Path(filepath).parent
names = ["nodemon.json", ".nodemonrc", ".nodemonrc.json"]
siblings = []
for name in names:
p = directory / name
if p.exists() and str(p.resolve()) != str(Path(filepath).resolve()):
siblings.append(name)
pkg = directory / "package.json"
if pkg.exists() and Path(filepath).name != "package.json":
try:
d = json.loads(pkg.read_text(encoding="utf-8"))
if "nodemonConfig" in d:
siblings.append("package.json#nodemonConfig")
except Exception:
pass
return siblings
class Finding:
def __init__(self, rule_id, severity, message, detail=None):
self.rule_id = rule_id
self.severity = severity
self.message = message
self.detail = detail
def to_dict(self):
d = {"rule": self.rule_id, "severity": self.severity, "message": self.message}
if self.detail:
d["detail"] = self.detail
return d
def to_text(self):
icon = {"error": "❌", "warning": "⚠️", "info": "ℹ️"}.get(self.severity, "•")
s = f"{icon} [{self.rule_id}] {self.message}"
if self.detail:
s += f"\n → {self.detail}"
return s
# ---------------------------------------------------------------------------
# Rule implementations
# ---------------------------------------------------------------------------
def _check_structure(config, filepath, findings):
"""S1-S5: Structural checks."""
# S4: Unknown top-level keys
unknown = set(config.keys()) - VALID_TOP_KEYS
if unknown:
findings.append(Finding("S4", "warning",
f"Unknown top-level keys: {', '.join(sorted(unknown))}",
f"Valid keys: {', '.join(sorted(VALID_TOP_KEYS))}"))
# S5: Conflicting config files in same directory
siblings = find_sibling_nodemon_configs(filepath)
if siblings:
findings.append(Finding("S5", "warning",
f"Multiple nodemon configs detected: {', '.join(siblings)}",
"Nodemon merges configs in a defined priority order which can cause unexpected behavior"))
def _check_watch(config, findings):
"""W1-W4: Watch-related checks."""
watch = config.get("watch")
# W1: Empty watch array
if watch is not None and isinstance(watch, list) and len(watch) == 0:
findings.append(Finding("W1", "warning",
"Empty watch array",
"An empty watch array means nodemon won't watch anything; omit it or add paths"))
# W2: Watch paths not using relative paths
if watch and isinstance(watch, list):
for wp in watch:
if isinstance(wp, str) and wp.startswith("/"):
findings.append(Finding("W2", "info",
f"Watch path is absolute: '{wp}'",
"Prefer relative paths (e.g. 'src', './lib') for portability"))
# W3: Watching node_modules
if watch and isinstance(watch, list):
for wp in watch:
if isinstance(wp, str) and "node_modules" in wp:
findings.append(Finding("W3", "error",
f"Watching node_modules: '{wp}'",
"Watching node_modules causes massive performance degradation and rapid restart loops"))
# W4: No watch or ext — relying on defaults
ext = config.get("ext")
if not watch and not ext:
findings.append(Finding("W4", "info",
"No watch or ext specified — relying on nodemon defaults",
"Defaults watch CWD and extensions js,mjs,cjs,json; be explicit for large projects"))
def _check_extensions(config, findings):
"""E1-E3: Extension-related checks."""
ext = config.get("ext")
# E1: Empty ext string
if ext is not None and isinstance(ext, str) and ext.strip() == "":
findings.append(Finding("E1", "warning",
"Empty ext string",
"An empty ext means no file extension filter; remove it or add extensions like 'js,ts,json'"))
if ext and isinstance(ext, str):
parts = [e.strip().lstrip(".") for e in ext.split(",") if e.strip()]
# E2: Too many extensions
if len(parts) > 10:
findings.append(Finding("E2", "warning",
f"Watching too many extensions ({len(parts)})",
"Watching >10 extensions increases filesystem watcher overhead; narrow down to what you use"))
# E3: Missing common extensions for inferred project type
ext_set = set(parts)
watch = config.get("watch", [])
exec_cmd = config.get("exec", "")
exec_map = config.get("execMap", {})
is_ts = "ts" in ext_set or "ts" in str(exec_cmd) or "ts-node" in str(exec_cmd)
is_react = "jsx" in ext_set or "tsx" in ext_set or "react" in str(exec_cmd).lower()
if is_ts and "ts" not in ext_set:
findings.append(Finding("E3", "info",
"TypeScript usage detected but 'ts' not in ext",
"Add 'ts' to ext to watch TypeScript files"))
if is_react and "tsx" not in ext_set and "jsx" not in ext_set:
findings.append(Finding("E3", "info",
"React usage detected but 'jsx'/'tsx' not in ext",
"Add 'tsx' or 'jsx' to ext"))
def _check_ignore(config, findings):
"""I1-I3: Ignore-related checks."""
ignore = config.get("ignore")
# I1: Empty ignore array
if ignore is not None and isinstance(ignore, list) and len(ignore) == 0:
findings.append(Finding("I1", "warning",
"Empty ignore array",
"An empty ignore array serves no purpose; omit it or add patterns to exclude"))
# I2: Not ignoring node_modules explicitly
if ignore is not None and isinstance(ignore, list):
has_node_modules = any(
"node_modules" in str(p) for p in ignore
)
if not has_node_modules:
findings.append(Finding("I2", "info",
"node_modules not explicitly ignored",
"Add 'node_modules' to ignore to prevent accidental watches if watch globs are broad"))
# I3: Overly broad ignore patterns
if ignore and isinstance(ignore, list):
for pattern in ignore:
if pattern in ("*", "**", "**/*", "."):
findings.append(Finding("I3", "warning",
f"Overly broad ignore pattern: '{pattern}'",
"This will ignore all files and nodemon won't trigger on any changes"))
def _check_exec(config, findings):
"""X1-X3: Exec/script checks."""
exec_cmd = config.get("exec")
script = config.get("script")
exec_map = config.get("execMap", {})
# X1: Shell injection risk in exec
if exec_cmd and isinstance(exec_cmd, str):
if SHELL_INJECTION_PATTERNS.search(exec_cmd):
findings.append(Finding("X1", "warning",
f"Possible shell injection risk in exec: '{exec_cmd[:60]}'",
"Avoid shell operators in exec; use a script file or wrap in a safe command"))
# X2: Both exec and script specified
if exec_cmd and script:
findings.append(Finding("X2", "warning",
"Both exec and script are specified",
"exec takes precedence over script; remove one to avoid confusion"))
# X3: execMap with unknown/unusual extensions
if exec_map and isinstance(exec_map, dict):
known_exts = {"js", "ts", "mjs", "cjs", "coffee", "py", "rb", "sh", "jsx", "tsx"}
for ext_key in exec_map:
if ext_key not in known_exts:
findings.append(Finding("X3", "info",
f"execMap has unusual extension: '{ext_key}'",
"Verify the extension is correct and the mapped command is available"))
def _check_delay(config, findings):
"""D1-D2: Delay checks."""
delay = config.get("delay")
if delay is None:
return
# Nodemon accepts delay as number (ms) or string like "2500ms" or "2.5"
delay_ms = None
if isinstance(delay, (int, float)):
delay_ms = float(delay)
elif isinstance(delay, str):
m = re.match(r'^(\d+(?:\.\d+)?)\s*(ms|s)?$', delay.strip())
if m:
val = float(m.group(1))
unit = m.group(2) or "ms"
delay_ms = val * 1000 if unit == "s" else val
if delay_ms is not None:
# D1: Too low
if delay_ms < 100:
findings.append(Finding("D1", "warning",
f"Delay too low: {delay} (< 100ms)",
"Very short delays can cause rapid restart loops before your code is ready"))
# D2: Too high
if delay_ms > 10000:
findings.append(Finding("D2", "warning",
f"Delay too high: {delay} (> 10000ms)",
"Very long delays make feedback slow; consider a value under 5000ms"))
def _check_best_practices(config, findings):
"""B1-B2: Best practices."""
# B1: Missing verbose for debugging
if not config.get("verbose"):
findings.append(Finding("B1", "info",
"verbose is not set",
"Set verbose: true during development to see which files triggered a restart"))
# B2: No ignore patterns at all
ignore = config.get("ignore")
if ignore is None:
findings.append(Finding("B2", "warning",
"No ignore patterns defined",
"Without ignore patterns, test output dirs, logs, and build artifacts may trigger restarts"))
def validate(config, filepath):
findings = []
_check_structure(config, filepath, findings)
_check_watch(config, findings)
_check_extensions(config, findings)
_check_ignore(config, findings)
_check_exec(config, findings)
_check_delay(config, findings)
_check_best_practices(config, findings)
return findings
# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------
def format_text(findings, filepath):
if not findings:
return f"✅ {filepath}: No issues found"
lines = [f"📋 {filepath}: {len(findings)} issue(s) found\n"]
for f in findings:
lines.append(f.to_text())
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
infos = sum(1 for f in findings if f.severity == "info")
icon = "❌" if errors else ("⚠️" if warnings else "ℹ️")
lines.append(f"\n{icon} {errors} error(s), {warnings} warning(s), {infos} info(s)")
return "\n".join(lines)
def format_json(findings, filepath):
return json.dumps({
"file": filepath,
"findings": [f.to_dict() for f in findings],
"summary": {
"errors": sum(1 for f in findings if f.severity == "error"),
"warnings": sum(1 for f in findings if f.severity == "warning"),
"infos": sum(1 for f in findings if f.severity == "info"),
"total": len(findings),
}
}, indent=2)
def format_summary(findings, filepath):
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
infos = sum(1 for f in findings if f.severity == "info")
status = "FAIL" if errors else ("WARN" if warnings else "PASS")
return f"{status} | {filepath} | {errors} errors, {warnings} warnings, {infos} infos"
# ---------------------------------------------------------------------------
# Explain / Suggest
# ---------------------------------------------------------------------------
def explain_config(config, filepath):
lines = [f"📖 Nodemon Config Explanation: {filepath}\n"]
watch = config.get("watch")
if watch:
lines.append(f"Watch paths: {', '.join(watch) if isinstance(watch, list) else watch}")
else:
lines.append("Watch paths: (default — current working directory)")
ext = config.get("ext")
if ext:
lines.append(f"Extensions: {ext}")
else:
lines.append("Extensions: (default — js,mjs,cjs,json)")
ignore = config.get("ignore")
if ignore:
lines.append(f"Ignore patterns: {', '.join(ignore) if isinstance(ignore, list) else ignore}")
else:
lines.append("Ignore patterns: (none set)")
exec_cmd = config.get("exec")
script = config.get("script")
if exec_cmd:
lines.append(f"Exec command: {exec_cmd}")
if script:
lines.append(f"Script: {script}")
delay = config.get("delay")
if delay is not None:
lines.append(f"Restart delay: {delay}ms")
exec_map = config.get("execMap", {})
if exec_map:
lines.append("ExecMap (extension → command):")
for ext_key, cmd in exec_map.items():
lines.append(f" • .{ext_key} → {cmd}")
events = config.get("events", {})
if events:
lines.append(f"Events hooked: {', '.join(events.keys()) if isinstance(events, dict) else events}")
env = config.get("env", {})
if env:
lines.append(f"Environment vars: {', '.join(str(k) for k in env.keys())}")
signal = config.get("signal")
if signal:
lines.append(f"Restart signal: {signal}")
verbose = config.get("verbose")
if verbose:
lines.append("Verbose mode: enabled")
legacy_watch = config.get("legacyWatch")
if legacy_watch:
lines.append("Legacy watch (polling): enabled")
return "\n".join(lines)
def suggest_improvements(config, filepath):
lines = [f"💡 Suggestions for {filepath}\n"]
suggestions = []
watch = config.get("watch")
ignore = config.get("ignore", [])
# Suggest explicit watch
if not watch:
suggestions.append("Add an explicit watch list (e.g. ['src', 'config']) to limit filesystem scope")
# Suggest ignoring node_modules if not present
ignore_strs = [str(p) for p in (ignore if isinstance(ignore, list) else [])]
if not any("node_modules" in p for p in ignore_strs):
suggestions.append("Add 'node_modules' to ignore to be safe if watch paths are broad")
# Suggest ignoring build/test output directories
common_noise_dirs = ["dist", "build", "coverage", ".nyc_output", "out"]
missing_noise = [d for d in common_noise_dirs if not any(d in p for p in ignore_strs)]
if missing_noise:
suggestions.append(
f"Consider ignoring build/output dirs: {', '.join(missing_noise[:3])}"
)
# Suggest verbose for debugging
if not config.get("verbose"):
suggestions.append("Set verbose: true to see which files trigger restarts (great for debugging watch issues)")
# Suggest explicit ext
if not config.get("ext"):
suggestions.append("Set ext explicitly (e.g. 'js,json,ts') rather than relying on defaults")
# Suggest signal for graceful shutdown
if not config.get("signal"):
suggestions.append("Set signal: 'SIGUSR2' for graceful shutdown support (e.g. with cluster mode or --inspect)")
# Suggest delay if missing
if config.get("delay") is None:
suggestions.append("Consider setting delay: 500 to debounce rapid file saves and reduce unnecessary restarts")
if not suggestions:
lines.append("No suggestions — config looks good!")
else:
for s in suggestions:
lines.append(f" • {s}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
if len(sys.argv) < 3:
print("Usage: nodemon_config_validator.py <command> <file> [--format text|json|summary] [--strict]")
print("Commands: validate, check, explain, suggest")
sys.exit(2)
command = sys.argv[1]
filepath = sys.argv[2]
fmt = "text"
strict = False
i = 3
while i < len(sys.argv):
if sys.argv[i] == "--format" and i + 1 < len(sys.argv):
fmt = sys.argv[i + 1]
i += 2
elif sys.argv[i] == "--strict":
strict = True
i += 1
else:
i += 1
config, error = load_config(filepath)
if error:
if command in ("validate", "check"):
finding = Finding(
"S1" if "not found" in error else ("S2" if "empty" in error.lower() else "S3"),
"error", error
)
if fmt == "json":
print(format_json([finding], filepath))
elif fmt == "summary":
print(format_summary([finding], filepath))
else:
print(finding.to_text())
sys.exit(2 if "not found" in error else 1)
else:
print(f"❌ {error}")
sys.exit(2)
if not isinstance(config, dict):
print("❌ Config must be a JSON object")
sys.exit(1)
if command == "explain":
print(explain_config(config, filepath))
sys.exit(0)
if command == "suggest":
print(suggest_improvements(config, filepath))
sys.exit(0)
findings = validate(config, filepath)
if command == "check":
# check = structural rules only (S*)
findings = [f for f in findings if f.rule_id.startswith("S")]
if strict:
for f in findings:
if f.severity in ("warning", "info"):
f.severity = "error"
if fmt == "json":
print(format_json(findings, filepath))
elif fmt == "summary":
print(format_summary(findings, filepath))
else:
print(format_text(findings, filepath))
errors = sum(1 for f in findings if f.severity == "error")
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()
Validate SWC config files (.swcrc, package.json#swc) for parser settings, transform conflicts, module type issues, and best practices. Use when validating SW...
---
name: swc-config-validator
description: Validate SWC config files (.swcrc, package.json#swc) for parser settings, transform conflicts, module type issues, and best practices. Use when validating SWC transpiler configs, auditing Next.js/Turbopack build setups, or linting swc config files.
---
# SWC Config Validator
Validate `.swcrc` and `package.json#swc` for parser errors, invalid targets, transform conflicts, module type issues, minification problems, and best practices. Supports text, JSON, and summary output formats with CI-friendly exit codes.
## Commands
```bash
# Full validation (all 22+ rules)
python3 scripts/swc_config_validator.py validate .swcrc
# Quick syntax/structure check only
python3 scripts/swc_config_validator.py check .swcrc
# Explain config in human-readable form
python3 scripts/swc_config_validator.py explain .swcrc
# Suggest improvements
python3 scripts/swc_config_validator.py suggest .swcrc
# JSON output (CI-friendly)
python3 scripts/swc_config_validator.py validate .swcrc --format json
# Summary only (pass/fail + counts)
python3 scripts/swc_config_validator.py validate .swcrc --format summary
# Strict mode (warnings become errors)
python3 scripts/swc_config_validator.py validate .swcrc --strict
# Validate from package.json#swc
python3 scripts/swc_config_validator.py validate package.json
```
## Rules (22+)
| # | Category | Severity | Rule |
|---|----------|----------|------|
| S1 | Structure | Error | File not found or unreadable |
| S2 | Structure | Error | Empty config |
| S3 | Structure | Error | Invalid JSON syntax |
| S4 | Structure | Warning | Unknown top-level keys |
| S5 | Structure | Warning | Missing jsc key (most configs need it) |
| J1 | JSC Config | Error | Invalid parser syntax (must be ecmascript or typescript) |
| J2 | JSC Config | Warning | JSX enabled in parser without React transform |
| J3 | JSC Config | Warning | Deprecated loose mode in jsc.transform |
| J4 | JSC Config | Warning | Missing target (no compilation target specified) |
| J5 | JSC Config | Error | Invalid target value (not es3/es5/es2015-es2024/esnext) |
| M1 | Modules | Error | Unknown module type |
| M2 | Modules | Warning | isModule: false with ESM module type |
| M3 | Modules | Warning | CommonJS module with ESM-only parser features |
| T1 | Transform | Error | React transform without parser.jsx enabled |
| T2 | Transform | Warning | Legacy decorators without decoratorsBeforeExport |
| T3 | Transform | Warning | Conflicting useDefineForClassFields with TypeScript |
| T4 | Transform | Warning | Deprecated constModules in jsc.experimental |
| N1 | Minification | Warning | Minification enabled with compress: false |
| N2 | Minification | Warning | Mangle enabled without compress |
| N3 | Minification | Warning | Drop console in development config |
| B1 | Best Practices | Warning | sourceMaps not configured |
| B2 | Best Practices | Info | No env config for different environments |
## Output Formats
- `text` (default): Human-readable with severity icons
- `json`: Machine-parseable JSON array of findings
- `summary`: Pass/fail with error/warning counts
## Exit Codes
- `0`: No errors (warnings/info only or clean)
- `1`: One or more errors found
- `2`: File not found or invalid input
## Requirements
- Python 3.8+
- No external dependencies (pure stdlib)
## Supported Targets
`es3`, `es5`, `es2015` through `es2024`, `esnext`
## Supported Module Types
`es6`, `commonjs`, `umd`, `amd`, `nodenext`, `systemjs`
## Valid Top-Level Keys
`$schema`, `jsc`, `module`, `minify`, `env`, `isModule`, `sourceMaps`, `inlineSourcesContent`, `emitSourceMapColumns`, `inputSourceMap`, `test`, `exclude`, `filename`
FILE:STATUS.md
# swc-config-validator — Status
- **Status:** Ready
- **Price:** $49
- **Built:** 2026-04-22
- **Rules:** 22+
- **Lines:** ~400-500
- **Dependencies:** Pure Python stdlib
FILE:scripts/swc_config_validator.py
#!/usr/bin/env python3
"""SWC Config Validator — validate .swcrc and package.json#swc files."""
import json
import os
import re
import sys
from pathlib import Path
VALID_TOP_KEYS = {
"$schema", "jsc", "module", "minify", "env", "isModule", "sourceMaps",
"inlineSourcesContent", "emitSourceMapColumns", "inputSourceMap",
"test", "exclude", "filename",
}
VALID_PARSER_SYNTAX = {"ecmascript", "typescript"}
VALID_TARGETS = {
"es3", "es5",
"es2015", "es2016", "es2017", "es2018", "es2019", "es2020",
"es2021", "es2022", "es2023", "es2024",
"esnext",
}
VALID_MODULE_TYPES = {
"es6", "commonjs", "umd", "amd", "nodenext", "systemjs",
}
ESM_ONLY_FEATURES = {
"importMeta", "dynamicImport", "staticBlocks",
}
def load_config(filepath):
path = Path(filepath)
if not path.exists():
return None, f"File not found: {filepath}"
try:
text = path.read_text(encoding="utf-8").strip()
except Exception as e:
return None, f"Cannot read file: {e}"
if not text:
return None, "File is empty"
if path.name == "package.json":
try:
pkg = json.loads(text)
if "swc" not in pkg:
return None, "No 'swc' key in package.json"
return pkg["swc"], None
except json.JSONDecodeError as e:
return None, f"Invalid JSON: {e}"
else:
# Try plain JSON first (avoids mangling URLs/strings that contain //)
try:
return json.loads(text), None
except json.JSONDecodeError:
pass
# Fallback: strip JS-style comments (common in .swcrc authored by humans)
# Only strip // outside quoted strings by using a state-machine approach
comment_re = re.compile(r'("(?:[^"\\]|\\.)*")|//[^\n]*|(/\*.*?\*/)',
re.DOTALL)
def _strip(m):
if m.group(1): # inside a quoted string — keep as-is
return m.group(1)
return "" # comment — remove
cleaned = comment_re.sub(_strip, text)
cleaned = re.sub(r',(\s*[}\]])', r'\1', cleaned)
try:
return json.loads(cleaned), None
except json.JSONDecodeError as e:
return None, f"Invalid JSON: {e}"
class Finding:
def __init__(self, rule_id, severity, message, detail=None):
self.rule_id = rule_id
self.severity = severity
self.message = message
self.detail = detail
def to_dict(self):
d = {"rule": self.rule_id, "severity": self.severity, "message": self.message}
if self.detail:
d["detail"] = self.detail
return d
def to_text(self):
icon = "❌" if self.severity == "error" else ("⚠️" if self.severity == "warning" else "ℹ️")
s = f"{icon} [{self.rule_id}] {self.message}"
if self.detail:
s += f"\n → {self.detail}"
return s
def validate(config, filepath):
findings = []
# ── Structure rules ──────────────────────────────────────────────────────
# S4: Unknown top-level keys
unknown = set(config.keys()) - VALID_TOP_KEYS
if unknown:
findings.append(Finding("S4", "warning",
f"Unknown top-level keys: {', '.join(sorted(unknown))}",
f"Valid keys: {', '.join(sorted(VALID_TOP_KEYS))}"))
# S5: Missing jsc key
if "jsc" not in config:
findings.append(Finding("S5", "warning",
"Missing 'jsc' key",
"Most SWC configs need 'jsc' to configure the parser, transform, and compilation target"))
# ── JSC Config rules ─────────────────────────────────────────────────────
jsc = config.get("jsc")
if isinstance(jsc, dict):
parser = jsc.get("parser", {})
if isinstance(parser, dict):
syntax = parser.get("syntax")
# J1: Invalid parser syntax
if syntax is not None and syntax not in VALID_PARSER_SYNTAX:
findings.append(Finding("J1", "error",
f"Invalid parser syntax: '{syntax}'",
f"Must be one of: {', '.join(sorted(VALID_PARSER_SYNTAX))}"))
# J2: JSX enabled without React transform
jsx_enabled = parser.get("jsx", False)
transform = jsc.get("transform", {})
react_transform = isinstance(transform, dict) and "react" in transform
if jsx_enabled and not react_transform:
findings.append(Finding("J2", "warning",
"JSX enabled in parser but no jsc.transform.react configured",
"Add jsc.transform.react (e.g. { runtime: 'automatic' }) to handle JSX output"))
# T1: React transform without parser.jsx enabled (cross-rule, placed here for context)
if react_transform and not jsx_enabled:
findings.append(Finding("T1", "error",
"jsc.transform.react is configured but parser.jsx is not enabled",
"Set parser.jsx: true to enable JSX parsing"))
# J3: Deprecated loose mode in jsc.transform
transform = jsc.get("transform", {})
if isinstance(transform, dict):
loose = transform.get("loose")
if loose is True:
findings.append(Finding("J3", "warning",
"Deprecated loose mode in jsc.transform",
"loose mode may produce spec-non-compliant output; prefer explicit assumption flags instead"))
# T2: Legacy decorators without decoratorsBeforeExport
decorator_version = transform.get("decoratorVersion")
legacy_decorators = transform.get("legacyDecorator", False)
decorators_before_export = transform.get("decoratorsBeforeExport")
if legacy_decorators and decorators_before_export is None:
findings.append(Finding("T2", "warning",
"Legacy decorators enabled without decoratorsBeforeExport",
"Set jsc.transform.decoratorsBeforeExport: true or false to avoid ambiguity"))
# T3: Conflicting useDefineForClassFields
use_define = jsc.get("transform", {}).get("useDefineForClassFields")
exper = jsc.get("externalHelpers")
if use_define is False and isinstance(parser, dict) and parser.get("syntax") == "typescript":
findings.append(Finding("T3", "warning",
"useDefineForClassFields: false with TypeScript parser",
"TypeScript class fields default to define semantics; setting false can break TS behaviour"))
# T4: Deprecated constModules
const_modules = jsc.get("experimental", {})
if isinstance(const_modules, dict) and "constModules" in const_modules:
findings.append(Finding("T4", "warning",
"Deprecated constModules in jsc.experimental",
"constModules is no longer recommended; use explicit imports instead"))
# J4: Missing target
target = jsc.get("target")
if target is None:
findings.append(Finding("J4", "warning",
"No compilation target specified (jsc.target is missing)",
"Add jsc.target (e.g. 'es2017') to control output syntax level"))
# J5: Invalid target value
elif isinstance(target, str) and target.lower() not in VALID_TARGETS:
findings.append(Finding("J5", "error",
f"Invalid jsc.target: '{target}'",
f"Must be one of: {', '.join(sorted(VALID_TARGETS))}"))
elif jsc is not None:
findings.append(Finding("J1", "error",
"jsc must be an object, got " + type(jsc).__name__))
# ── Module rules ─────────────────────────────────────────────────────────
module_config = config.get("module")
module_type = None
if isinstance(module_config, dict):
module_type = module_config.get("type")
# M1: Unknown module type
if module_type is not None and module_type not in VALID_MODULE_TYPES:
findings.append(Finding("M1", "error",
f"Unknown module type: '{module_type}'",
f"Must be one of: {', '.join(sorted(VALID_MODULE_TYPES))}"))
# M2: modules: false equivalent without bundler context (isModule: false)
is_module = config.get("isModule")
if is_module is False and module_type in ("es6", "nodenext"):
findings.append(Finding("M2", "warning",
f"isModule: false with module type '{module_type}'",
"isModule: false tells SWC the input is a script, not an ES module — this conflicts with ESM output"))
# M3: CommonJS module with ESM-only features
if module_type == "commonjs" and isinstance(jsc, dict):
parser = jsc.get("parser", {})
if isinstance(parser, dict):
for feat in ESM_ONLY_FEATURES:
if parser.get(feat, False):
findings.append(Finding("M3", "warning",
f"CommonJS module with ESM-only parser feature: {feat}",
"ESM-only features may not work correctly when outputting CommonJS"))
break
# ── Transform rules (cross-file, not in jsc block) ───────────────────────
# T2 / T3 / T4 already handled above inside jsc block
# ── Minification rules ───────────────────────────────────────────────────
minify = config.get("minify")
jsc_minify = isinstance(jsc, dict) and jsc.get("minify")
if minify or jsc_minify:
minify_config = jsc_minify if isinstance(jsc_minify, dict) else {}
compress = minify_config.get("compress", True)
mangle = minify_config.get("mangle", True)
dead_code = minify_config.get("deadCode", False)
# N1: Minification enabled with compress: false
if minify and compress is False:
findings.append(Finding("N1", "warning",
"Minification enabled but jsc.minify.compress is false",
"compress: false skips dead-code elimination and expression simplification"))
# N2: Mangle enabled without compress
if mangle and compress is False:
findings.append(Finding("N2", "warning",
"jsc.minify.mangle enabled without compress",
"Mangling identifiers without compressing may produce hard-to-debug output with no size benefit"))
# N3: Drop console in development config
compress_opts = minify_config.get("compress")
if isinstance(compress_opts, dict):
drop_console = compress_opts.get("drop_console", False)
env_section = config.get("env", {})
dev_config = isinstance(env_section, dict) and "development" in env_section
if drop_console and dev_config:
findings.append(Finding("N3", "warning",
"drop_console enabled in a config that also has a development env section",
"Dropping console statements in development makes debugging much harder"))
# ── Best practices ───────────────────────────────────────────────────────
# B1: sourceMaps not configured
source_maps = config.get("sourceMaps")
if source_maps is None or source_maps is False:
findings.append(Finding("B1", "warning",
"sourceMaps not configured",
"Add sourceMaps: true (or 'inline') to enable source map generation for easier debugging"))
# B2: No env config for different environments
env_section = config.get("env")
if not env_section or (isinstance(env_section, dict) and not env_section):
findings.append(Finding("B2", "info",
"No env config for different environments",
"Consider adding an 'env' block to apply different transforms for development/production/test"))
return findings
def format_text(findings, filepath):
if not findings:
return f"✅ {filepath}: No issues found"
lines = [f"📋 {filepath}: {len(findings)} issue(s) found\n"]
for f in findings:
lines.append(f.to_text())
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
infos = sum(1 for f in findings if f.severity == "info")
parts = []
if errors:
parts.append(f"{errors} error(s)")
if warnings:
parts.append(f"{warnings} warning(s)")
if infos:
parts.append(f"{infos} info")
icon = "❌" if errors else ("⚠️" if warnings else "ℹ️")
lines.append(f"\n{icon} {', '.join(parts)}")
return "\n".join(lines)
def format_json(findings, filepath):
return json.dumps({
"file": filepath,
"findings": [f.to_dict() for f in findings],
"summary": {
"errors": sum(1 for f in findings if f.severity == "error"),
"warnings": sum(1 for f in findings if f.severity == "warning"),
"info": sum(1 for f in findings if f.severity == "info"),
"total": len(findings),
}
}, indent=2)
def format_summary(findings, filepath):
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
status = "FAIL" if errors else ("WARN" if warnings else "PASS")
return f"{status} | {filepath} | {errors} errors, {warnings} warnings"
def explain_config(config, filepath):
lines = [f"📖 SWC Config Explanation: {filepath}\n"]
jsc = config.get("jsc", {})
if isinstance(jsc, dict):
parser = jsc.get("parser", {})
if isinstance(parser, dict):
syntax = parser.get("syntax", "(not set)")
jsx = parser.get("jsx", False)
tsx = parser.get("tsx", False)
decorators = parser.get("decorators", False)
lines.append(f"Parser:")
lines.append(f" • syntax: {syntax}")
if jsx:
lines.append(f" • JSX: enabled")
if tsx:
lines.append(f" • TSX: enabled")
if decorators:
lines.append(f" • decorators: enabled")
target = jsc.get("target")
if target:
lines.append(f"\nCompilation target: {target}")
transform = jsc.get("transform", {})
if isinstance(transform, dict):
react = transform.get("react")
if react:
runtime = react.get("runtime", "classic") if isinstance(react, dict) else "configured"
lines.append(f"\nReact transform: runtime={runtime}")
if transform.get("legacyDecorator"):
lines.append(" • legacyDecorator: enabled")
if transform.get("loose"):
lines.append(" • loose mode: enabled")
if jsc.get("minify"):
lines.append("\nMinification: enabled via jsc.minify")
module_config = config.get("module", {})
if isinstance(module_config, dict):
mtype = module_config.get("type", "(not set)")
lines.append(f"\nModule output type: {mtype}")
if config.get("minify"):
lines.append("\nTop-level minify: enabled")
source_maps = config.get("sourceMaps")
if source_maps:
lines.append(f"\nsourceMaps: {source_maps}")
env_section = config.get("env", {})
if isinstance(env_section, dict) and env_section:
lines.append(f"\nEnvironment overrides: {', '.join(env_section.keys())}")
return "\n".join(lines)
def suggest_improvements(config, filepath):
lines = [f"💡 Suggestions for {filepath}\n"]
suggestions = []
jsc = config.get("jsc", {}) if isinstance(config.get("jsc"), dict) else {}
# Suggest adding target
if not jsc.get("target"):
suggestions.append("Add jsc.target (e.g. 'es2017') — without it SWC defaults to ES5 which is larger")
# Suggest sourceMaps
if not config.get("sourceMaps"):
suggestions.append("Add sourceMaps: true to enable source maps for easier debugging")
# Suggest env block
if not config.get("env"):
suggestions.append("Add an 'env' block to apply different settings per environment (development/production/test)")
# Suggest runtime: automatic for React (only when react transform is explicitly configured)
transform = jsc.get("transform", {}) if isinstance(jsc.get("transform"), dict) else {}
react = transform.get("react")
if isinstance(react, dict) and react.get("runtime") != "automatic":
suggestions.append("Set jsc.transform.react.runtime: 'automatic' to avoid importing React in every JSX file")
# Suggest externalHelpers for large projects
if not jsc.get("externalHelpers"):
suggestions.append("Consider jsc.externalHelpers: true to avoid inlining SWC helpers in every file (use @swc/helpers package)")
# Suggest keepClassNames in production
minify_cfg = jsc.get("minify", {}) if isinstance(jsc.get("minify"), dict) else {}
if config.get("minify") and not minify_cfg.get("keepClassNames"):
suggestions.append("If class names matter at runtime (e.g. decorators, DI containers), set jsc.minify.keepClassNames: true")
if not suggestions:
lines.append("No suggestions — config looks good!")
else:
for s in suggestions:
lines.append(f" • {s}")
return "\n".join(lines)
def main():
if len(sys.argv) < 3:
print("Usage: swc_config_validator.py <command> <file> [--format text|json|summary] [--strict]")
print("Commands: validate, check, explain, suggest")
sys.exit(2)
command = sys.argv[1]
filepath = sys.argv[2]
fmt = "text"
strict = False
for i, arg in enumerate(sys.argv[3:], 3):
if arg == "--format" and i + 1 < len(sys.argv):
fmt = sys.argv[i + 1]
if arg == "--strict":
strict = True
config, error = load_config(filepath)
if error:
if command in ("validate", "check"):
rule = "S1" if "not found" in error else ("S2" if "empty" in error.lower() else "S3")
finding = Finding(rule, "error", error)
if fmt == "json":
print(format_json([finding], filepath))
elif fmt == "summary":
print(format_summary([finding], filepath))
else:
print(finding.to_text())
sys.exit(2 if "not found" in error else 1)
else:
print(f"❌ {error}")
sys.exit(2)
if not isinstance(config, dict):
print("❌ Config must be a JSON object")
sys.exit(1)
if command == "explain":
print(explain_config(config, filepath))
sys.exit(0)
if command == "suggest":
print(suggest_improvements(config, filepath))
sys.exit(0)
findings = validate(config, filepath)
if command == "check":
findings = [f for f in findings if f.rule_id.startswith("S")]
if strict:
for f in findings:
if f.severity in ("warning", "info"):
f.severity = "error"
if fmt == "json":
print(format_json(findings, filepath))
elif fmt == "summary":
print(format_summary(findings, filepath))
else:
print(format_text(findings, filepath))
errors = sum(1 for f in findings if f.severity == "error")
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()
Validate Rollup config files (rollup.config.js/mjs/ts) for output format conflicts, plugin ordering issues, deprecated options, and best practices. Use when...
---
name: rollup-config-validator
description: Validate Rollup config files (rollup.config.js/mjs/ts) for output format conflicts, plugin ordering issues, deprecated options, and best practices. Use when validating Rollup bundler configs, auditing build pipelines, migrating Rollup versions, or linting rollup config files.
---
# Rollup Config Validator
Validate Rollup config files (exported JSON or parsed config objects) for output format conflicts, external/bundle mismatches, plugin ordering issues, deprecated options, treeshake settings, and best practices. Supports text, JSON, and summary output formats with CI-friendly exit codes.
## Commands
```bash
# Full validation (all 22+ rules)
python3 scripts/rollup_config_validator.py validate rollup.config.json
# Quick syntax-only check (structure rules only)
python3 scripts/rollup_config_validator.py check rollup.config.json
# Explain config in human-readable form
python3 scripts/rollup_config_validator.py explain rollup.config.json
# Suggest improvements
python3 scripts/rollup_config_validator.py suggest rollup.config.json
# JSON output (CI-friendly)
python3 scripts/rollup_config_validator.py validate rollup.config.json --format json
# Summary only (pass/fail + counts)
python3 scripts/rollup_config_validator.py validate rollup.config.json --format summary
# Strict mode (warnings become errors)
python3 scripts/rollup_config_validator.py validate rollup.config.json --strict
```
## Input Format
Since Rollup configs are typically JavaScript, this tool validates **JSON representations** of Rollup config objects. Export your config as JSON or use a wrapper:
```bash
# Extract config as JSON from rollup.config.js
node -e "const c = require('./rollup.config.js'); console.log(JSON.stringify(c, null, 2))" > rollup.config.json
python3 scripts/rollup_config_validator.py validate rollup.config.json
```
Or validate directly from a JSON config file.
## Rules (22+)
| # | Category | Severity | Rule |
|---|----------|----------|------|
| S1 | Structure | Error | File not found or unreadable |
| S2 | Structure | Error | Empty config or no content |
| S3 | Structure | Warning | Unknown top-level config keys |
| S4 | Structure | Error | Invalid JSON syntax |
| S5 | Structure | Error | Missing input entry point |
| O1 | Output | Error | Missing output configuration |
| O2 | Output | Warning | Missing output.format (defaults to 'es') |
| O3 | Output | Warning | output.file and output.dir both specified |
| O4 | Output | Warning | format: 'iife' or 'umd' without output.name |
| O5 | Output | Warning | Multiple outputs with same format and no distinct file/dir |
| O6 | Output | Warning | output.sourcemap: true without sourcemapExcludeSources consideration |
| E1 | External | Warning | Bare module in external should match import pattern |
| E2 | External | Warning | Regex pattern in external (fragile) |
| E3 | External | Warning | Node built-in not in external (path, fs, etc.) |
| P1 | Plugins | Warning | Plugin ordering: resolve before commonjs |
| P2 | Plugins | Warning | commonjs plugin without @rollup/plugin-node-resolve |
| P3 | Plugins | Warning | json plugin missing (importing .json files) |
| P4 | Plugins | Warning | Deprecated plugin (rollup-plugin-* → @rollup/plugin-*) |
| T1 | Treeshake | Warning | treeshake: false disables dead code elimination |
| T2 | Treeshake | Warning | moduleSideEffects: false may break libraries |
| B1 | Best Practices | Warning | Missing preserveEntrySignatures for library builds |
| B2 | Best Practices | Warning | Large number of manual chunks without shared dependencies |
| B3 | Best Practices | Warning | watch mode config without clearScreen setting |
## Output Formats
- `text` (default): Human-readable with severity icons
- `json`: Machine-parseable JSON array of findings
- `summary`: Pass/fail with error/warning counts
## Exit Codes
- `0`: No errors (warnings only or clean)
- `1`: One or more errors found
- `2`: File not found or invalid input
## Requirements
- Python 3.8+
- No external dependencies (pure stdlib)
FILE:STATUS.md
# rollup-config-validator — Status
- **Status:** Ready to publish
- **Price:** $49
- **Built:** 2026-04-22
- **Rules:** 22+
- **Lines:** ~380
- **Dependencies:** Pure Python stdlib
FILE:scripts/rollup_config_validator.py
#!/usr/bin/env python3
"""Rollup Config Validator — validate Rollup bundler configuration (JSON format)."""
import json
import re
import sys
from pathlib import Path
VALID_TOP_KEYS = {
"input", "output", "external", "plugins", "cache", "onwarn", "onLog",
"strictDeprecations", "context", "moduleContext", "treeshake",
"experimentalCacheExpiry", "perf", "preserveEntrySignatures",
"preserveSymlinks", "shimMissingExports", "watch", "makeAbsoluteExternalsRelative",
"maxParallelFileOps", "logLevel",
}
VALID_OUTPUT_KEYS = {
"dir", "file", "format", "globals", "name", "plugins", "assetFileNames",
"banner", "chunkFileNames", "compact", "dynamicImportInCjs", "entryFileNames",
"esModule", "exports", "extend", "externalImportAssertions", "externalImportAttributes",
"footer", "freeze", "generatedCode", "hoistTransitiveImports", "importAttributesKey",
"inlineDynamicImports", "interop", "intro", "manualChunks", "minifyInternalExports",
"noConflict", "outro", "paths", "preserveModules", "preserveModulesRoot",
"sanitizeFileName", "sourcemap", "sourcemapBaseUrl", "sourcemapExcludeSources",
"sourcemapFile", "sourcemapIgnoreList", "sourcemapPathTransform", "strict",
"systemNullSetters", "validate", "amd", "indent",
}
DEPRECATED_PLUGINS = {
"rollup-plugin-node-resolve": "@rollup/plugin-node-resolve",
"rollup-plugin-commonjs": "@rollup/plugin-commonjs",
"rollup-plugin-json": "@rollup/plugin-json",
"rollup-plugin-babel": "@rollup/plugin-babel",
"rollup-plugin-replace": "@rollup/plugin-replace",
"rollup-plugin-alias": "@rollup/plugin-alias",
"rollup-plugin-inject": "@rollup/plugin-inject",
"rollup-plugin-sucrase": "@rollup/plugin-sucrase",
"rollup-plugin-terser": "@rollup/plugin-terser",
"rollup-plugin-typescript": "@rollup/plugin-typescript",
"rollup-plugin-url": "@rollup/plugin-url",
"rollup-plugin-wasm": "@rollup/plugin-wasm",
"rollup-plugin-yaml": "@rollup/plugin-yaml",
"rollup-plugin-image": "@rollup/plugin-image",
"rollup-plugin-dsv": "@rollup/plugin-dsv",
"rollup-plugin-graphql-tag": "@rollup/plugin-graphql",
"rollup-plugin-multi-entry": "@rollup/plugin-multi-entry",
"rollup-plugin-legacy": "@rollup/plugin-legacy",
"rollup-plugin-strip": "@rollup/plugin-strip",
"rollup-plugin-virtual": "@rollup/plugin-virtual",
}
NODE_BUILTINS = {
"assert", "buffer", "child_process", "cluster", "crypto", "dgram", "dns",
"events", "fs", "http", "http2", "https", "net", "os", "path", "perf_hooks",
"process", "querystring", "readline", "stream", "string_decoder", "timers",
"tls", "tty", "url", "util", "v8", "vm", "worker_threads", "zlib",
"node:assert", "node:buffer", "node:child_process", "node:cluster",
"node:crypto", "node:dgram", "node:dns", "node:events", "node:fs",
"node:http", "node:http2", "node:https", "node:net", "node:os", "node:path",
"node:perf_hooks", "node:process", "node:querystring", "node:readline",
"node:stream", "node:string_decoder", "node:timers", "node:tls", "node:tty",
"node:url", "node:util", "node:v8", "node:vm", "node:worker_threads", "node:zlib",
}
class Finding:
def __init__(self, rule_id, severity, message, detail=None):
self.rule_id = rule_id
self.severity = severity
self.message = message
self.detail = detail
def to_dict(self):
d = {"rule": self.rule_id, "severity": self.severity, "message": self.message}
if self.detail:
d["detail"] = self.detail
return d
def to_text(self):
icon = "❌" if self.severity == "error" else "⚠️"
s = f"{icon} [{self.rule_id}] {self.message}"
if self.detail:
s += f"\n → {self.detail}"
return s
def load_config(filepath):
path = Path(filepath)
if not path.exists():
return None, f"File not found: {filepath}"
try:
text = path.read_text(encoding="utf-8").strip()
except Exception as e:
return None, f"Cannot read file: {e}"
if not text:
return None, "File is empty"
comment_re = re.compile(r'//.*?$|/\*.*?\*/', re.MULTILINE | re.DOTALL)
cleaned = comment_re.sub('', text)
cleaned = re.sub(r',(\s*[}\]])', r'\1', cleaned)
try:
data = json.loads(cleaned)
return data, None
except json.JSONDecodeError as e:
return None, f"Invalid JSON: {e}"
def normalize_config(config):
if isinstance(config, list):
return config
return [config]
def get_plugin_name(plugin):
if isinstance(plugin, str):
return plugin
if isinstance(plugin, dict) and "name" in plugin:
return plugin["name"]
return ""
def validate_single(config, filepath, config_idx=None):
findings = []
prefix = f"Config #{config_idx}: " if config_idx is not None else ""
unknown = set(config.keys()) - VALID_TOP_KEYS
if unknown:
findings.append(Finding("S3", "warning",
f"{prefix}Unknown top-level keys: {', '.join(sorted(unknown))}"))
inp = config.get("input")
if not inp:
findings.append(Finding("S5", "error",
f"{prefix}Missing 'input' entry point"))
output = config.get("output")
if not output:
findings.append(Finding("O1", "error",
f"{prefix}Missing 'output' configuration"))
else:
outputs = output if isinstance(output, list) else [output]
for i, out in enumerate(outputs):
if not isinstance(out, dict):
continue
out_prefix = f"{prefix}output[{i}]: " if len(outputs) > 1 else f"{prefix}"
out_unknown = set(out.keys()) - VALID_OUTPUT_KEYS
if out_unknown:
findings.append(Finding("S3", "warning",
f"{out_prefix}Unknown output keys: {', '.join(sorted(out_unknown))}"))
fmt = out.get("format")
if not fmt:
findings.append(Finding("O2", "warning",
f"{out_prefix}Missing output.format (defaults to 'es')",
"Explicitly set format: 'es', 'cjs', 'iife', 'umd', 'amd', or 'system'"))
if out.get("file") and out.get("dir"):
findings.append(Finding("O3", "warning",
f"{out_prefix}Both output.file and output.dir specified",
"Use file for single-file output or dir for code-splitting"))
if fmt in ("iife", "umd") and not out.get("name"):
findings.append(Finding("O4", "warning",
f"{out_prefix}format '{fmt}' requires output.name for the global variable",
"Set name: 'MyLibrary' for browser/UMD builds"))
if out.get("sourcemap") and not out.get("sourcemapExcludeSources"):
pass # O6 only if sourcemap is true, but this is very optional
if len(outputs) > 1:
format_files = {}
for out in outputs:
if isinstance(out, dict):
key = (out.get("format", "es"), out.get("file"), out.get("dir"))
if key in format_files and not key[1] and not key[2]:
findings.append(Finding("O5", "warning",
f"{prefix}Multiple outputs with format '{key[0]}' without distinct file/dir"))
format_files[key] = True
external = config.get("external", [])
if isinstance(external, list):
for ext in external:
if isinstance(ext, str) and ext.startswith("/") and ext.endswith("/"):
findings.append(Finding("E2", "warning",
f"{prefix}Regex pattern in external: {ext}",
"Regex externals are fragile; prefer explicit module names or a function"))
plugins = config.get("plugins", [])
if isinstance(plugins, list):
plugin_names = [get_plugin_name(p) for p in plugins]
resolve_idx = -1
commonjs_idx = -1
for i, pn in enumerate(plugin_names):
if "node-resolve" in pn or "resolve" == pn:
resolve_idx = i
if "commonjs" in pn:
commonjs_idx = i
if resolve_idx >= 0 and commonjs_idx >= 0 and resolve_idx > commonjs_idx:
findings.append(Finding("P1", "warning",
f"{prefix}@rollup/plugin-node-resolve should come before @rollup/plugin-commonjs",
"Resolve must locate modules before commonjs transforms them"))
if commonjs_idx >= 0 and resolve_idx < 0:
findings.append(Finding("P2", "warning",
f"{prefix}@rollup/plugin-commonjs without @rollup/plugin-node-resolve",
"commonjs plugin needs node-resolve to find node_modules packages"))
for pn in plugin_names:
if pn in DEPRECATED_PLUGINS:
findings.append(Finding("P4", "warning",
f"{prefix}Deprecated plugin: {pn}",
f"Replace with {DEPRECATED_PLUGINS[pn]}"))
treeshake = config.get("treeshake")
if treeshake is False:
findings.append(Finding("T1", "warning",
f"{prefix}treeshake: false disables dead code elimination",
"Only disable for debugging; re-enable for production builds"))
elif isinstance(treeshake, dict):
if treeshake.get("moduleSideEffects") is False:
findings.append(Finding("T2", "warning",
f"{prefix}treeshake.moduleSideEffects: false may break libraries with side effects",
"Consider using sideEffects field in package.json instead"))
watch = config.get("watch")
if isinstance(watch, dict):
if "clearScreen" not in watch:
findings.append(Finding("B3", "warning",
f"{prefix}watch config without clearScreen setting",
"Set clearScreen: false to preserve terminal output during watch"))
return findings
def validate(config_data, filepath):
configs = normalize_config(config_data)
all_findings = []
for i, config in enumerate(configs):
if not isinstance(config, dict):
all_findings.append(Finding("S2", "error", f"Config #{i+1} is not an object"))
continue
idx = i + 1 if len(configs) > 1 else None
all_findings.extend(validate_single(config, filepath, idx))
return all_findings
def format_text(findings, filepath):
if not findings:
return f"✅ {filepath}: No issues found"
lines = [f"📋 {filepath}: {len(findings)} issue(s) found\n"]
for f in findings:
lines.append(f.to_text())
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
lines.append(f"\n{'❌' if errors else '⚠️'} {errors} error(s), {warnings} warning(s)")
return "\n".join(lines)
def format_json(findings, filepath):
return json.dumps({
"file": filepath,
"findings": [f.to_dict() for f in findings],
"summary": {
"errors": sum(1 for f in findings if f.severity == "error"),
"warnings": sum(1 for f in findings if f.severity == "warning"),
"total": len(findings),
}
}, indent=2)
def format_summary(findings, filepath):
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
status = "FAIL" if errors else ("WARN" if warnings else "PASS")
return f"{status} | {filepath} | {errors} errors, {warnings} warnings"
def explain_config(config_data, filepath):
configs = normalize_config(config_data)
lines = [f"📖 Rollup Config Explanation: {filepath}\n"]
for i, config in enumerate(configs):
if not isinstance(config, dict):
continue
if len(configs) > 1:
lines.append(f"--- Config #{i+1} ---")
inp = config.get("input")
if inp:
if isinstance(inp, dict):
lines.append(f"Entry points: {json.dumps(inp)}")
elif isinstance(inp, list):
lines.append(f"Entry points: {', '.join(inp)}")
else:
lines.append(f"Entry point: {inp}")
output = config.get("output")
if output:
outputs = output if isinstance(output, list) else [output]
lines.append(f"\nOutput ({len(outputs)} target{'s' if len(outputs) > 1 else ''}):")
for j, out in enumerate(outputs):
if isinstance(out, dict):
fmt = out.get("format", "es")
dest = out.get("file") or out.get("dir") or "(no path)"
name = out.get("name", "")
desc = f" • [{fmt}] → {dest}"
if name:
desc += f" (global: {name})"
lines.append(desc)
external = config.get("external", [])
if external:
if isinstance(external, list):
lines.append(f"\nExternal modules: {', '.join(str(e) for e in external[:10])}")
if len(external) > 10:
lines.append(f" ... and {len(external) - 10} more")
plugins = config.get("plugins", [])
if plugins:
lines.append(f"\nPlugins ({len(plugins)}):")
for p in plugins:
pn = get_plugin_name(p)
lines.append(f" • {pn or '(anonymous)'}")
treeshake = config.get("treeshake")
if treeshake is False:
lines.append("\nTreeshake: DISABLED")
elif isinstance(treeshake, dict):
lines.append(f"\nTreeshake: custom ({json.dumps(treeshake, default=str)[:80]})")
return "\n".join(lines)
def suggest_improvements(config_data, filepath):
configs = normalize_config(config_data)
lines = [f"💡 Suggestions for {filepath}\n"]
suggestions = []
for config in configs:
if not isinstance(config, dict):
continue
plugins = config.get("plugins", [])
plugin_names = [get_plugin_name(p) for p in plugins] if isinstance(plugins, list) else []
has_resolve = any("resolve" in pn for pn in plugin_names)
has_commonjs = any("commonjs" in pn for pn in plugin_names)
has_terser = any("terser" in pn for pn in plugin_names)
if not has_resolve:
suggestions.append("Add @rollup/plugin-node-resolve to resolve node_modules imports")
if not has_commonjs and has_resolve:
suggestions.append("Add @rollup/plugin-commonjs to handle CommonJS dependencies")
if not has_terser:
suggestions.append("Consider @rollup/plugin-terser for production minification")
output = config.get("output")
if output:
outputs = output if isinstance(output, list) else [output]
formats = [o.get("format") for o in outputs if isinstance(o, dict)]
if "es" not in formats and "esm" not in formats:
suggestions.append("Consider adding an ESM output format for modern bundlers")
if "cjs" not in formats and "commonjs" not in formats:
suggestions.append("Consider adding a CJS output for Node.js compatibility")
treeshake = config.get("treeshake")
if treeshake is False:
suggestions.append("Re-enable treeshake for production builds to reduce bundle size")
for pn in plugin_names:
if pn in DEPRECATED_PLUGINS:
suggestions.append(f"Migrate {pn} → {DEPRECATED_PLUGINS[pn]}")
if not suggestions:
lines.append("No suggestions — config looks good!")
else:
for s in list(dict.fromkeys(suggestions)):
lines.append(f" • {s}")
return "\n".join(lines)
def main():
if len(sys.argv) < 3:
print("Usage: rollup_config_validator.py <command> <file> [--format text|json|summary] [--strict]")
print("Commands: validate, check, explain, suggest")
sys.exit(2)
command = sys.argv[1]
filepath = sys.argv[2]
fmt = "text"
strict = False
for i, arg in enumerate(sys.argv[3:], 3):
if arg == "--format" and i + 1 < len(sys.argv):
fmt = sys.argv[i + 1]
if arg == "--strict":
strict = True
config_data, error = load_config(filepath)
if error:
if command in ("validate", "check"):
finding = Finding("S1" if "not found" in error else "S4", "error", error)
if fmt == "json":
print(format_json([finding], filepath))
elif fmt == "summary":
print(format_summary([finding], filepath))
else:
print(finding.to_text())
sys.exit(2 if "not found" in error else 1)
else:
print(f"❌ {error}")
sys.exit(2)
if command == "explain":
print(explain_config(config_data, filepath))
sys.exit(0)
if command == "suggest":
print(suggest_improvements(config_data, filepath))
sys.exit(0)
findings = validate(config_data, filepath)
if command == "check":
findings = [f for f in findings if f.rule_id.startswith("S")]
if strict:
for f in findings:
if f.severity == "warning":
f.severity = "error"
if fmt == "json":
print(format_json(findings, filepath))
elif fmt == "summary":
print(format_summary(findings, filepath))
else:
print(format_text(findings, filepath))
errors = sum(1 for f in findings if f.severity == "error")
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()
Validate Babel config files (babel.config.json, .babelrc, .babelrc.json, package.json#babel) for deprecated presets, plugin conflicts, ordering issues, and b...
---
name: babel-config-validator
description: Validate Babel config files (babel.config.json, .babelrc, .babelrc.json, package.json#babel) for deprecated presets, plugin conflicts, ordering issues, and best practices. Use when validating Babel transpiler configs, auditing build setups, migrating Babel versions, or linting babel config files.
---
# Babel Config Validator
Validate `babel.config.json`, `.babelrc`, `.babelrc.json`, and `package.json#babel` for deprecated presets/plugins, conflicting transforms, ordering issues, and best practices. Supports text, JSON, and summary output formats with CI-friendly exit codes.
## Commands
```bash
# Full validation (all 24+ rules)
python3 scripts/babel_config_validator.py validate babel.config.json
# Quick syntax-only check (structure rules only)
python3 scripts/babel_config_validator.py check .babelrc
# Explain config in human-readable form
python3 scripts/babel_config_validator.py explain babel.config.json
# Suggest improvements
python3 scripts/babel_config_validator.py suggest package.json
# JSON output (CI-friendly)
python3 scripts/babel_config_validator.py validate .babelrc --format json
# Summary only (pass/fail + counts)
python3 scripts/babel_config_validator.py validate .babelrc --format summary
# Strict mode (warnings become errors)
python3 scripts/babel_config_validator.py validate .babelrc --strict
```
## Rules (24+)
| # | Category | Severity | Rule |
|---|----------|----------|------|
| S1 | Structure | Error | File not found or unreadable |
| S2 | Structure | Error | Empty config or no content |
| S3 | Structure | Warning | Both babel.config and .babelrc present (conflict) |
| S4 | Structure | Warning | Unknown top-level config keys |
| S5 | Structure | Error | Invalid JSON syntax |
| P1 | Presets | Error | Deprecated preset (es2015, es2016, es2017, latest, stage-*) |
| P2 | Presets | Warning | Preset ordering matters (@babel/preset-typescript before @babel/preset-env) |
| P3 | Presets | Warning | Duplicate presets |
| P4 | Presets | Error | Unknown/misspelled preset name |
| P5 | Presets | Warning | Missing @babel/preset-env (most configs need it) |
| L1 | Plugins | Error | Deprecated plugin (@babel/plugin-proposal-* → built-in) |
| L2 | Plugins | Warning | Duplicate plugins |
| L3 | Plugins | Warning | Plugin ordering conflict (decorators before class-properties) |
| L4 | Plugins | Warning | Conflicting plugins (transform-runtime + external-helpers) |
| L5 | Plugins | Warning | Plugin without @babel/ scope (may be community or typo) |
| M1 | Modules | Warning | modules: false in preset-env without bundler context |
| M2 | Modules | Warning | sourceType mismatch with modules setting |
| M3 | Modules | Warning | Conflicting module transforms |
| E1 | Env/Overrides | Warning | Empty env config section |
| E2 | Env/Overrides | Warning | Override without test pattern |
| E3 | Env/Overrides | Warning | Unknown env name (not development/production/test) |
| B1 | Best Practices | Warning | loose mode inconsistency across plugins |
| B2 | Best Practices | Warning | Missing targets/browserslist (unoptimized output) |
| B3 | Best Practices | Warning | useBuiltIns without corejs version |
| B4 | Best Practices | Warning | corejs version outdated (< 3) |
## Output Formats
- `text` (default): Human-readable with colors and severity icons
- `json`: Machine-parseable JSON array of findings
- `summary`: Pass/fail with error/warning counts
## Exit Codes
- `0`: No errors (warnings only or clean)
- `1`: One or more errors found
- `2`: File not found or invalid input
## Requirements
- Python 3.8+
- No external dependencies (pure stdlib)
FILE:STATUS.md
# babel-config-validator — Status
- **Status:** Ready to publish
- **Price:** $49
- **Built:** 2026-04-22
- **Rules:** 24+
- **Lines:** ~400
- **Dependencies:** Pure Python stdlib
FILE:scripts/babel_config_validator.py
#!/usr/bin/env python3
"""Babel Config Validator — validate babel.config.json, .babelrc, .babelrc.json, package.json#babel."""
import json
import os
import re
import sys
from pathlib import Path
VALID_TOP_KEYS = {
"presets", "plugins", "env", "overrides", "sourceType", "assumptions",
"targets", "browserslistConfigFile", "browserslistEnv", "caller",
"minified", "comments", "retainLines", "compact", "auxiliaryCommentBefore",
"auxiliaryCommentAfter", "shouldPrintComment", "moduleIds", "moduleId",
"getModuleId", "moduleRoot", "sourceMaps", "sourceMap", "sourceFileName",
"sourceRoot", "parserOpts", "generatorOpts", "passPerPreset", "inputSourceMap",
"wrapPluginVisitorMethod", "highlightCode", "include", "exclude", "ignore",
"only", "test", "extends", "cwd", "root", "rootMode", "envName", "configFile",
"babelrc", "babelrcRoots", "filename", "filenameRelative", "code", "ast",
"cloneInputAst",
}
DEPRECATED_PRESETS = {
"es2015": "@babel/preset-env",
"es2016": "@babel/preset-env",
"es2017": "@babel/preset-env",
"latest": "@babel/preset-env",
"stage-0": "explicit plugins",
"stage-1": "explicit plugins",
"stage-2": "explicit plugins",
"stage-3": "explicit plugins",
"babel-preset-es2015": "@babel/preset-env",
"babel-preset-es2016": "@babel/preset-env",
"babel-preset-es2017": "@babel/preset-env",
"babel-preset-latest": "@babel/preset-env",
"babel-preset-stage-0": "explicit plugins",
"babel-preset-stage-1": "explicit plugins",
"babel-preset-stage-2": "explicit plugins",
"babel-preset-stage-3": "explicit plugins",
}
DEPRECATED_PLUGINS = {
"@babel/plugin-proposal-class-properties": "Built-in since Babel 7.14",
"@babel/plugin-proposal-private-methods": "Built-in since Babel 7.14",
"@babel/plugin-proposal-private-property-in-object": "Built-in since Babel 7.14",
"@babel/plugin-proposal-numeric-separator": "Built-in since Babel 7.14",
"@babel/plugin-proposal-nullish-coalescing-operator": "Built-in since Babel 7.14",
"@babel/plugin-proposal-optional-chaining": "Built-in since Babel 7.14",
"@babel/plugin-proposal-optional-catch-binding": "Built-in since Babel 7.14",
"@babel/plugin-proposal-json-strings": "Built-in since Babel 7.14",
"@babel/plugin-proposal-async-generator-functions": "Built-in since Babel 7.14",
"@babel/plugin-proposal-object-rest-spread": "Built-in since Babel 7.14",
"@babel/plugin-proposal-unicode-property-regex": "Built-in since Babel 7.14",
"@babel/plugin-proposal-export-namespace-from": "Built-in since Babel 7.14",
"@babel/plugin-proposal-logical-assignment-operators": "Built-in since Babel 7.14",
"@babel/plugin-proposal-class-static-block": "Built-in since Babel 7.14",
"babel-plugin-transform-class-properties": "@babel/plugin-transform-class-properties",
"babel-plugin-transform-object-rest-spread": "@babel/plugin-transform-object-rest-spread",
"babel-plugin-transform-runtime": "@babel/plugin-transform-runtime",
}
KNOWN_PRESETS = {
"@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript",
"@babel/preset-flow",
}
CONFLICTING_PLUGINS = [
({"@babel/plugin-transform-runtime"}, {"@babel/plugin-external-helpers"},
"transform-runtime and external-helpers serve similar purposes"),
]
def normalize_name(name):
if isinstance(name, list):
name = name[0] if name else ""
if isinstance(name, str):
return name.strip()
return ""
def get_preset_name(preset):
if isinstance(preset, str):
return preset
if isinstance(preset, list) and len(preset) > 0:
return str(preset[0])
return ""
def get_plugin_name(plugin):
if isinstance(plugin, str):
return plugin
if isinstance(plugin, list) and len(plugin) > 0:
return str(plugin[0])
return ""
def get_plugin_options(plugin):
if isinstance(plugin, list) and len(plugin) > 1:
return plugin[1] if isinstance(plugin[1], dict) else {}
return {}
def load_config(filepath):
path = Path(filepath)
if not path.exists():
return None, f"File not found: {filepath}"
try:
text = path.read_text(encoding="utf-8").strip()
except Exception as e:
return None, f"Cannot read file: {e}"
if not text:
return None, "File is empty"
if path.name == "package.json":
try:
pkg = json.loads(text)
if "babel" not in pkg:
return None, "No 'babel' key in package.json"
return pkg["babel"], None
except json.JSONDecodeError as e:
return None, f"Invalid JSON: {e}"
else:
comment_re = re.compile(r'//.*?$|/\*.*?\*/', re.MULTILINE | re.DOTALL)
cleaned = comment_re.sub('', text)
cleaned = re.sub(r',(\s*[}\]])', r'\1', cleaned)
try:
return json.loads(cleaned), None
except json.JSONDecodeError as e:
return None, f"Invalid JSON: {e}"
def find_sibling_configs(filepath):
directory = Path(filepath).parent
configs = []
names = ["babel.config.json", "babel.config.js", "babel.config.cjs", "babel.config.mjs",
".babelrc", ".babelrc.json", ".babelrc.js", ".babelrc.cjs", ".babelrc.mjs"]
for name in names:
p = directory / name
if p.exists() and str(p.resolve()) != str(Path(filepath).resolve()):
configs.append(name)
pkg = directory / "package.json"
if pkg.exists() and Path(filepath).name != "package.json":
try:
d = json.loads(pkg.read_text())
if "babel" in d:
configs.append("package.json#babel")
except Exception:
pass
return configs
class Finding:
def __init__(self, rule_id, severity, message, detail=None):
self.rule_id = rule_id
self.severity = severity
self.message = message
self.detail = detail
def to_dict(self):
d = {"rule": self.rule_id, "severity": self.severity, "message": self.message}
if self.detail:
d["detail"] = self.detail
return d
def to_text(self):
icon = "❌" if self.severity == "error" else "⚠️"
s = f"{icon} [{self.rule_id}] {self.message}"
if self.detail:
s += f"\n → {self.detail}"
return s
def validate(config, filepath):
findings = []
siblings = find_sibling_configs(filepath)
if siblings:
findings.append(Finding("S3", "warning",
f"Multiple Babel configs detected: {', '.join(siblings)}",
"Having both babel.config and .babelrc can cause unexpected behavior"))
unknown = set(config.keys()) - VALID_TOP_KEYS
if unknown:
findings.append(Finding("S4", "warning",
f"Unknown top-level keys: {', '.join(sorted(unknown))}"))
presets = config.get("presets", [])
if isinstance(presets, list):
preset_names = [get_preset_name(p) for p in presets]
for pn in preset_names:
if pn in DEPRECATED_PRESETS:
findings.append(Finding("P1", "error",
f"Deprecated preset: {pn}",
f"Replace with {DEPRECATED_PRESETS[pn]}"))
ts_idx = -1
env_idx = -1
for i, pn in enumerate(preset_names):
if "preset-typescript" in pn:
ts_idx = i
if "preset-env" in pn:
env_idx = i
if ts_idx >= 0 and env_idx >= 0 and ts_idx > env_idx:
findings.append(Finding("P2", "warning",
"@babel/preset-typescript should come before @babel/preset-env in presets array",
"Babel applies presets in reverse order; TypeScript must be stripped before env transforms"))
seen = {}
for pn in preset_names:
if pn:
norm = pn.replace("babel-preset-", "@babel/preset-")
if norm in seen:
findings.append(Finding("P3", "warning", f"Duplicate preset: {pn}"))
seen[norm] = True
for pn in preset_names:
if pn and not pn.startswith("./") and not pn.startswith("module:"):
norm = pn
if not norm.startswith("@") and not norm.startswith("babel-preset-"):
norm = f"@babel/preset-{norm}" if not norm.startswith("@babel/") else norm
if norm in KNOWN_PRESETS:
continue
if pn.startswith("@babel/preset-") and pn not in KNOWN_PRESETS:
findings.append(Finding("P4", "error",
f"Unknown @babel preset: {pn}",
"Check for typos in preset name"))
has_env = any("preset-env" in pn for pn in preset_names)
if not has_env and preset_names:
findings.append(Finding("P5", "warning",
"Missing @babel/preset-env",
"Most Babel configs need preset-env for browser/node targeting"))
plugins = config.get("plugins", [])
if isinstance(plugins, list):
plugin_names = [get_plugin_name(p) for p in plugins]
for pn in plugin_names:
if pn in DEPRECATED_PLUGINS:
findings.append(Finding("L1", "error",
f"Deprecated plugin: {pn}",
DEPRECATED_PLUGINS[pn]))
seen = {}
for pn in plugin_names:
if pn:
if pn in seen:
findings.append(Finding("L2", "warning", f"Duplicate plugin: {pn}"))
seen[pn] = True
deco_idx = -1
class_prop_idx = -1
for i, pn in enumerate(plugin_names):
if "decorators" in pn:
deco_idx = i
if "class-properties" in pn or "class-prop" in pn:
class_prop_idx = i
if deco_idx >= 0 and class_prop_idx >= 0 and deco_idx > class_prop_idx:
findings.append(Finding("L3", "warning",
"Decorators plugin should come before class-properties",
"Plugin ordering matters — decorators must be processed first"))
plugin_set = set(plugin_names)
for group_a, group_b, msg in CONFLICTING_PLUGINS:
if group_a & plugin_set and group_b & plugin_set:
findings.append(Finding("L4", "warning",
f"Potentially conflicting plugins: {msg}"))
for pn in plugin_names:
if pn and not pn.startswith("@") and not pn.startswith("./") and not pn.startswith("module:") and not pn.startswith("babel-plugin-"):
findings.append(Finding("L5", "warning",
f"Plugin without @babel/ scope: {pn}",
"May be a community plugin or a typo"))
if isinstance(presets, list):
for p in presets:
opts = {}
if isinstance(p, list) and len(p) > 1 and isinstance(p[1], dict):
opts = p[1]
pname = get_preset_name(p)
else:
continue
if "preset-env" in pname:
modules = opts.get("modules")
if modules is False:
findings.append(Finding("M1", "warning",
"modules: false in preset-env",
"This disables module transformation — only correct if a bundler (webpack/rollup/vite) handles modules"))
use_built_ins = opts.get("useBuiltIns")
corejs = opts.get("corejs")
if use_built_ins and use_built_ins != False and not corejs:
findings.append(Finding("B3", "warning",
f"useBuiltIns: '{use_built_ins}' without corejs version",
"Set corejs: 3 (or { version: 3, proposals: true })"))
if corejs:
ver = corejs
if isinstance(corejs, dict):
ver = corejs.get("version", 0)
try:
if float(ver) < 3:
findings.append(Finding("B4", "warning",
f"corejs version {ver} is outdated",
"Upgrade to corejs: 3 for better polyfill coverage"))
except (TypeError, ValueError):
pass
source_type = config.get("sourceType")
if source_type and isinstance(presets, list):
for p in presets:
if isinstance(p, list) and len(p) > 1 and isinstance(p[1], dict):
modules = p[1].get("modules")
if source_type == "script" and modules not in (False, "commonjs", "cjs"):
findings.append(Finding("M2", "warning",
f"sourceType: 'script' but modules is '{modules}'",
"sourceType should match module transform setting"))
envs = config.get("env", {})
if isinstance(envs, dict):
known_envs = {"development", "production", "test", "staging"}
for env_name, env_config in envs.items():
if not env_config or (isinstance(env_config, dict) and not env_config):
findings.append(Finding("E1", "warning",
f"Empty env config section: '{env_name}'"))
if env_name not in known_envs:
findings.append(Finding("E3", "warning",
f"Uncommon env name: '{env_name}'",
f"Common env names: {', '.join(sorted(known_envs))}"))
overrides = config.get("overrides", [])
if isinstance(overrides, list):
for i, ov in enumerate(overrides):
if isinstance(ov, dict) and "test" not in ov and "include" not in ov and "exclude" not in ov:
findings.append(Finding("E2", "warning",
f"Override #{i+1} has no test/include/exclude pattern",
"Overrides without a file pattern apply to all files"))
if isinstance(plugins, list):
loose_settings = {}
for p in plugins:
opts = get_plugin_options(p)
pn = get_plugin_name(p)
if "loose" in opts:
loose_settings[pn] = opts["loose"]
if loose_settings:
values = set(loose_settings.values())
if len(values) > 1:
findings.append(Finding("B1", "warning",
"Inconsistent loose mode across plugins",
"Some plugins have loose: true, others false — this can cause subtle bugs"))
targets = config.get("targets")
has_targets_in_preset = False
if isinstance(presets, list):
for p in presets:
if isinstance(p, list) and len(p) > 1 and isinstance(p[1], dict):
if "targets" in p[1]:
has_targets_in_preset = True
if not targets and not has_targets_in_preset:
findings.append(Finding("B2", "warning",
"No targets or browserslist configuration",
"Without targets, Babel transpiles to ES5 which produces larger output"))
return findings
def format_text(findings, filepath):
if not findings:
return f"✅ {filepath}: No issues found"
lines = [f"📋 {filepath}: {len(findings)} issue(s) found\n"]
for f in findings:
lines.append(f.to_text())
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
lines.append(f"\n{'❌' if errors else '⚠️'} {errors} error(s), {warnings} warning(s)")
return "\n".join(lines)
def format_json(findings, filepath):
return json.dumps({
"file": filepath,
"findings": [f.to_dict() for f in findings],
"summary": {
"errors": sum(1 for f in findings if f.severity == "error"),
"warnings": sum(1 for f in findings if f.severity == "warning"),
"total": len(findings),
}
}, indent=2)
def format_summary(findings, filepath):
errors = sum(1 for f in findings if f.severity == "error")
warnings = sum(1 for f in findings if f.severity == "warning")
status = "FAIL" if errors else ("WARN" if warnings else "PASS")
return f"{status} | {filepath} | {errors} errors, {warnings} warnings"
def explain_config(config, filepath):
lines = [f"📖 Babel Config Explanation: {filepath}\n"]
presets = config.get("presets", [])
if presets:
lines.append("Presets (applied in reverse order):")
for p in presets:
pn = get_preset_name(p)
opts = {}
if isinstance(p, list) and len(p) > 1:
opts = p[1] if isinstance(p[1], dict) else {}
desc = f" • {pn}"
if opts:
desc += f" (options: {json.dumps(opts, default=str)[:80]})"
lines.append(desc)
plugins = config.get("plugins", [])
if plugins:
lines.append("\nPlugins (applied in order):")
for p in plugins:
pn = get_plugin_name(p)
lines.append(f" • {pn}")
targets = config.get("targets")
if targets:
lines.append(f"\nTargets: {json.dumps(targets, default=str)}")
envs = config.get("env", {})
if envs:
lines.append(f"\nEnvironment overrides: {', '.join(envs.keys())}")
overrides = config.get("overrides", [])
if overrides:
lines.append(f"\nFile overrides: {len(overrides)} section(s)")
return "\n".join(lines)
def suggest_improvements(config, filepath):
lines = [f"💡 Suggestions for {filepath}\n"]
suggestions = []
presets = config.get("presets", [])
preset_names = [get_preset_name(p) for p in presets] if isinstance(presets, list) else []
if not any("preset-env" in pn for pn in preset_names):
suggestions.append("Add @babel/preset-env for automatic polyfill and syntax targeting")
if any("preset-react" in pn for pn in preset_names):
for p in presets:
if isinstance(p, list) and "preset-react" in get_preset_name(p):
opts = p[1] if len(p) > 1 and isinstance(p[1], dict) else {}
if opts.get("runtime") != "automatic":
suggestions.append("Set runtime: 'automatic' in preset-react (no need to import React in every file)")
plugins = config.get("plugins", [])
if isinstance(plugins, list):
for pn_raw in plugins:
pn = get_plugin_name(pn_raw)
if pn in DEPRECATED_PLUGINS:
suggestions.append(f"Remove {pn} — {DEPRECATED_PLUGINS[pn]}")
if not config.get("targets"):
has_preset_targets = False
for p in presets:
if isinstance(p, list) and len(p) > 1 and isinstance(p[1], dict) and "targets" in p[1]:
has_preset_targets = True
if not has_preset_targets:
suggestions.append("Add targets (e.g., browserslist) to reduce output size")
assumptions = config.get("assumptions")
if not assumptions:
suggestions.append("Consider adding 'assumptions' for smaller output (e.g., noDocumentAll, setPublicClassFields)")
if not suggestions:
lines.append("No suggestions — config looks good!")
else:
for s in suggestions:
lines.append(f" • {s}")
return "\n".join(lines)
def main():
if len(sys.argv) < 3:
print("Usage: babel_config_validator.py <command> <file> [--format text|json|summary] [--strict]")
print("Commands: validate, check, explain, suggest")
sys.exit(2)
command = sys.argv[1]
filepath = sys.argv[2]
fmt = "text"
strict = False
for i, arg in enumerate(sys.argv[3:], 3):
if arg == "--format" and i + 1 < len(sys.argv):
fmt = sys.argv[i + 1]
if arg == "--strict":
strict = True
config, error = load_config(filepath)
if error:
if command in ("validate", "check"):
finding = Finding("S1" if "not found" in error else "S5", "error", error)
if fmt == "json":
print(format_json([finding], filepath))
elif fmt == "summary":
print(format_summary([finding], filepath))
else:
print(finding.to_text())
sys.exit(2 if "not found" in error else 1)
else:
print(f"❌ {error}")
sys.exit(2)
if not isinstance(config, dict):
print("❌ Config must be a JSON object")
sys.exit(1)
if command == "explain":
print(explain_config(config, filepath))
sys.exit(0)
if command == "suggest":
print(suggest_improvements(config, filepath))
sys.exit(0)
findings = validate(config, filepath)
if command == "check":
findings = [f for f in findings if f.rule_id.startswith("S")]
if strict:
for f in findings:
if f.severity == "warning":
f.severity = "error"
if fmt == "json":
print(format_json(findings, filepath))
elif fmt == "summary":
print(format_summary(findings, filepath))
else:
print(format_text(findings, filepath))
errors = sum(1 for f in findings if f.severity == "error")
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()
Validate jest.config.ts/js/json and Jest configuration in package.json for deprecated options, transform conflicts, and best practices. Use when validating J...
---
name: jest-config-validator
description: Validate jest.config.ts/js/json and Jest configuration in package.json for deprecated options, transform conflicts, and best practices. Use when validating Jest test runner configs, auditing test setups, migrating Jest versions, or linting jest.config files.
---
# Jest Config Validator
Validate `jest.config.ts`, `jest.config.js`, `jest.config.json`, and `package.json#jest` for deprecated options, transform conflicts, coverage misconfigurations, and best practices. Supports text, JSON, and summary output formats with CI-friendly exit codes.
## Commands
```bash
# Full validation (all 22+ rules)
python3 scripts/jest_config_validator.py validate jest.config.js
# Quick syntax-only check (structure rules only)
python3 scripts/jest_config_validator.py check jest.config.ts
# Explain config in human-readable form
python3 scripts/jest_config_validator.py explain jest.config.json
# Suggest improvements
python3 scripts/jest_config_validator.py suggest package.json
# JSON output (CI-friendly)
python3 scripts/jest_config_validator.py validate jest.config.js --format json
# Summary only (pass/fail + counts)
python3 scripts/jest_config_validator.py validate jest.config.js --format summary
# Strict mode (warnings become errors)
python3 scripts/jest_config_validator.py validate jest.config.js --strict
```
## Rules (22+)
| # | Category | Severity | Rule |
|---|----------|----------|------|
| S1 | Structure | Error | File not found or unreadable |
| S2 | Structure | Error | Empty config or missing module.exports/export default |
| S3 | Structure | Warning | Both jest.config and package.json#jest present (conflict) |
| S4 | Structure | Warning | Unknown top-level config keys detected |
| S5 | Structure | Error | Invalid JSON syntax (for .json configs) |
| T1 | Test Environment | Error | Invalid testEnvironment value |
| T2 | Test Environment | Warning | testEnvironment: jsdom without jest-environment-jsdom (Jest 28+) |
| T3 | Test Environment | Warning | testURL deprecated in Jest 28+ (use testEnvironmentOptions) |
| T4 | Test Environment | Warning | Empty testMatch or testPathPattern |
| X1 | Transforms | Warning | Overlapping transform patterns (conflict) |
| X2 | Transforms | Warning | ts-jest and babel-jest used together without clear separation |
| X3 | Transforms | Warning | transformIgnorePatterns too broad (may skip needed transforms) |
| X4 | Transforms | Warning | Missing transform for .tsx/.jsx when React detected |
| V1 | Coverage | Warning | collectCoverageFrom empty or too broad |
| V2 | Coverage | Warning | coverageThreshold set but collectCoverage not enabled |
| V3 | Coverage | Warning | Deprecated coverageReporters values |
| D1 | Deprecated | Warning | Deprecated Jest options detected |
| D2 | Deprecated | Warning | jest.fn() used inside config file (configs should not mock) |
| D3 | Deprecated | Warning | timers: 'fake' (old syntax, use fakeTimers object) |
| B1 | Best Practices | Info | No clearMocks/resetMocks/restoreMocks set |
| B2 | Best Practices | Warning | roots pointing outside project directory |
| B3 | Best Practices | Warning | setupFiles/setupFilesAfterFramework path pattern issues |
| B4 | Best Practices | Info | moduleNameMapper with complex regex missing comment |
| B5 | Best Practices | Warning | preset and manual config overlap |
| B6 | Best Practices | Warning | maxWorkers set to 1 in non-CI context |
## Output Formats
**text** (default): Human-readable with file path, rule code, severity, and message per finding.
**json**: Machine-readable JSON with `file`, `summary`, and `findings` array. Each finding has `rule`, `severity`, `message`, and `line` fields.
**summary**: One-line pass/fail with error/warning/info counts. Ideal for CI output gates.
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | No errors found (warnings/info may exist) |
| 1 | One or more errors found |
| 2 | File not found or parse error |
FILE:STATUS.md
Ready
FILE:scripts/jest_config_validator.py
#!/usr/bin/env python3
"""
jest-config-validator — Validate jest.config.ts/js/json and package.json#jest
for deprecated options, transform conflicts, and best practices.
Usage:
python3 jest_config_validator.py validate <file> [--format text|json|summary] [--strict]
python3 jest_config_validator.py check <file> [--format text|json|summary] [--strict]
python3 jest_config_validator.py explain <file>
python3 jest_config_validator.py suggest <file> [--format text|json|summary] [--strict]
Exit codes:
0 — No errors
1 — Errors found
2 — File not found or parse error
"""
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
SEVERITY_ERROR = "error"
SEVERITY_WARNING = "warning"
SEVERITY_INFO = "info"
SEVERITY_ORDER = {SEVERITY_ERROR: 0, SEVERITY_WARNING: 1, SEVERITY_INFO: 2}
@dataclass
class Finding:
rule: str
severity: str
message: str
line: Optional[int] = None
@dataclass
class ValidationResult:
file: str
findings: List[Finding] = field(default_factory=list)
def add(self, rule: str, severity: str, message: str, line: Optional[int] = None):
self.findings.append(Finding(rule=rule, severity=severity, message=message, line=line))
@property
def errors(self) -> List[Finding]:
return [f for f in self.findings if f.severity == SEVERITY_ERROR]
@property
def warnings(self) -> List[Finding]:
return [f for f in self.findings if f.severity == SEVERITY_WARNING]
@property
def infos(self) -> List[Finding]:
return [f for f in self.findings if f.severity == SEVERITY_INFO]
def has_errors(self) -> bool:
return len(self.errors) > 0
# ---------------------------------------------------------------------------
# Known Jest config keys (Jest 27–30 range)
# ---------------------------------------------------------------------------
KNOWN_JEST_KEYS = {
"automock", "bail", "cacheDirectory", "clearMocks", "collectCoverage",
"collectCoverageFrom", "coverageDirectory", "coveragePathIgnorePatterns",
"coverageProvider", "coverageReporters", "coverageThreshold",
"dependencyExtractor", "displayName", "extensionsToTreatAsEsm",
"fakeTimers", "forceCoverageMatch", "globalSetup", "globalTeardown",
"globals", "haste", "injectGlobals", "maxConcurrency", "maxWorkers",
"moduleDirectories", "moduleFileExtensions", "moduleNameMapper",
"modulePathIgnorePatterns", "modulePaths", "notify", "notifyMode",
"openHandlesTimeout", "preset", "prettierPath", "projects",
"reporters", "resetMocks", "resetModules", "resolver", "restoreMocks",
"rootDir", "roots", "runner", "setupFiles", "setupFilesAfterFramework",
"setupFilesAfterFramework", "slowTestThreshold", "snapshotFormat",
"snapshotResolver", "snapshotSerializers", "testEnvironment",
"testEnvironmentOptions", "testFailureExitCode", "testLocationInResults",
"testMatch", "testNamePattern", "testPathIgnorePatterns",
"testPathPattern", "testRegex", "testResultsProcessor", "testRunner",
"testSequencer", "testTimeout", "transform", "transformIgnorePatterns",
"unmockedModulePathPatterns", "verbose", "watchPathIgnorePatterns",
"watchPlugins", "watchman", "workerIdleMemoryLimit", "workerThreads",
# Deprecated but still seen
"extraGlobals", "testURL", "timers",
# package.json wrapper key (not a real jest key but allowed at top level)
"jest",
}
DEPRECATED_OPTIONS = {
# testURL is handled by T3 (check_test_environment) to avoid duplicate findings
"extraGlobals": (
"D1",
"extraGlobals is deprecated since Jest 28. Use globals instead.",
),
"timers": (
"D3",
"timers: 'fake' is deprecated syntax. Use fakeTimers: { enableGlobally: true } instead.",
),
"preprocessorIgnorePatterns": (
"D1",
"preprocessorIgnorePatterns is deprecated. Use transformIgnorePatterns instead.",
),
"scriptPreprocessor": (
"D1",
"scriptPreprocessor is deprecated. Use transform instead.",
),
"moduleLoader": (
"D1",
"moduleLoader is deprecated. Use runner instead.",
),
"testPathDirs": (
"D1",
"testPathDirs is deprecated. Use roots instead.",
),
"mapCoverage": (
"D1",
"mapCoverage is deprecated and has no effect since Jest 23.",
),
"browser": (
"D1",
"browser is deprecated since Jest 28. Use testEnvironment: 'jsdom' instead.",
),
}
DEPRECATED_COVERAGE_REPORTERS = {"lcovonly", "teamcity", "clover"}
VALID_TEST_ENVIRONMENTS = {"jsdom", "node", "node:experimental"}
# ---------------------------------------------------------------------------
# Regex helpers for JS/TS parsing
# ---------------------------------------------------------------------------
# Strip single-line comments (// ...) but not inside strings — approximate
_RE_SINGLE_COMMENT = re.compile(r"//[^\n]*")
# Strip multi-line comments
_RE_MULTI_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
# Detect module.exports = { ... } or export default { ... }
_RE_MODULE_EXPORTS = re.compile(
r"module\s*\.\s*exports\s*=|export\s+default\s+", re.MULTILINE
)
# Detect key: value pairs (approximate, handles quoted and unquoted keys)
_RE_KEY_VALUE = re.compile(
r"""(?:^|[,{]\s*)["']?(\w+)["']?\s*:\s*(.+?)(?=\s*[,}\n]|$)""",
re.MULTILINE,
)
# Detect string values (single or double quoted)
_RE_STRING_VALUE = re.compile(r"""^["'](.+?)["']$""")
# Detect testEnvironment value
_RE_TEST_ENV = re.compile(r"""testEnvironment\s*:\s*["']([^"']+)["']""")
# Detect testURL
_RE_TEST_URL = re.compile(r"""testURL\s*:\s*["']""")
# Detect timers: 'fake'
_RE_TIMERS_FAKE = re.compile(r"""timers\s*:\s*["']fake["']""")
# Detect transform block (rough extraction)
_RE_TRANSFORM_BLOCK = re.compile(
r"transform\s*:\s*\{([^}]*)\}", re.DOTALL
)
# Detect transform patterns (each line inside transform block)
_RE_TRANSFORM_ENTRY = re.compile(
r"""["']([^"']+)["']\s*:\s*["']([^"']+)["']"""
)
# transformIgnorePatterns
_RE_TRANSFORM_IGNORE = re.compile(
r"""transformIgnorePatterns\s*:\s*\[([^\]]*)\]""", re.DOTALL
)
# collectCoverage
_RE_COLLECT_COVERAGE = re.compile(
r"""collectCoverage\s*:\s*(true|false)"""
)
# collectCoverageFrom
_RE_COLLECT_FROM_BLOCK = re.compile(
r"""collectCoverageFrom\s*:\s*\[([^\]]*)\]""", re.DOTALL
)
# coverageThreshold
_RE_COVERAGE_THRESHOLD = re.compile(
r"""coverageThreshold\s*:\s*\{"""
)
# coverageReporters
_RE_COVERAGE_REPORTERS = re.compile(
r"""coverageReporters\s*:\s*\[([^\]]*)\]""", re.DOTALL
)
# testMatch / testRegex
_RE_TEST_MATCH = re.compile(
r"""testMatch\s*:\s*\[([^\]]*)\]""", re.DOTALL
)
_RE_TEST_PATH_PATTERN = re.compile(
r"""testPathPattern\s*:\s*["']([^"']*)["']"""
)
# maxWorkers
_RE_MAX_WORKERS = re.compile(
r"""maxWorkers\s*:\s*(\d+|["'][^"']+["'])"""
)
# roots
_RE_ROOTS = re.compile(r"""roots\s*:\s*\[([^\]]*)\]""", re.DOTALL)
# preset
_RE_PRESET = re.compile(r"""preset\s*:\s*["']([^"']+)["']""")
# globals key present
_RE_GLOBALS = re.compile(r"""(?<![a-zA-Z])globals\s*:""")
# moduleNameMapper block
_RE_MNM_BLOCK = re.compile(
r"""moduleNameMapper\s*:\s*\{([^}]*)\}""", re.DOTALL
)
# setupFiles / setupFilesAfterFramework
_RE_SETUP_FILES = re.compile(
r"""setupFiles(?:AfterFramework)?\s*:\s*\[([^\]]*)\]""", re.DOTALL
)
# verbose
_RE_VERBOSE = re.compile(r"""verbose\s*:\s*(true|false)""")
# reporters
_RE_REPORTERS = re.compile(r"""reporters\s*:\s*\[""")
# jest.fn() usage
_RE_JEST_FN = re.compile(r"""jest\.fn\s*\(""")
# React / JSX detection
_RE_JSX_TRANSFORM = re.compile(
r"""["'][^"']*\.jsx?["']\s*:|jsx|react""", re.IGNORECASE
)
# ts-jest and babel-jest patterns
_RE_TS_JEST = re.compile(r"""ts-jest""")
_RE_BABEL_JEST = re.compile(r"""babel-jest""")
# ---------------------------------------------------------------------------
# Config file loader
# ---------------------------------------------------------------------------
def _strip_comments(text: str) -> str:
"""Remove JS-style comments (approximate, not full parser)."""
text = _RE_MULTI_COMMENT.sub(" ", text)
text = _RE_SINGLE_COMMENT.sub("", text)
return text
def load_file(path: str) -> Tuple[Optional[str], Optional[dict], Optional[str]]:
"""
Load a Jest config file. Returns (raw_text, json_data, error).
json_data is only populated for .json files.
"""
if not os.path.exists(path):
return None, None, f"File not found: {path}"
try:
with open(path, "r", encoding="utf-8") as fh:
raw = fh.read()
except OSError as exc:
return None, None, f"Cannot read file: {exc}"
ext = os.path.splitext(path)[1].lower()
if ext == ".json":
# For package.json, extract #jest if present
if os.path.basename(path) == "package.json":
try:
pkg = json.loads(raw)
except json.JSONDecodeError as exc:
return raw, None, f"Invalid JSON in package.json: {exc}"
jest_cfg = pkg.get("jest")
if jest_cfg is None:
return raw, None, "No 'jest' key found in package.json"
return raw, jest_cfg, None
else:
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
return raw, None, f"Invalid JSON: {exc}"
return raw, data, None
# .js / .ts / .cjs / .mjs
return raw, None, None
def detect_jest_config_files(directory: str = ".") -> List[str]:
"""Find Jest config files in a directory (for S3 multi-config check)."""
candidates = [
"jest.config.js", "jest.config.ts", "jest.config.cjs",
"jest.config.mjs", "jest.config.json",
]
found = []
for name in candidates:
p = os.path.join(directory, name)
if os.path.exists(p):
found.append(p)
return found
# ---------------------------------------------------------------------------
# Individual rule checks
# ---------------------------------------------------------------------------
def check_structure(path: str, raw: str, json_data: Optional[dict], result: ValidationResult):
"""S1-S5: Structure rules."""
# S2: empty config
if not raw or not raw.strip():
result.add("S2", SEVERITY_ERROR, "Config file is empty.")
return
ext = os.path.splitext(path)[1].lower()
basename = os.path.basename(path)
if basename == "package.json" or ext == ".json":
# S5 is handled at load time (json parse error)
if json_data is None and ext == ".json" and basename != "package.json":
result.add("S5", SEVERITY_ERROR, "Invalid JSON syntax in config file.")
return
if json_data is not None:
# S4: unknown keys
unknown = set(json_data.keys()) - KNOWN_JEST_KEYS
if unknown:
result.add(
"S4", SEVERITY_WARNING,
f"Unknown top-level config key(s): {', '.join(sorted(unknown))}. "
"These will be ignored by Jest.",
)
else:
# JS/TS: check for module.exports or export default
stripped = _strip_comments(raw)
if not _RE_MODULE_EXPORTS.search(stripped):
result.add(
"S2", SEVERITY_ERROR,
"No module.exports or export default found. Jest will not load this config.",
)
# S4: detect unknown keys from JS (approximate)
found_keys = set()
for m in _RE_KEY_VALUE.finditer(stripped):
found_keys.add(m.group(1))
unknown = found_keys - KNOWN_JEST_KEYS
# Filter out common JS words that regex picks up
noise = {
"global", "branches", "functions", "lines", "statements",
"require", "path", "process", "env", "exports", "default",
"module", "true", "false", "null", "const", "let", "var",
"return", "from", "import", "type", "interface",
}
unknown -= noise
if unknown:
result.add(
"S4", SEVERITY_WARNING,
f"Possible unknown top-level config key(s): {', '.join(sorted(unknown))}. "
"Verify these are valid Jest options.",
)
def check_multi_config(path: str, result: ValidationResult):
"""S3: Check if both jest.config.* and package.json#jest exist."""
directory = os.path.dirname(os.path.abspath(path))
pkg_path = os.path.join(directory, "package.json")
basename = os.path.basename(path)
if basename == "package.json":
# Check if any jest.config.* also exists
others = detect_jest_config_files(directory)
if others:
result.add(
"S3", SEVERITY_WARNING,
f"package.json#jest is configured, but jest.config file(s) also exist: "
f"{', '.join(os.path.basename(p) for p in others)}. "
"Jest will prefer the config file over package.json#jest.",
)
elif os.path.exists(pkg_path):
try:
with open(pkg_path, "r", encoding="utf-8") as fh:
pkg = json.load(fh)
if "jest" in pkg:
result.add(
"S3", SEVERITY_WARNING,
f"Both {basename} and package.json#jest are present. "
"Jest will use the config file and ignore package.json#jest.",
)
except (OSError, json.JSONDecodeError):
pass
def check_test_environment(raw: str, json_data: Optional[dict], result: ValidationResult):
"""T1-T4: Test environment rules."""
if json_data is not None:
env = json_data.get("testEnvironment")
if env is not None:
if env not in VALID_TEST_ENVIRONMENTS and not env.startswith("<") and "/" not in env and "\\" not in env:
result.add(
"T1", SEVERITY_ERROR,
f"Invalid testEnvironment: '{env}'. Expected 'jsdom', 'node', or a custom module path.",
)
if env == "jsdom":
result.add(
"T2", SEVERITY_WARNING,
"testEnvironment: 'jsdom' requires the 'jest-environment-jsdom' package (Jest 28+). "
"Install it separately: npm install --save-dev jest-environment-jsdom",
)
# T3: testURL
if "testURL" in json_data:
result.add(
"T3", SEVERITY_WARNING,
"testURL is deprecated since Jest 28. Use testEnvironmentOptions: { url: '...' } instead.",
)
# T4: empty testMatch
tm = json_data.get("testMatch")
if tm is not None and (not tm or tm == []):
result.add("T4", SEVERITY_WARNING, "testMatch is set but empty — no tests will be found.")
tp = json_data.get("testPathPattern")
if tp is not None and tp == "":
result.add("T4", SEVERITY_WARNING, "testPathPattern is set but empty — no tests will be found.")
else:
# JS/TS parsing
m = _RE_TEST_ENV.search(raw)
if m:
env = m.group(1)
if env not in VALID_TEST_ENVIRONMENTS and "/" not in env and "\\" not in env and not env.startswith("<"):
result.add(
"T1", SEVERITY_ERROR,
f"Invalid testEnvironment: '{env}'. Expected 'jsdom', 'node', or a custom module path.",
)
if env == "jsdom":
result.add(
"T2", SEVERITY_WARNING,
"testEnvironment: 'jsdom' requires the 'jest-environment-jsdom' package (Jest 28+). "
"Install it separately: npm install --save-dev jest-environment-jsdom",
)
if _RE_TEST_URL.search(raw):
result.add(
"T3", SEVERITY_WARNING,
"testURL is deprecated since Jest 28. Use testEnvironmentOptions: { url: '...' } instead.",
)
# T4: empty testMatch
tm = _RE_TEST_MATCH.search(raw)
if tm and not tm.group(1).strip():
result.add("T4", SEVERITY_WARNING, "testMatch is set but empty — no tests will be found.")
tp = _RE_TEST_PATH_PATTERN.search(raw)
if tp and not tp.group(1).strip():
result.add("T4", SEVERITY_WARNING, "testPathPattern is set but empty — no tests will be found.")
def check_transforms(raw: str, json_data: Optional[dict], result: ValidationResult):
"""X1-X4: Transform rules."""
if json_data is not None:
transform = json_data.get("transform", {})
transform_ignore = json_data.get("transformIgnorePatterns", [])
patterns = list(transform.keys()) if isinstance(transform, dict) else []
transformers = list(transform.values()) if isinstance(transform, dict) else []
else:
patterns = []
transformers = []
transform_ignore = []
block_m = _RE_TRANSFORM_BLOCK.search(raw)
if block_m:
block = block_m.group(1)
for em in _RE_TRANSFORM_ENTRY.finditer(block):
patterns.append(em.group(1))
transformers.append(em.group(2))
ig_m = _RE_TRANSFORM_IGNORE.search(raw)
if ig_m:
for s in re.findall(r"""["']([^"']+)["']""", ig_m.group(1)):
transform_ignore.append(s)
# X1: overlapping patterns
if len(patterns) > 1:
for i in range(len(patterns)):
for j in range(i + 1, len(patterns)):
# Check for obvious overlap (one is substring/superset of other)
pi, pj = patterns[i], patterns[j]
# Both match .ts — heuristic: same extension group
exts_i = re.findall(r"\.\w+", pi)
exts_j = re.findall(r"\.\w+", pj)
overlap = set(exts_i) & set(exts_j)
if overlap and transformers[i] != transformers[j]:
result.add(
"X1", SEVERITY_WARNING,
f"Transform patterns may overlap: '{pi}' and '{pj}' both match extension(s) "
f"{overlap}. Files may be processed by the wrong transformer.",
)
# X2: ts-jest + babel-jest together
has_ts_jest = any(_RE_TS_JEST.search(t) for t in transformers)
has_babel_jest = any(_RE_BABEL_JEST.search(t) for t in transformers)
if not transformers:
has_ts_jest = bool(_RE_TS_JEST.search(raw))
has_babel_jest = bool(_RE_BABEL_JEST.search(raw))
if has_ts_jest and has_babel_jest:
result.add(
"X2", SEVERITY_WARNING,
"Both ts-jest and babel-jest are configured as transformers. "
"Ensure patterns are strictly separated to avoid conflicts (e.g., .ts files only to ts-jest).",
)
# X3: transformIgnorePatterns too broad
for pat in transform_ignore:
if pat in ("", ".*", ".+", "node_modules") or pat == "/node_modules/":
# /node_modules/ is the default and fine; flag only truly broad ones
if pat in ("", ".*", ".+"):
result.add(
"X3", SEVERITY_WARNING,
f"transformIgnorePatterns includes overly broad pattern '{pat}'. "
"This may prevent necessary transforms from running.",
)
elif re.search(r"\.\*$|\.\+$", pat) and "node_modules" not in pat:
result.add(
"X3", SEVERITY_WARNING,
f"transformIgnorePatterns pattern '{pat}' may be too broad and skip needed transforms.",
)
# X4: missing transform for JSX/TSX when React is detected
raw_lower = raw.lower()
has_react = "react" in raw_lower or "jsx" in raw_lower
if has_react and patterns:
has_jsx_transform = any(re.search(r"jsx|tsx", p) for p in patterns) or any(
re.search(r"jsx|tsx", t) for t in transformers
)
if not has_jsx_transform:
result.add(
"X4", SEVERITY_WARNING,
"React/JSX usage detected but no transform pattern covers .jsx/.tsx files. "
"Add a transform for '^.+\\.tsx?$' or '^.+\\.jsx?$'.",
)
def check_coverage(raw: str, json_data: Optional[dict], result: ValidationResult):
"""V1-V3: Coverage rules."""
if json_data is not None:
collect = json_data.get("collectCoverage", None)
collect_from = json_data.get("collectCoverageFrom", None)
threshold = json_data.get("coverageThreshold", None)
reporters = json_data.get("coverageReporters", [])
# V1
if collect_from is not None:
if not collect_from:
result.add(
"V1", SEVERITY_WARNING,
"collectCoverageFrom is empty — no files will be included in coverage.",
)
elif collect_from == ["**/*"] or collect_from == ["**"]:
result.add(
"V1", SEVERITY_WARNING,
"collectCoverageFrom is too broad ('**/*'). "
"Restrict to source directories (e.g., ['src/**/*.ts']).",
)
# V2
if threshold and collect is False:
result.add(
"V2", SEVERITY_WARNING,
"coverageThreshold is configured but collectCoverage is false. "
"Thresholds will never be checked. Set collectCoverage: true.",
)
# V3
for rep in reporters:
if isinstance(rep, str) and rep in DEPRECATED_COVERAGE_REPORTERS:
result.add(
"V3", SEVERITY_WARNING,
f"Deprecated coverageReporter: '{rep}'. "
"Use 'lcov', 'text', 'html', or 'cobertura' instead.",
)
else:
collect_m = _RE_COLLECT_COVERAGE.search(raw)
collect = None
if collect_m:
collect = collect_m.group(1) == "true"
# V1
cf_m = _RE_COLLECT_FROM_BLOCK.search(raw)
if cf_m:
content = cf_m.group(1).strip()
if not content:
result.add(
"V1", SEVERITY_WARNING,
"collectCoverageFrom is empty — no files will be included in coverage.",
)
elif re.search(r"""["']\*\*/\*["']|["']\*\*["']""", content):
result.add(
"V1", SEVERITY_WARNING,
"collectCoverageFrom is too broad ('**/*'). "
"Restrict to source directories (e.g., ['src/**/*.ts']).",
)
# V2
has_threshold = bool(_RE_COVERAGE_THRESHOLD.search(raw))
if has_threshold and collect is False:
result.add(
"V2", SEVERITY_WARNING,
"coverageThreshold is configured but collectCoverage is false. "
"Thresholds will never be checked. Set collectCoverage: true.",
)
# V3
rep_m = _RE_COVERAGE_REPORTERS.search(raw)
if rep_m:
for rep in re.findall(r"""["']([^"']+)["']""", rep_m.group(1)):
if rep in DEPRECATED_COVERAGE_REPORTERS:
result.add(
"V3", SEVERITY_WARNING,
f"Deprecated coverageReporter: '{rep}'. "
"Use 'lcov', 'text', 'html', or 'cobertura' instead.",
)
def check_deprecated(raw: str, json_data: Optional[dict], result: ValidationResult):
"""D1-D3: Deprecated / migration rules."""
if json_data is not None:
for opt, (rule, msg) in DEPRECATED_OPTIONS.items():
if opt in json_data:
if opt == "timers":
val = json_data[opt]
if val == "fake":
result.add(rule, SEVERITY_WARNING, msg)
else:
result.add(rule, SEVERITY_WARNING, msg)
else:
for opt, (rule, msg) in DEPRECATED_OPTIONS.items():
if opt == "timers":
if _RE_TIMERS_FAKE.search(raw):
result.add(rule, SEVERITY_WARNING, msg)
else:
pat = re.compile(rf"""(?<![a-zA-Z]){re.escape(opt)}\s*:""")
if pat.search(raw):
result.add(rule, SEVERITY_WARNING, msg)
# D2: jest.fn() in config file
if _RE_JEST_FN.search(raw):
result.add(
"D2", SEVERITY_WARNING,
"jest.fn() detected in config file. Config files should not contain mocks. "
"Move mocks to setupFiles or individual test files.",
)
# D1 extra: verbose + custom reporters (verbose is ignored when reporters is set)
verbose_m = _RE_VERBOSE.search(raw)
has_reporters = bool(_RE_REPORTERS.search(raw))
if verbose_m and verbose_m.group(1) == "true" and has_reporters:
result.add(
"D1", SEVERITY_WARNING,
"verbose: true has no effect when a custom reporters array is configured. "
"Add '@jest/reporters' to the reporters array if verbose output is needed.",
)
def check_best_practices(raw: str, json_data: Optional[dict], result: ValidationResult):
"""B1-B6: Best practice rules."""
if json_data is not None:
# B1
has_clear = json_data.get("clearMocks") or json_data.get("resetMocks") or json_data.get("restoreMocks")
if not has_clear:
result.add(
"B1", SEVERITY_INFO,
"None of clearMocks/resetMocks/restoreMocks is set. "
"Consider enabling clearMocks: true to avoid mock state leaking between tests.",
)
# B2
roots = json_data.get("roots", [])
for r in roots:
if isinstance(r, str) and r.startswith(".."):
result.add(
"B2", SEVERITY_WARNING,
f"roots entry '{r}' points outside the project directory. "
"This may cause unexpected test discovery.",
)
# B3
for key in ("setupFiles", "setupFilesAfterFramework"):
for sf in json_data.get(key, []):
if isinstance(sf, str) and not (sf.startswith("<") or sf.startswith(".") or sf.startswith("/")):
result.add(
"B3", SEVERITY_WARNING,
f"{key} entry '{sf}' does not look like a relative path or module. "
"Use '<rootDir>/path/to/setup.ts' or a relative path.",
)
# B4
mnm = json_data.get("moduleNameMapper", {})
if isinstance(mnm, dict):
for pattern in mnm.keys():
if len(pattern) > 30 and not pattern.startswith("^"):
result.add(
"B4", SEVERITY_INFO,
f"moduleNameMapper pattern '{pattern[:40]}...' is complex. "
"Consider adding a comment above it explaining its purpose.",
)
# B5
preset = json_data.get("preset")
if preset:
overlap_keys = {
"transform", "testEnvironment", "moduleFileExtensions",
"moduleNameMapper", "globals",
}
overlap = overlap_keys & set(json_data.keys()) - {"preset"}
if overlap:
result.add(
"B5", SEVERITY_WARNING,
f"preset '{preset}' is set alongside keys that presets typically configure: "
f"{', '.join(sorted(overlap))}. This may cause unexpected overrides.",
)
# B6
max_workers = json_data.get("maxWorkers")
ci_env = os.environ.get("CI") or os.environ.get("CONTINUOUS_INTEGRATION")
if max_workers == 1 and not ci_env:
result.add(
"B6", SEVERITY_WARNING,
"maxWorkers: 1 disables parallelism. "
"This is only recommended in CI environments. "
"Remove or increase maxWorkers for local development.",
)
else:
# B1
has_clear = (
re.search(r"""clearMocks\s*:\s*true""", raw)
or re.search(r"""resetMocks\s*:\s*true""", raw)
or re.search(r"""restoreMocks\s*:\s*true""", raw)
)
if not has_clear:
result.add(
"B1", SEVERITY_INFO,
"None of clearMocks/resetMocks/restoreMocks is set. "
"Consider enabling clearMocks: true to avoid mock state leaking between tests.",
)
# B2
roots_m = _RE_ROOTS.search(raw)
if roots_m:
for r in re.findall(r"""["']([^"']+)["']""", roots_m.group(1)):
if r.startswith(".."):
result.add(
"B2", SEVERITY_WARNING,
f"roots entry '{r}' points outside the project directory.",
)
# B3
setup_m = _RE_SETUP_FILES.search(raw)
if setup_m:
for sf in re.findall(r"""["']([^"']+)["']""", setup_m.group(1)):
if not (sf.startswith("<") or sf.startswith(".") or sf.startswith("/")):
result.add(
"B3", SEVERITY_WARNING,
f"setupFiles entry '{sf}' does not look like a relative path or <rootDir> reference.",
)
# B4
mnm_m = _RE_MNM_BLOCK.search(raw)
if mnm_m:
for pattern in re.findall(r"""["']([^"']+)["']\s*:""", mnm_m.group(1)):
if len(pattern) > 30 and not pattern.startswith("^"):
result.add(
"B4", SEVERITY_INFO,
f"moduleNameMapper pattern '{pattern[:40]}' is complex without a leading anchor. "
"Consider adding a comment explaining its purpose.",
)
# B5
preset_m = _RE_PRESET.search(raw)
if preset_m:
preset = preset_m.group(1)
overlap_keys = ["transform", "testEnvironment", "moduleFileExtensions", "moduleNameMapper"]
found_overlap = []
for k in overlap_keys:
if re.search(rf"""(?<![a-zA-Z]){re.escape(k)}\s*:""", raw):
found_overlap.append(k)
if found_overlap:
result.add(
"B5", SEVERITY_WARNING,
f"preset '{preset}' is set alongside keys that presets typically configure: "
f"{', '.join(found_overlap)}. This may cause unexpected overrides.",
)
# B6
mw_m = _RE_MAX_WORKERS.search(raw)
if mw_m:
val = mw_m.group(1).strip("\"'")
ci_env = os.environ.get("CI") or os.environ.get("CONTINUOUS_INTEGRATION")
if val == "1" and not ci_env:
result.add(
"B6", SEVERITY_WARNING,
"maxWorkers: 1 disables parallelism. "
"This is only recommended in CI environments.",
)
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def run_validate(path: str, strict: bool = False, structure_only: bool = False) -> ValidationResult:
"""Full validation (or structure-only for 'check' command)."""
result = ValidationResult(file=path)
# S1: file existence is checked in load_file
raw, json_data, err = load_file(path)
if err and raw is None:
result.add("S1", SEVERITY_ERROR, err)
return result
if err:
# JSON parse error — still report it
result.add("S5", SEVERITY_ERROR, err)
if structure_only:
return result
raw = raw or ""
check_structure(path, raw, json_data, result)
check_multi_config(path, result)
if not structure_only:
if json_data is not None or raw:
check_test_environment(raw, json_data, result)
check_transforms(raw, json_data, result)
check_coverage(raw, json_data, result)
check_deprecated(raw, json_data, result)
check_best_practices(raw, json_data, result)
if strict:
for f in result.findings:
if f.severity == SEVERITY_WARNING:
f.severity = SEVERITY_ERROR
return result
def run_explain(path: str) -> str:
"""Explain the config in human-readable form."""
raw, json_data, err = load_file(path)
if err and raw is None:
return f"Error: {err}"
raw = raw or ""
lines = [f"Config file: {path}", ""]
def get_val(key: str, default="(not set)") -> str:
if json_data is not None:
return str(json_data.get(key, default))
m = re.search(rf"""(?<![a-zA-Z]){re.escape(key)}\s*:\s*["']?([^"',\n\}}]+)["']?""", raw)
return m.group(1).strip() if m else default
lines.append(f" Test environment : {get_val('testEnvironment')}")
lines.append(f" Preset : {get_val('preset')}")
lines.append(f" Max workers : {get_val('maxWorkers')}")
lines.append(f" Collect coverage : {get_val('collectCoverage')}")
lines.append(f" Clear mocks : {get_val('clearMocks')}")
lines.append(f" Reset mocks : {get_val('resetMocks')}")
lines.append(f" Restore mocks : {get_val('restoreMocks')}")
lines.append(f" Verbose : {get_val('verbose')}")
lines.append("")
# Transforms
if json_data is not None:
transform = json_data.get("transform", {})
if transform:
lines.append(" Transforms:")
for pat, transformer in transform.items():
lines.append(f" {pat!r:40s} -> {transformer}")
else:
lines.append(" Transforms: (none configured — using Jest defaults)")
else:
block_m = _RE_TRANSFORM_BLOCK.search(raw)
if block_m:
lines.append(" Transforms:")
for em in _RE_TRANSFORM_ENTRY.finditer(block_m.group(1)):
lines.append(f" {em.group(1)!r:40s} -> {em.group(2)}")
else:
lines.append(" Transforms: (none configured — using Jest defaults)")
lines.append("")
# Coverage
if json_data is not None:
cf = json_data.get("collectCoverageFrom", [])
if cf:
lines.append(" Coverage from:")
for p in cf:
lines.append(f" {p}")
th = json_data.get("coverageThreshold")
if th:
lines.append(f" Coverage threshold: {json.dumps(th)}")
else:
cf_m = _RE_COLLECT_FROM_BLOCK.search(raw)
if cf_m:
lines.append(" Coverage from:")
for p in re.findall(r"""["']([^"']+)["']""", cf_m.group(1)):
lines.append(f" {p}")
return "\n".join(lines)
def run_suggest(path: str, strict: bool = False) -> ValidationResult:
"""Run full validation and present results as suggestions."""
return run_validate(path, strict=strict)
# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------
def format_text(result: ValidationResult, command: str = "validate") -> str:
"""Human-readable text output."""
lines = [f"jest-config-validator — {result.file}", ""]
if not result.findings:
lines.append(" No issues found.")
return "\n".join(lines)
SEV_LABEL = {SEVERITY_ERROR: "ERROR ", SEVERITY_WARNING: "WARNING", SEVERITY_INFO: "INFO "}
for f in sorted(result.findings, key=lambda x: SEVERITY_ORDER[x.severity]):
loc = f" (line {f.line})" if f.line else ""
lines.append(f" [{SEV_LABEL[f.severity]}] [{f.rule}]{loc} {f.message}")
lines.append("")
lines.append(
f" {len(result.errors)} error(s), "
f"{len(result.warnings)} warning(s), "
f"{len(result.infos)} info(s)"
)
return "\n".join(lines)
def format_summary(result: ValidationResult) -> str:
"""One-line summary."""
status = "FAIL" if result.has_errors() else "PASS"
return (
f"[{status}] {result.file}: "
f"{len(result.errors)} error(s), "
f"{len(result.warnings)} warning(s), "
f"{len(result.infos)} info(s)"
)
def format_json(result: ValidationResult) -> str:
"""JSON output."""
data = {
"file": result.file,
"summary": {
"errors": len(result.errors),
"warnings": len(result.warnings),
"infos": len(result.infos),
"pass": not result.has_errors(),
},
"findings": [
{
"rule": f.rule,
"severity": f.severity,
"message": f.message,
"line": f.line,
}
for f in sorted(result.findings, key=lambda x: SEVERITY_ORDER[x.severity])
],
}
return json.dumps(data, indent=2)
def format_explain(text: str) -> str:
return text
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="jest_config_validator",
description="Validate Jest configuration files for errors, deprecated options, and best practices.",
)
sub = parser.add_subparsers(dest="command", required=True)
def add_common(p):
p.add_argument("file", help="Path to jest.config.js/ts/json or package.json")
p.add_argument(
"--format",
choices=["text", "json", "summary"],
default="text",
help="Output format (default: text)",
)
p.add_argument(
"--strict",
action="store_true",
help="Treat warnings as errors",
)
p_validate = sub.add_parser("validate", help="Full validation (all rules)")
add_common(p_validate)
p_check = sub.add_parser("check", help="Quick syntax/structure check only")
add_common(p_check)
p_explain = sub.add_parser("explain", help="Explain config in human-readable form")
p_explain.add_argument("file", help="Path to Jest config file")
p_suggest = sub.add_parser("suggest", help="Suggest improvements")
add_common(p_suggest)
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if args.command == "explain":
text = run_explain(args.file)
print(text)
sys.exit(0)
structure_only = args.command == "check"
result = run_validate(args.file, strict=args.strict, structure_only=structure_only)
fmt = args.format
if fmt == "json":
print(format_json(result))
elif fmt == "summary":
print(format_summary(result))
else:
print(format_text(result, command=args.command))
# Exit codes
# 2 = file not found / parse error (S1 or S5 as only finding)
if result.findings and all(f.rule in ("S1", "S5") for f in result.findings):
sys.exit(2)
sys.exit(1 if result.has_errors() else 0)
if __name__ == "__main__":
main()
Validate vitest.config.ts/js and vitest workspace configurations for syntax, deprecated options, plugin conflicts, and best practices. Use when validating Vi...
---
name: vitest-config-validator
description: Validate vitest.config.ts/js and vitest workspace configurations for syntax, deprecated options, plugin conflicts, and best practices. Use when validating Vitest test runner configs, auditing test setups, or linting vitest.config files.
---
# Vitest Config Validator
Validate `vitest.config.ts` and `vitest.config.js` files for syntax errors, deprecated options, plugin conflicts, and best practices. Parses config files as text using regex patterns — no JS execution required.
## Commands
```bash
# Full validation (all rules)
python3 scripts/vitest_config_validator.py validate vitest.config.ts
# Quick syntax-only check (structure rules only)
python3 scripts/vitest_config_validator.py check vitest.config.ts
# Explain config in human-readable form
python3 scripts/vitest_config_validator.py explain vitest.config.ts
# Suggest improvements
python3 scripts/vitest_config_validator.py suggest vitest.config.ts
# JSON output
python3 scripts/vitest_config_validator.py validate vitest.config.ts --format json
# One-line PASS/WARN/FAIL summary
python3 scripts/vitest_config_validator.py validate vitest.config.ts --format summary
# Strict mode (warnings become errors)
python3 scripts/vitest_config_validator.py validate vitest.config.ts --strict
```
## Rules (22)
| # | Category | Severity | Rule |
|---|----------|----------|------|
| S1 | Structure | E | File not found or unreadable |
| S2 | Structure | E | Empty config or no defineConfig call |
| S3 | Structure | W | No default export found |
| S4 | Structure | W | Both vitest.config and vite.config with test section detected |
| S5 | Structure | W | Unknown top-level config keys |
| T1 | Test Settings | E | Invalid test environment (must be jsdom/happy-dom/node/edge-runtime) |
| T2 | Test Settings | W | Empty include or exclude patterns |
| T3 | Test Settings | E | Invalid glob pattern in include/exclude |
| T4 | Test Settings | I | Coverage provider not set (recommend c8/v8/istanbul) |
| T5 | Test Settings | W | testTimeout set unreasonably high (>60000ms) or low (<100ms) |
| P1 | Performance | W | singleThread: true used with forks pool (disables parallelism) |
| P2 | Performance | W | isolate: false without comment (risky for test isolation) |
| P3 | Performance | I | No pool configuration (defaults may not be optimal) |
| P4 | Performance | W | globals: true without type declaration reference |
| C1 | Compatibility | W | Deprecated option used |
| C2 | Compatibility | W | css.modules without css.include (potential miss) |
| C3 | Compatibility | W | deps.inline and deps.external conflict |
| B1 | Best Practices | I | No reporter configured |
| B2 | Best Practices | I | Missing coverage configuration |
| B3 | Best Practices | W | setupFiles references potentially non-existent pattern |
| B4 | Best Practices | I | snapshotFormat not explicitly configured |
| B5 | Best Practices | I | passWithNoTests not set (CI may fail on empty test suite) |
## Output Formats
- **text** (default): Human-readable with `[E]`/`[W]`/`[I]` severity prefix
- **json**: Machine-readable structured output
- **summary**: Single-line `PASS` / `WARN` / `FAIL`
## Exit Codes
- `0` — No errors
- `1` — Errors found (or warnings in `--strict` mode)
- `2` — File not found or parse error
FILE:STATUS.md
Ready
FILE:scripts/vitest_config_validator.py
#!/usr/bin/env python3
"""
vitest_config_validator.py — Validate vitest.config.ts/js configurations.
Commands:
validate Full validation (all 22 rules)
check Quick syntax-only check (structure rules only)
explain Human-readable explanation of the config
suggest Suggest improvements
Flags:
--format text|json|summary Output format (default: text)
--strict Treat warnings as errors
Exit codes:
0 No errors
1 Errors found (or warnings in --strict mode)
2 File not found or parse error
"""
import re
import sys
import os
import json
import argparse
from typing import List, Tuple, Dict, Optional, Any
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
VALID_ENVIRONMENTS = {"jsdom", "happy-dom", "node", "edge-runtime"}
VALID_COVERAGE_PROVIDERS = {"c8", "v8", "istanbul"}
VALID_POOLS = {"threads", "forks", "vmThreads", "vmForks"}
KNOWN_TOP_LEVEL_KEYS = {
"test",
"plugins",
"resolve",
"define",
"root",
"mode",
"build",
"server",
"optimizeDeps",
"css",
"publicDir",
"assetsInclude",
"esbuild",
"worker",
"logLevel",
"clearScreen",
"envDir",
"envPrefix",
"appType",
"experimental",
"ssr",
"base",
"cacheDir",
"configFile",
}
KNOWN_TEST_KEYS = {
"include",
"exclude",
"includeSource",
"name",
"environment",
"globals",
"setupFiles",
"globalSetup",
"reporters",
"reporter",
"outputFile",
"coverage",
"testTimeout",
"hookTimeout",
"teardownTimeout",
"silent",
"open",
"api",
"bail",
"isolate",
"pool",
"poolOptions",
"singleThread",
"maxConcurrency",
"watch",
"watchExclude",
"forceRerunTriggers",
"update",
"snapshotFormat",
"resolveSnapshotPath",
"allowOnly",
"passWithNoTests",
"logHeapUsage",
"deps",
"css",
"sequence",
"typecheck",
"benchmark",
"alias",
"threads",
"minThreads",
"maxThreads",
"cache",
"clearMocks",
"resetMocks",
"restoreMocks",
"mockReset",
"unstubEnvs",
"unstubGlobals",
"diff",
"chaiConfig",
"fakeTimers",
"retry",
"dangerouslyIgnoreUnhandledErrors",
"slowTestThreshold",
"inspect",
"inspectBrk",
"fileParallelism",
"projects",
"workspace",
"server",
"browser",
"runner",
"testNamePattern",
"disableConsoleIntercept",
"printConsoleTrace",
"onConsoleLog",
"onStackTrace",
"onTestFailed",
"onTestFinished",
}
# Deprecated options (vitest < 1.0 names that were renamed)
DEPRECATED_OPTIONS: Dict[str, str] = {
r"\bthreads\s*:\s*false\b": "threads:false is deprecated; use pool:'forks' or singleThread instead",
r"\bthreads\s*:\s*true\b": "threads:true is deprecated; use pool:'threads' instead",
r"\bminThreads\b": "minThreads is deprecated; use poolOptions.threads.minThreads",
r"\bmaxThreads\b": "maxThreads is deprecated; use poolOptions.threads.maxThreads",
r"\bdeps\.registerNodeLoader\b": "deps.registerNodeLoader is removed in Vitest 1.x",
r"\bdeps\.experimentalOptimizer\b": "deps.experimentalOptimizer is deprecated; use deps.optimizer",
r"\bsuite\b\s*:": "suite: key is not a valid Vitest option",
r"\bspecs\b\s*:": "specs: is not valid; use include: instead",
r"\btestFiles\b\s*:": "testFiles: is not valid; use include: instead",
}
# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------
class Issue:
def __init__(self, rule_id: str, severity: str, message: str, line: Optional[int] = None):
self.rule_id = rule_id
self.severity = severity # E, W, I
self.message = message
self.line = line
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"rule": self.rule_id,
"severity": self.severity,
"message": self.message,
}
if self.line is not None:
d["line"] = self.line
return d
def __str__(self) -> str:
loc = f" (line {self.line})" if self.line is not None else ""
return f"[{self.severity}] {self.rule_id}{loc}: {self.message}"
# ---------------------------------------------------------------------------
# Config parser
# ---------------------------------------------------------------------------
def read_file(path: str) -> Tuple[Optional[str], Optional[str]]:
"""Return (content, error_message)."""
if not os.path.exists(path):
return None, f"File not found: {path}"
try:
with open(path, "r", encoding="utf-8") as f:
return f.read(), None
except OSError as e:
return None, f"Cannot read file: {e}"
def strip_comments(text: str) -> str:
"""Remove // and /* */ comments outside of string literals, preserving line structure."""
result = []
i = 0
n = len(text)
while i < n:
# String literals — pass through unchanged
if text[i] in ('"', "'", '`'):
quote = text[i]
result.append(text[i])
i += 1
while i < n:
c = text[i]
result.append(c)
if c == '\\' and i + 1 < n:
i += 1
result.append(text[i])
elif c == quote:
break
i += 1
i += 1
continue
# Block comment /* ... */
if text[i] == '/' and i + 1 < n and text[i + 1] == '*':
j = i + 2
while j < n - 1:
if text[j] == '*' and text[j + 1] == '/':
j += 2
break
if text[j] == '\n':
result.append('\n')
j += 1
i = j
continue
# Line comment // ...
if text[i] == '/' and i + 1 < n and text[i + 1] == '/':
while i < n and text[i] != '\n':
i += 1
continue
result.append(text[i])
i += 1
return ''.join(result)
def extract_test_block(content: str) -> Optional[str]:
"""Extract the content of the test: { ... } block (best-effort)."""
# Find 'test:' or '"test":' key
match = re.search(r'\btest\s*:\s*\{', content)
if not match:
return None
start = match.end() - 1 # position of opening {
depth = 0
i = start
while i < len(content):
if content[i] == '{':
depth += 1
elif content[i] == '}':
depth -= 1
if depth == 0:
return content[start:i+1]
i += 1
return None
def extract_string_value(content: str, key: str) -> Optional[str]:
"""Extract string value for a given key like environment: "jsdom"."""
pattern = rf'\b{re.escape(key)}\s*:\s*["\']([^"\']+)["\']'
m = re.search(pattern, content)
return m.group(1) if m else None
def extract_number_value(content: str, key: str) -> Optional[int]:
"""Extract numeric value for a key."""
pattern = rf'\b{re.escape(key)}\s*:\s*(\d+)'
m = re.search(pattern, content)
return int(m.group(1)) if m else None
def extract_bool_value(content: str, key: str) -> Optional[bool]:
"""Extract boolean value for a key."""
pattern = rf'\b{re.escape(key)}\s*:\s*(true|false)\b'
m = re.search(pattern, content)
if m:
return m.group(1) == "true"
return None
def find_line(content: str, pattern: str) -> Optional[int]:
"""Return 1-based line number of first match of pattern in content."""
lines = content.splitlines()
compiled = re.compile(pattern)
for i, line in enumerate(lines, 1):
if compiled.search(line):
return i
return None
def extract_array_strings(content: str, key: str) -> List[str]:
"""Extract string items from an array value like include: ["a", "b"]."""
pattern = rf'\b{re.escape(key)}\s*:\s*\[([^\]]*)\]'
m = re.search(pattern, content, re.DOTALL)
if not m:
# might be a single string
sv = extract_string_value(content, key)
return [sv] if sv else []
items_str = m.group(1)
return re.findall(r'["\']([^"\']+)["\']', items_str)
def extract_top_level_keys(content: str) -> List[str]:
"""Heuristically extract top-level keys from defineConfig({...})."""
# Find the outer defineConfig block
m = re.search(r'defineConfig\s*\(?\s*\{', content)
if not m:
# try export default { ...
m = re.search(r'export\s+default\s*\{', content)
if not m:
return []
start = content.find('{', m.start())
if start == -1:
return []
# Extract top-level keys (depth=1)
keys = []
depth = 0
i = start
buf = []
while i < len(content):
c = content[i]
if c in ('{', '[', '('):
depth += 1
elif c in ('}', ']', ')'):
depth -= 1
if depth == 0:
break
elif depth == 1 and c not in (' ', '\t', '\n', '\r', ','):
buf.append(c)
elif depth == 1 and (c == '\n' or c == ','):
text = ''.join(buf).strip()
m2 = re.match(r'^([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:', text)
if m2:
keys.append(m2.group(1))
buf = []
i += 1
# Flush last buffer
text = ''.join(buf).strip()
m2 = re.match(r'^([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:', text)
if m2:
keys.append(m2.group(1))
return keys
def validate_glob(pattern: str) -> bool:
"""Basic glob pattern sanity check."""
# Unbalanced braces
opens = pattern.count('{')
closes = pattern.count('}')
if opens != closes:
return False
# Double slashes (common mistake)
if '//' in pattern:
return False
# Starts with / but not a rooted glob
return True
# ---------------------------------------------------------------------------
# Rules
# ---------------------------------------------------------------------------
def check_structure(content: str, stripped: str, path: str) -> List[Issue]:
"""S1-S5: Structure checks."""
issues: List[Issue] = []
# S2: Empty / no defineConfig
if not stripped.strip():
issues.append(Issue("S2", "E", "Config file is empty"))
return issues
has_define_config = bool(re.search(r'\bdefineConfig\b', stripped))
has_export_default = bool(re.search(r'\bexport\s+default\b', stripped))
if not has_define_config and not has_export_default:
issues.append(Issue("S2", "E", "No defineConfig call found — config may not be loaded by Vitest"))
# S3: No default export
if not has_export_default:
issues.append(Issue("S3", "W", "No default export found — Vitest requires 'export default defineConfig(...)'"))
# S4: Both vitest.config and vite.config with test section
base = os.path.basename(path)
if "vitest.config" in base:
dirname = os.path.dirname(os.path.abspath(path))
for name in ("vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs"):
sibling = os.path.join(dirname, name)
if os.path.exists(sibling):
try:
with open(sibling, "r", encoding="utf-8") as f:
vite_content = f.read()
if re.search(r'\btest\s*:', vite_content):
issues.append(Issue(
"S4", "W",
f"Both {base} and {name} define a 'test' section — Vitest may pick up conflicting config"
))
except OSError:
pass
# S5: Unknown top-level keys
top_keys = extract_top_level_keys(stripped)
unknown = [k for k in top_keys if k not in KNOWN_TOP_LEVEL_KEYS]
for k in unknown:
line = find_line(content, rf'^\s*{re.escape(k)}\s*:')
issues.append(Issue("S5", "W", f"Unknown top-level config key: '{k}'", line))
return issues
def check_test_settings(content: str, stripped: str) -> List[Issue]:
"""T1-T5: Test settings checks."""
issues: List[Issue] = []
test_block = extract_test_block(stripped) or stripped
# T1: environment
env_val = extract_string_value(test_block, "environment")
if env_val is not None:
if env_val not in VALID_ENVIRONMENTS:
line = find_line(content, r'\benvironment\s*:')
issues.append(Issue(
"T1", "E",
f"Invalid test environment: '{env_val}'. Must be one of: {', '.join(sorted(VALID_ENVIRONMENTS))}",
line
))
# T2: Empty include/exclude
for key in ("include", "exclude"):
vals = extract_array_strings(test_block, key)
# Detect explicit empty array
empty_arr = re.search(rf'\b{key}\s*:\s*\[\s*\]', test_block)
if empty_arr:
line = find_line(content, rf'\b{key}\s*:')
issues.append(Issue("T2", "W", f"'{key}' is an empty array — no test files will match", line))
# T3: Invalid glob patterns
for key in ("include", "exclude"):
patterns = extract_array_strings(test_block, key)
for pat in patterns:
if not validate_glob(pat):
line = find_line(content, re.escape(pat))
issues.append(Issue("T3", "E", f"Invalid glob pattern in '{key}': '{pat}'", line))
# T4: Coverage provider not set
has_coverage = bool(re.search(r'\bcoverage\s*:', test_block))
if has_coverage:
cov_provider = extract_string_value(test_block, "provider")
if cov_provider is None:
line = find_line(content, r'\bcoverage\s*:')
issues.append(Issue(
"T4", "I",
"Coverage is configured but 'provider' is not set — recommend setting provider: 'v8' or 'istanbul'",
line
))
elif cov_provider not in VALID_COVERAGE_PROVIDERS:
line = find_line(content, r'\bprovider\s*:')
issues.append(Issue(
"T4", "E",
f"Invalid coverage provider: '{cov_provider}'. Must be one of: {', '.join(sorted(VALID_COVERAGE_PROVIDERS))}",
line
))
# T5: testTimeout
timeout_val = extract_number_value(test_block, "testTimeout")
if timeout_val is not None:
if timeout_val > 60000:
line = find_line(content, r'\btestTimeout\s*:')
issues.append(Issue(
"T5", "W",
f"testTimeout is {timeout_val}ms — unreasonably high (>60000ms). Tests should be fast; use per-test overrides instead",
line
))
elif timeout_val < 100:
line = find_line(content, r'\btestTimeout\s*:')
issues.append(Issue(
"T5", "W",
f"testTimeout is {timeout_val}ms — unreasonably low (<100ms). Tests may flake due to setup overhead",
line
))
return issues
def check_performance(content: str, stripped: str) -> List[Issue]:
"""P1-P4: Performance checks."""
issues: List[Issue] = []
test_block = extract_test_block(stripped) or stripped
# P1: singleThread with forks
single_thread = extract_bool_value(test_block, "singleThread")
pool_val = extract_string_value(test_block, "pool")
if single_thread is True and pool_val == "forks":
line = find_line(content, r'\bsingleThread\s*:')
issues.append(Issue(
"P1", "W",
"singleThread: true with pool: 'forks' disables parallelism entirely — tests run sequentially",
line
))
# Also catch legacy threads: false pattern
threads_false = re.search(r'\bthreads\s*:\s*false\b', test_block)
if threads_false:
line = find_line(content, r'\bthreads\s*:\s*false\b')
issues.append(Issue(
"P1", "W",
"threads: false disables parallel test execution — use pool: 'forks' with singleFork for clarity",
line
))
# P2: isolate: false
isolate_val = extract_bool_value(test_block, "isolate")
if isolate_val is False:
line = find_line(content, r'\bisolate\s*:\s*false\b')
# Check if there's a comment nearby
if line is not None:
lines = content.splitlines()
context_lines = lines[max(0, line-2):line+1]
has_comment = any('//' in l or '/*' in l for l in context_lines)
else:
has_comment = False
if not has_comment:
issues.append(Issue(
"P2", "W",
"isolate: false can cause test pollution (shared module state between test files). Add a comment explaining why",
line
))
# P3: No pool configuration
has_pool = bool(re.search(r'\bpool\s*:', test_block))
has_pool_options = bool(re.search(r'\bpoolOptions\s*:', test_block))
if not has_pool and not has_pool_options:
issues.append(Issue(
"P3", "I",
"No pool configuration found — consider setting pool: 'threads' (default) or 'forks' for explicit parallelism control"
))
# P4: globals: true without triple-slash reference or tsconfig types
globals_val = extract_bool_value(test_block, "globals")
if globals_val is True:
# Check for /// <reference types="vitest/globals" /> or vitest/globals in tsconfig
has_ref = bool(re.search(r'<reference\s+types=["\']vitest', content))
# Check tsconfig.json in same dir (best effort — just flag it)
line = find_line(content, r'\bglobals\s*:\s*true\b')
if not has_ref:
issues.append(Issue(
"P4", "W",
"globals: true requires TypeScript types to be registered. Add '/// <reference types=\"vitest/globals\" />' or add 'vitest/globals' to tsconfig compilerOptions.types",
line
))
return issues
def check_compatibility(content: str, stripped: str) -> List[Issue]:
"""C1-C3: Compatibility checks."""
issues: List[Issue] = []
test_block = extract_test_block(stripped) or stripped
# C1: Deprecated options
for pattern, message in DEPRECATED_OPTIONS.items():
if re.search(pattern, test_block):
line = find_line(content, pattern)
issues.append(Issue("C1", "W", message, line))
# C2: css.modules without css.include
has_css_modules = bool(re.search(r'\bmodules\s*:', test_block))
has_css_include = bool(re.search(r'\bcss\s*:.*include\s*:', test_block, re.DOTALL))
# More targeted: look for css block
css_match = re.search(r'\bcss\s*:\s*\{([^}]*)\}', test_block, re.DOTALL)
if css_match:
css_block = css_match.group(1)
if re.search(r'\bmodules\s*:', css_block) and not re.search(r'\binclude\s*:', css_block):
line = find_line(content, r'\bmodules\s*:')
issues.append(Issue(
"C2", "W",
"css.modules is set but css.include is not — CSS transforms may be skipped for non-matched files",
line
))
# C3: deps.inline and deps.external conflict
deps_match = re.search(r'\bdeps\s*:\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}', test_block, re.DOTALL)
if deps_match:
deps_block = deps_match.group(1)
has_inline = re.search(r'\binline\s*:', deps_block)
has_external = re.search(r'\bexternal\s*:', deps_block)
if has_inline and has_external:
line = find_line(content, r'\bdeps\s*:')
issues.append(Issue(
"C3", "W",
"Both deps.inline and deps.external are set — overlapping entries will cause unpredictable module resolution",
line
))
return issues
def check_best_practices(content: str, stripped: str) -> List[Issue]:
"""B1-B5: Best practice checks."""
issues: List[Issue] = []
test_block = extract_test_block(stripped) or stripped
# B1: No reporter configured
has_reporters = bool(re.search(r'\breporters?\s*:', test_block))
if not has_reporters:
issues.append(Issue(
"B1", "I",
"No reporter configured — consider adding reporters: ['verbose'] for local dev or ['junit'] for CI"
))
# B2: Missing coverage configuration
has_coverage = bool(re.search(r'\bcoverage\s*:', test_block))
if not has_coverage:
issues.append(Issue(
"B2", "I",
"No coverage configuration found — add coverage: { provider: 'v8', reporter: ['text', 'lcov'] } to track test coverage"
))
# B3: setupFiles references
setup_files = extract_array_strings(test_block, "setupFiles")
if not setup_files:
sv = extract_string_value(test_block, "setupFiles")
if sv:
setup_files = [sv]
for sf in setup_files:
# Warn about glob patterns in setupFiles (not supported)
if '*' in sf or '?' in sf or '[' in sf:
line = find_line(content, re.escape(sf))
issues.append(Issue(
"B3", "W",
f"setupFiles entry '{sf}' contains glob characters — setupFiles does not support globs, use explicit paths",
line
))
# B4: snapshotFormat not configured
has_snapshot = bool(re.search(r'\bsnapshotFormat\s*:', test_block))
if not has_snapshot:
issues.append(Issue(
"B4", "I",
"snapshotFormat not configured — defaults may differ from Jest. Set snapshotFormat: { escapeString: false } for Jest compatibility"
))
# B5: passWithNoTests
has_pass_with_no_tests = bool(re.search(r'\bpassWithNoTests\s*:', test_block))
if not has_pass_with_no_tests:
issues.append(Issue(
"B5", "I",
"passWithNoTests is not set — in CI, Vitest will exit with error code 1 if no test files are found. Set passWithNoTests: true if intentional"
))
return issues
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def run_validate(content: str, stripped: str, path: str, strict: bool) -> List[Issue]:
"""Run all 22 rules."""
issues: List[Issue] = []
issues.extend(check_structure(content, stripped, path))
issues.extend(check_test_settings(content, stripped))
issues.extend(check_performance(content, stripped))
issues.extend(check_compatibility(content, stripped))
issues.extend(check_best_practices(content, stripped))
return issues
def run_check(content: str, stripped: str, path: str) -> List[Issue]:
"""Structure-only quick check (S rules)."""
return check_structure(content, stripped, path)
def run_explain(content: str, stripped: str) -> str:
"""Return a human-readable explanation of the config."""
test_block = extract_test_block(stripped) or stripped
lines_out = ["=== Vitest Config Explanation ===", ""]
env = extract_string_value(test_block, "environment")
lines_out.append(f"Environment : {env or '(not set, defaults to node)'}")
include_pats = extract_array_strings(test_block, "include")
lines_out.append(f"Include patterns: {', '.join(include_pats) if include_pats else '(defaults: **/*.{{test,spec}}.{{js,ts,jsx,tsx}})'}")
exclude_pats = extract_array_strings(test_block, "exclude")
lines_out.append(f"Exclude patterns: {', '.join(exclude_pats) if exclude_pats else '(defaults: node_modules, dist)'}")
timeout = extract_number_value(test_block, "testTimeout")
lines_out.append(f"Test timeout : {timeout}ms" if timeout else "Test timeout : (default: 5000ms)")
globals_val = extract_bool_value(test_block, "globals")
lines_out.append(f"Globals : {'enabled' if globals_val else 'disabled (default)'}")
isolate_val = extract_bool_value(test_block, "isolate")
lines_out.append(f"Isolation : {'disabled (risky)' if isolate_val is False else 'enabled (default)'}")
pool = extract_string_value(test_block, "pool")
lines_out.append(f"Pool : {pool or '(default: threads)'}")
has_coverage = bool(re.search(r'\bcoverage\s*:', test_block))
if has_coverage:
cov_provider = extract_string_value(test_block, "provider")
lines_out.append(f"Coverage : enabled (provider: {cov_provider or 'not set'})")
else:
lines_out.append("Coverage : not configured")
reporters = extract_array_strings(test_block, "reporters")
if not reporters:
reporters = extract_array_strings(test_block, "reporter")
lines_out.append(f"Reporters : {', '.join(reporters) if reporters else '(default: verbose)'}")
setup_files = extract_array_strings(test_block, "setupFiles")
if setup_files:
lines_out.append(f"Setup files : {', '.join(setup_files)}")
pass_no_tests = extract_bool_value(test_block, "passWithNoTests")
lines_out.append(f"passWithNoTests : {'true' if pass_no_tests else 'false (may fail CI on empty test suite)'}")
return "\n".join(lines_out)
def run_suggest(issues: List[Issue]) -> str:
"""Generate improvement suggestions from issues."""
suggestions = []
severity_order = {"E": 0, "W": 1, "I": 2}
sorted_issues = sorted(issues, key=lambda x: severity_order.get(x.severity, 3))
for i, issue in enumerate(sorted_issues, 1):
if issue.severity == "E":
prefix = "CRITICAL"
elif issue.severity == "W":
prefix = "Recommend"
else:
prefix = "Consider"
suggestions.append(f"{i}. [{prefix}] ({issue.rule_id}) {issue.message}")
if not suggestions:
return "No suggestions — config looks good!"
return "\n".join(["=== Improvement Suggestions ===", ""] + suggestions)
# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------
def format_text(issues: List[Issue], path: str, command: str, strict: bool) -> str:
errors = [i for i in issues if i.severity == "E"]
warnings = [i for i in issues if i.severity == "W"]
infos = [i for i in issues if i.severity == "I"]
lines = [f"Validating: {path}", ""]
if not issues:
lines.append("No issues found.")
else:
for issue in issues:
lines.append(str(issue))
lines.append("")
effective_errors = len(errors) + (len(warnings) if strict else 0)
lines.append(
f"Summary: {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info(s)"
+ (" [strict mode: warnings counted as errors]" if strict else "")
)
if effective_errors > 0:
lines.append("Result: FAIL")
elif warnings:
lines.append("Result: WARN")
else:
lines.append("Result: PASS")
return "\n".join(lines)
def format_json(issues: List[Issue], path: str, command: str, strict: bool) -> str:
errors = [i for i in issues if i.severity == "E"]
warnings = [i for i in issues if i.severity == "W"]
infos = [i for i in issues if i.severity == "I"]
effective_errors = len(errors) + (len(warnings) if strict else 0)
result = {
"file": path,
"command": command,
"strict": strict,
"issues": [i.to_dict() for i in issues],
"summary": {
"errors": len(errors),
"warnings": len(warnings),
"infos": len(infos),
},
"result": "FAIL" if effective_errors > 0 else ("WARN" if warnings else "PASS"),
}
return json.dumps(result, indent=2)
def format_summary(issues: List[Issue], path: str, strict: bool) -> str:
errors = [i for i in issues if i.severity == "E"]
warnings = [i for i in issues if i.severity == "W"]
effective_errors = len(errors) + (len(warnings) if strict else 0)
if effective_errors > 0:
status = "FAIL"
elif warnings:
status = "WARN"
else:
status = "PASS"
return (
f"{status} {path} — "
f"{len(errors)}E {len(warnings)}W {len([i for i in issues if i.severity == 'I'])}I"
)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(
prog="vitest_config_validator",
description="Validate vitest.config.ts/js configurations",
)
subparsers = parser.add_subparsers(dest="command", required=True)
for cmd, help_text in [
("validate", "Full validation (all 22 rules)"),
("check", "Quick syntax-only check (structure rules only)"),
("explain", "Explain config in human-readable form"),
("suggest", "Suggest improvements based on all rules"),
]:
sp = subparsers.add_parser(cmd, help=help_text)
sp.add_argument("file", help="Path to vitest.config.ts or vitest.config.js")
sp.add_argument(
"--format", choices=["text", "json", "summary"], default="text",
help="Output format (default: text)"
)
sp.add_argument("--strict", action="store_true", help="Treat warnings as errors")
args = parser.parse_args()
# S1: Read file
content, error = read_file(args.file)
if error:
if args.format == "json":
print(json.dumps({"error": error, "result": "FAIL", "issues": [
{"rule": "S1", "severity": "E", "message": error}
]}, indent=2))
elif args.format == "summary":
print(f"FAIL {args.file} — file error")
else:
print(f"[E] S1: {error}")
return 2
stripped = strip_comments(content)
if args.command == "validate":
issues = run_validate(content, stripped, args.file, args.strict)
elif args.command == "check":
issues = run_check(content, stripped, args.file)
elif args.command == "explain":
print(run_explain(content, stripped))
return 0
elif args.command == "suggest":
issues = run_validate(content, stripped, args.file, args.strict)
if args.format == "json":
suggestions = [
{"rule": i.rule_id, "severity": i.severity, "message": i.message}
for i in sorted(issues, key=lambda x: {"E": 0, "W": 1, "I": 2}.get(x.severity, 3))
]
print(json.dumps({"file": args.file, "suggestions": suggestions}, indent=2))
else:
print(run_suggest(issues))
return 0
# Format and print output
if args.format == "json":
print(format_json(issues, args.file, args.command, args.strict))
elif args.format == "summary":
print(format_summary(issues, args.file, args.strict))
else:
print(format_text(issues, args.file, args.command, args.strict))
# Exit code
errors = [i for i in issues if i.severity == "E"]
warnings = [i for i in issues if i.severity == "W"]
effective_errors = len(errors) + (len(warnings) if args.strict else 0)
return 1 if effective_errors > 0 else 0
if __name__ == "__main__":
sys.exit(main())