diff --git a/.claude/hooks/bash-guard.sh b/.claude/hooks/bash-guard.sh new file mode 100755 index 000000000..97f5aa093 --- /dev/null +++ b/.claude/hooks/bash-guard.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# PreToolUse hook: Block Bash calls that should use dedicated tools. +# Exit 0 = allow, Exit 2 = block (message on stderr). + +INPUT=$(cat 2>/dev/null || true) +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true) + +[ -z "$COMMAND" ] && exit 0 + +# Strip leading env vars (FOO=bar cmd ...) and whitespace to get the actual command +STRIPPED=$(echo "$COMMAND" | sed 's/^[[:space:]]*\([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]*\)*//') +FIRST_CMD=$(echo "$STRIPPED" | awk '{print $1}') + +case "$FIRST_CMD" in + grep|egrep|fgrep|rg) + echo "BLOCKED: Use the Grep tool instead of \`$FIRST_CMD\`. It provides better output and permissions handling." >&2 + exit 2 + ;; + find) + echo "BLOCKED: Use the Glob tool instead of \`find\`. Glob is faster and returns results sorted by modification time." >&2 + exit 2 + ;; + cat|head|tail) + echo "BLOCKED: Use the Read tool instead of \`$FIRST_CMD\`. Read provides line numbers and supports images/PDFs." >&2 + exit 2 + ;; + awk) + echo "BLOCKED: Use the Grep tool or Read tool instead of \`awk\`." >&2 + exit 2 + ;; + sed) + if echo "$COMMAND" | grep -qE '(^|[[:space:]])sed[[:space:]]+-i'; then + echo "BLOCKED: Use the Edit tool instead of \`sed -i\`. Edit tracks changes properly." >&2 + exit 2 + fi + ;; +esac + +# echo with file redirection (echo "..." > file) +if echo "$STRIPPED" | grep -qE '^echo\b.*[[:space:]]>'; then + echo "BLOCKED: Use the Write tool instead of \`echo >\`. Write provides proper file creation." >&2 + exit 2 +fi + +exit 0 diff --git a/.claude/hooks/post-compact.sh b/.claude/hooks/post-compact.sh new file mode 100755 index 000000000..265ebb899 --- /dev/null +++ b/.claude/hooks/post-compact.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# PreCompact hook: Inject state preservation guidance before context compaction. + +cd "$CLAUDE_PROJECT_DIR" 2>/dev/null || exit 0 + +STATE="" + +BRANCH=$(git branch --show-current 2>/dev/null) +[ -n "$BRANCH" ] && STATE="${STATE}Branch: ${BRANCH}\n" + +DIRTY=$(git status --porcelain 2>/dev/null) +if [ -n "$DIRTY" ]; then + COUNT=$(echo "$DIRTY" | wc -l | tr -d ' ') + STATE="${STATE}Uncommitted files (${COUNT}):\n${DIRTY}\n" +fi + +UPSTREAM=$(git rev-parse --abbrev-ref '@{upstream}' 2>/dev/null) +if [ -n "$UPSTREAM" ]; then + AHEAD=$(git rev-list --count "${UPSTREAM}..HEAD" 2>/dev/null) + [ "$AHEAD" -gt 0 ] 2>/dev/null && STATE="${STATE}Unpushed commits: ${AHEAD}\n" +fi + +RECENT=$(git log --oneline -5 2>/dev/null) +[ -n "$RECENT" ] && STATE="${STATE}Recent commits:\n${RECENT}\n" + +LATEST_HANDOFF=$(ls -t "$CLAUDE_PROJECT_DIR/.claude/handoffs/"*.md 2>/dev/null | head -1) +if [ -n "$LATEST_HANDOFF" ] && [ -f "$LATEST_HANDOFF" ]; then + HANDOFF_CONTENT=$(head -40 "$LATEST_HANDOFF" 2>/dev/null) + [ -n "$HANDOFF_CONTENT" ] && STATE="${STATE}\nHandoff context:\n${HANDOFF_CONTENT}\n" +fi + +STATE="${STATE}\nProject conventions to preserve:\n" +STATE="${STATE}- Python 3.9+, uv for all tooling, ruff + mypy via prek\n" +STATE="${STATE}- Verification: uv run prek (single command for lint/format/types)\n" +STATE="${STATE}- Pre-push: uv run prek run --from-ref origin/\n" +STATE="${STATE}- Conventional commits: fix:, feat:, refactor:, test:, chore:\n" +STATE="${STATE}- Result type: Success(value) / Failure(error), check with is_successful()\n" +STATE="${STATE}- Language singleton: set_current_language() / current_language()\n" +STATE="${STATE}- libcst for code transforms, ast for read-only analysis\n" + +[ -z "$STATE" ] && exit 0 + +EXPANDED=$(printf '%b' "$STATE") +jq -n --arg msg "PRESERVE the following session state through compaction: +$EXPANDED" '{"systemMessage": $msg}' + +exit 0 diff --git a/.claude/hooks/post-edit-lint.sh b/.claude/hooks/post-edit-lint.sh index 714b7fafb..ace0f73a8 100755 --- a/.claude/hooks/post-edit-lint.sh +++ b/.claude/hooks/post-edit-lint.sh @@ -1,5 +1,4 @@ #!/usr/bin/env bash -# Everyone is on macOS so this should be fine, we don't account for Windows set -euo pipefail input=$(cat) @@ -10,6 +9,5 @@ if [[ -z "$file_path" || ! -f "$file_path" ]]; then fi if [[ "$file_path" == *.py ]]; then - # First run auto-fixes formatting; second run catches real lint errors uv run prek --files "$file_path" 2>/dev/null || uv run prek --files "$file_path" fi diff --git a/.claude/hooks/require-read.sh b/.claude/hooks/require-read.sh new file mode 100755 index 000000000..c12d30f80 --- /dev/null +++ b/.claude/hooks/require-read.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# PreToolUse hook: Block Write/Edit on existing files that haven't been Read first. +# Exit 0 = allow, Exit 2 = block (message on stderr). + +INPUT=$(cat 2>/dev/null || true) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true) + +[ -z "$FILE_PATH" ] && exit 0 + +# New files don't need prior reads +[ ! -f "$FILE_PATH" ] && exit 0 + +TRACKER="$CLAUDE_PROJECT_DIR/.claude/.read-tracker" + +if [ ! -f "$TRACKER" ]; then + echo "BLOCKED: Read \`$(basename "$FILE_PATH")\` first before modifying it." >&2 + exit 2 +fi + +if grep -qxF "$FILE_PATH" "$TRACKER"; then + exit 0 +fi + +echo "BLOCKED: Read \`$(basename "$FILE_PATH")\` first before modifying it." >&2 +exit 2 diff --git a/.claude/hooks/status-line.sh b/.claude/hooks/status-line.sh new file mode 100755 index 000000000..71de759da --- /dev/null +++ b/.claude/hooks/status-line.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Status line: derive context from git state. + +input=$(cat) +project_dir=$(echo "$input" | jq -r '.workspace.project_dir') + +user=$(whoami) +branch=$(git -C "$project_dir" branch --show-current 2>/dev/null) + +changed=$(git -C "$project_dir" diff --name-only HEAD 2>/dev/null) +[ -z "$changed" ] && changed=$(git -C "$project_dir" diff --name-only 2>/dev/null) +[ -z "$changed" ] && changed=$(git -C "$project_dir" diff --name-only --cached 2>/dev/null) + +if [ -n "$changed" ]; then + area=$(echo "$changed" | sed 's|/.*||' | sort | uniq -c | sort -rn | head -1 | awk '{print $2}') +else + area="" +fi + +context="" +case "$area" in + codeflash) + subsystem=$(echo "$changed" | grep '^codeflash/' | sed 's|^codeflash/||; s|/.*||' | sort | uniq -c | sort -rn | head -1 | awk '{print $2}') + [ -n "$subsystem" ] && context="editing $subsystem" ;; + tests) + target=$(echo "$changed" | grep '^tests/' | sed 's|^tests/||; s|/.*||' | sort -u | head -1) + [ -n "$target" ] && context="testing $target" ;; + .claude) + context="configuring claude" ;; +esac + +if [ -z "$context" ] && [ -n "$branch" ]; then + case "$branch" in + feat/*|cf-*) context="building: ${branch#feat/}" ;; + fix/*) context="fixing: ${branch#fix/}" ;; + refactor/*) context="refactoring: ${branch#refactor/}" ;; + test/*) context="testing: ${branch#test/}" ;; + chore/*) context="chore: ${branch#chore/}" ;; + esac +fi + +dirty="" +if [ -n "$(git -C "$project_dir" status --porcelain 2>/dev/null)" ]; then + dirty=" *" +fi + +status="$user | codeflash" +[ -n "$context" ] && status="$status | $context" +[ -n "$branch" ] && status="$status | $branch$dirty" +echo "$status" diff --git a/.claude/hooks/track-read.sh b/.claude/hooks/track-read.sh new file mode 100755 index 000000000..d89c17110 --- /dev/null +++ b/.claude/hooks/track-read.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# PostToolUse hook: Track Read calls for the require-read guard. + +INPUT=$(cat 2>/dev/null || true) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true) + +[ -z "$FILE_PATH" ] && exit 0 + +TRACKER="$CLAUDE_PROJECT_DIR/.claude/.read-tracker" +grep -qxF "$FILE_PATH" "$TRACKER" 2>/dev/null || echo "$FILE_PATH" >> "$TRACKER" +exit 0 diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index bcc8c6c34..c4dcb5871 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -4,10 +4,11 @@ - **Python**: 3.9+ syntax - **Package management**: Always use `uv`, never `pip` - **Tooling**: Ruff for linting/formatting, mypy strict mode, prek for pre-commit checks -- **Comments**: Minimal - only explain "why", not "what" -- **Docstrings**: Do not add docstrings to new or changed code unless the user explicitly asks for them — not even one-liners. The codebase intentionally keeps functions self-documenting through clear naming and type annotations -- **Types**: Match the type annotation style of surrounding code — the codebase uses annotations, so add them in new code -- **Naming**: NEVER use leading underscores (`_function_name`) - Python has no true private functions, use public names +- **Comments**: Minimal — only explain "why", not "what" +- **Docstrings**: Do not add docstrings unless the user explicitly asks +- **Types**: Match the type annotation style of surrounding code +- **Naming**: No leading underscores (`_function_name`) — Python has no true private functions - **Paths**: Always use absolute paths -- **Encoding**: Always pass `encoding="utf-8"` to `open()`, `read_text()`, `write_text()`, etc. in new or changed code — Windows defaults to `cp1252` which breaks on non-ASCII content. Don't flag pre-existing code that lacks it unless you're already modifying that line. -- **Verification**: Use `uv run prek` to verify code — it handles ruff, ty, mypy in one pass. Don't run `ruff`, `mypy`, or `python -c "import ..."` separately; `prek` is the single verification command +- **Encoding**: Always pass `encoding="utf-8"` to `open()`, `read_text()`, `write_text()` in new or changed code +- **Verification**: Use `uv run prek` — it handles ruff, ty, mypy in one pass. Don't run them separately +- **Code transforms**: Use `libcst` for code modification/transformation. `ast` is acceptable for read-only analysis diff --git a/.claude/rules/debugging.md b/.claude/rules/debugging.md new file mode 100644 index 000000000..42050cb37 --- /dev/null +++ b/.claude/rules/debugging.md @@ -0,0 +1,19 @@ +# Debugging + +## Root cause first + +When encountering a bug, investigate the root cause. Don't patch symptoms. If you're about to add a try/except, a fallback default, or a defensive check — ask whether the real fix is upstream. + +## Isolated testing + +Prefer running individual test functions over full suites. Only run the full suite when explicitly asked or before pushing. + +- Single function: `uv run pytest tests/test_foo.py::TestBar::test_baz -v` +- Single module: `uv run pytest tests/test_foo.py -v` +- Full suite: only when asked, or before `git push` + +When debugging a specific endpoint or integration, test it directly instead of running the entire pipeline end-to-end. + +## Subprocess failures + +When a subprocess fails, always log stdout and stderr. "Exit code 1" with no output is useless. diff --git a/.claude/rules/git.md b/.claude/rules/git.md index edac5ff61..c5678ffc1 100644 --- a/.claude/rules/git.md +++ b/.claude/rules/git.md @@ -1,19 +1,35 @@ -# Git Commits & Pull Requests +# Git ## Commits + - Never commit, amend, or push without explicit permission -- Don't commit intermediate states — wait until the full implementation is complete, reviewed, and explicitly approved before committing. If the user corrects direction mid-implementation, incorporate the correction before any commit -- Always create a new branch from `main` before starting any new work — never commit directly to `main` or reuse an existing feature branch for unrelated changes -- Use conventional commit format: `fix:`, `feat:`, `refactor:`, `docs:`, `test:`, `chore:` -- Keep commits atomic - one logical change per commit -- Commit message body should be concise (1-2 sentences max) -- Merge for simple syncs, rebase when branches have diverged significantly -- When committing to an external/third-party repo, follow that repo's own conventions for versioning, changelog, and CI -- Pre-commit: Run `uv run prek` before committing — fix any issues before creating the commit -- Pre-push: Run `uv run prek run --from-ref origin/` to check all changed files against the PR base — this matches CI behavior and catches issues that per-commit prek misses. To detect the base branch: `gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main` +- Don't commit intermediate states — wait until the full implementation is complete and approved +- Always create a new branch from `main` — never commit directly to `main` +- Conventional format: `fix:`, `feat:`, `refactor:`, `docs:`, `test:`, `chore:` +- First line: imperative verb + what changed, under 72 characters +- Body for *why*, not *what* — the diff shows what changed +- One purpose per commit: a bug fix, a new function, a refactor — not all three +- A commit that adds a function also adds its tests and exports — that's one logical change + +## Sizing + +- Too small: renaming a variable in one commit, updating its references in another +- Right size: adding a function with its tests, `__init__` export, and usage update +- Too large: implementing an entire subsystem in one commit + +## Pre-commit / Pre-push + +- Pre-commit: Run `uv run prek` before committing +- Pre-push: Run `uv run prek run --from-ref origin/` to check all changed files against the PR base ## Pull Requests -- PR titles should use conventional format -- Keep the PR body short and straight to the point + +- PR titles use conventional format +- Keep the PR body short and to the point - If related to a Linear issue, include `CF-#` in the body -- Branch naming: `cf-#-title` (lowercase, hyphenated), no other prefixes/suffixes +- Branch naming: `cf-#-title` (lowercase, hyphenated) + +## Branch Hygiene + +- Delete feature branches locally after merging (`git branch -d `) +- Use `/clean_gone` to prune local branches whose remote tracking branch has been deleted diff --git a/.claude/rules/github.md b/.claude/rules/github.md new file mode 100644 index 000000000..8c2495193 --- /dev/null +++ b/.claude/rules/github.md @@ -0,0 +1,5 @@ +# GitHub Interactions + +ALWAYS use MCP GitHub tools (`mcp__github__*`) for GitHub operations. Check for a matching MCP tool first — only fall back to `gh` via Bash when no MCP tool exists for the operation. + +This also applies to other MCP-connected services (Linear, Granola). MCP first, CLI second. diff --git a/.claude/rules/language-patterns.md b/.claude/rules/language-patterns.md index 34d61e605..8e9b166a7 100644 --- a/.claude/rules/language-patterns.md +++ b/.claude/rules/language-patterns.md @@ -6,8 +6,8 @@ paths: # Language Support Patterns - Current language is a module-level singleton in `languages/current.py` — use `set_current_language()` / `current_language()`, never pass language as a parameter through call chains -- Use `get_language_support(identifier)` from `languages/registry.py` to get a `LanguageSupport` instance — never import language classes directly -- New language support classes must use the `@register_language` decorator to register with the extension and language registries -- `languages/__init__.py` uses `__getattr__` for lazy imports to avoid circular dependencies — follow this pattern when adding new exports -- Prefer `LanguageSupport` protocol dispatch over `is_python()`/`is_javascript()` guards — remaining guards are being migrated to protocol methods +- Use `get_language_support(identifier)` from `languages/registry.py` — never import language classes directly +- New language support classes must use the `@register_language` decorator +- `languages/__init__.py` uses `__getattr__` for lazy imports to avoid circular dependencies +- Prefer `LanguageSupport` protocol dispatch over `is_python()`/`is_javascript()` guards - `is_javascript()` returns `True` for both JavaScript and TypeScript (still used in ~15 call sites pending migration) diff --git a/.claude/rules/sessions.md b/.claude/rules/sessions.md new file mode 100644 index 000000000..0d47b8285 --- /dev/null +++ b/.claude/rules/sessions.md @@ -0,0 +1,27 @@ +# Session Discipline + +## Scope + +One task per session. Don't mix implementation with communication drafting, transcript search, or strategic planning. + +## Duration + +Cap sessions at 2-3 hours. Use `/handoff` at natural breakpoints rather than letting auto-compaction degrade context. + +- After 1 compaction: consider wrapping up the current task and handing off +- After 3 compactions: stop, and tell the user to start a fresh session +- Never continue past 5 compactions — context is too degraded + +## Context preservation + +When compacting, preserve: modified files list, current branch, test commands used, key decisions made. Use subagents for exploration to keep main context clean. + +## No polling + +Never poll background tasks. No `wc -l`, no `tail -f`, no `sleep` loops. Use `run_in_background` and wait for the completion notification. + +## File read budget + +If you've read the same file 3+ times in a session, either: +- The session is too long and compaction destroyed your context — write a handoff +- You're not retaining key information — write it down in your response before it compacts away diff --git a/.claude/rules/source-code.md b/.claude/rules/source-code.md deleted file mode 100644 index 297daa6ae..000000000 --- a/.claude/rules/source-code.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -paths: - - "codeflash/**/*.py" ---- - -# Source Code Rules - -- Use `libcst` for code modification/transformation to preserve formatting. `ast` is acceptable for read-only analysis and parsing. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 0d8471a3c..a56f3ca40 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -4,13 +4,14 @@ paths: - "codeflash/**/*test*.py" --- -# Testing Conventions +# Testing -- Code context extraction and replacement tests must always assert for full string equality, no substring matching. -- Use pytest's `tmp_path` fixture for temp directories — do not use `tempfile.mkdtemp()`, `tempfile.TemporaryDirectory()`, or `NamedTemporaryFile`. Some existing tests still use `tempfile` but new tests must use `tmp_path`. -- Always call `.resolve()` on Path objects before passing them to functions under test — this ensures absolute paths and resolves symlinks. Example: `source_file = (tmp_path / "example.py").resolve()` -- Use `.as_posix()` when converting resolved paths to strings (normalizes to forward slashes). -- Any new feature or bug fix that can be tested automatically must have test cases. -- If changes affect existing test expectations, update the tests accordingly. Tests must always pass after changes. -- The pytest plugin patches `time`, `random`, `uuid`, and `datetime` for deterministic test execution — never assume real randomness or real time in verification tests. -- `conftest.py` uses an autouse fixture that calls `reset_current_language()` — tests always start with Python as the default language. +- Full string equality for context extraction/replacement tests — no substring matching +- Use pytest's `tmp_path` fixture — not `tempfile.mkdtemp()` or `NamedTemporaryFile` +- Always call `.resolve()` on Path objects before passing to functions under test +- Use `.as_posix()` when converting resolved paths to strings +- New features and bug fixes must have test cases +- The pytest plugin patches `time`, `random`, `uuid`, `datetime` for deterministic execution +- `conftest.py` autouse fixture calls `reset_current_language()` — tests start with Python as default +- Prefer running individual tests over full suites: `uv run pytest tests/test_foo.py::TestBar::test_baz -v` +- Only run the full suite when explicitly asked or before pushing diff --git a/.claude/rules/workflow.md b/.claude/rules/workflow.md index c76f77eed..7f8b5a6c1 100644 --- a/.claude/rules/workflow.md +++ b/.claude/rules/workflow.md @@ -1,13 +1,17 @@ # Workflow ## Code Changes -- Before making any changes, outline your approach in 3-5 numbered steps. Include which repo/branch you'll work in, what commands you'll run, and what success looks like. Wait for approval before starting + +Before making any changes, outline your approach in 3-5 numbered steps. Include which branch you'll work on, what commands you'll run, and what success looks like. Wait for approval before starting. ## Response Style -- When listing items (PRs, functions, optimization targets), always provide the complete list ordered by priority on the first attempt. Do not give partial lists + +When listing items (PRs, functions, optimization targets), provide the complete list ordered by priority on the first attempt. No partial lists. ## Commands -- When running long-running commands (benchmarks, profiling, optimizers like codeflash), always run them in the foreground. Do not use background processes + +Long-running commands (benchmarks, profiling, optimizers) always run in the foreground. Do not use background processes. ## Debugging -- When claiming something is a pre-existing issue (e.g., test failures on main), verify by checking out main and running the tests before making that claim + +When claiming something is a pre-existing issue (e.g., test failures on main), verify by checking out main and running the tests before making that claim. diff --git a/.claude/settings.json b/.claude/settings.json index 0ca931576..771760ec4 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,16 +1,89 @@ { + "attribution": { + "commit": "", + "pr": "" + }, + "includeCoAuthoredBy": false, + "permissions": { + "allow": [ + "Bash(git status*)", + "Bash(git diff*)", + "Bash(git log*)", + "Bash(git branch*)", + "Bash(git show*)", + "Bash(git fetch*)", + "Bash(git checkout*)", + "Bash(uv run*)", + "Bash(uv sync*)", + "Bash(uv pip*)", + "Bash(prek*)", + "Bash(make*)", + "Bash(gh *)" + ] + }, "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/bash-guard.sh", + "timeout": 5 + } + ] + }, + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/require-read.sh", + "timeout": 5 + } + ] + } + ], "PostToolUse": [ + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/track-read.sh", + "timeout": 5 + } + ] + }, { "matcher": "Edit|Write", "hooks": [ { "type": "command", - "command": ".claude/hooks/post-edit-lint.sh", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit-lint.sh", "timeout": 30 } ] } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-compact.sh", + "timeout": 10 + } + ] + } ] + }, + "statusLine": { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/status-line.sh" + }, + "enableAllProjectMcpServers": true, + "env": { + "ENABLE_LSP_TOOL": "1" } } diff --git a/.claude/skills/fix-mypy.md b/.claude/skills/fix-mypy.md index 1a9432bf3..6e43c4d3b 100644 --- a/.claude/skills/fix-mypy.md +++ b/.claude/skills/fix-mypy.md @@ -7,6 +7,6 @@ uv run mypy --non-interactive --config-file pyproject.toml ``` - Fix type annotation issues: missing return types, incorrect types, Optional/None unions, import errors for type hints -- Do NOT add `# type: ignore` comments — always fix the root cause +- Do NOT add `# type: ignore` comments -- always fix the root cause - Do NOT fix type errors that require logic changes, complex generic type rework, or anything that could change runtime behavior -- Files in `mypy_allowlist.txt` are checked in CI — ensure they remain error-free +- Files in `mypy_allowlist.txt` are checked in CI -- ensure they remain error-free diff --git a/.claude/skills/fix-prek.md b/.claude/skills/fix-prek.md index f681512ec..32652153b 100644 --- a/.claude/skills/fix-prek.md +++ b/.claude/skills/fix-prek.md @@ -5,5 +5,5 @@ When prek (pre-commit) checks fail: 1. Run `uv run prek run` to see failures (local, checks staged files) 2. In CI, the equivalent is `uv run prek run --from-ref origin/main` 3. prek runs ruff format, ruff check, and mypy on changed files -4. Fix issues in order: formatting → lint → type errors +4. Fix issues in order: formatting -> lint -> type errors 5. Re-run `uv run prek run` to verify all checks pass diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b6f14199e..a030c3e81 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,6 +36,7 @@ jobs: env: PR_BODY: ${{ github.event.pull_request.body }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} + AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} run: | # Skip for bots (dependabot, renovate, github-actions) if [[ "$PR_AUTHOR" == *"[bot]"* || "$PR_AUTHOR" == "dependabot" ]]; then @@ -43,6 +44,12 @@ jobs: exit 0 fi + # Skip for org members + if [[ "$AUTHOR_ASSOCIATION" == "MEMBER" ]]; then + echo "Org member ($PR_AUTHOR) — skipping linked issue check." + exit 0 + fi + if [ -z "$PR_BODY" ]; then echo "::error::PR body is empty. Every PR must link an issue or discussion." echo "Use 'Closes #', 'Fixes #', 'Relates to #', or include a discussion URL." @@ -124,7 +131,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Install uv - uses: astral-sh/setup-uv@v8.0.0 + uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ matrix.python-version }} enable-cache: true @@ -157,7 +164,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Install uv - uses: astral-sh/setup-uv@v8.0.0 + uses: astral-sh/setup-uv@v8.1.0 with: python-version: "3.13" enable-cache: true @@ -173,7 +180,7 @@ jobs: - name: Upload coverage report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report path: coverage.xml @@ -193,7 +200,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Install uv - uses: astral-sh/setup-uv@v8.0.0 + uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true @@ -230,6 +237,7 @@ jobs: if: >- fromJSON(needs.determine-changes.outputs.flags).e2e == 'true' && github.event_name != 'push' + && github.actor != 'dependabot[bot]' strategy: fail-fast: false matrix: @@ -289,7 +297,7 @@ jobs: pr_state: ${{ github.event.pull_request.state }} - name: Install uv - uses: astral-sh/setup-uv@v8.0.0 + uses: astral-sh/setup-uv@v8.1.0 with: python-version: 3.11.6 enable-cache: true @@ -333,6 +341,7 @@ jobs: if: >- fromJSON(needs.determine-changes.outputs.flags).e2e_js == 'true' && github.event_name != 'push' + && github.actor != 'dependabot[bot]' strategy: fail-fast: false matrix: @@ -345,10 +354,12 @@ jobs: script: end_to_end_test_js_esm_async.py js_project_dir: code_to_optimize/js/code_to_optimize_js_esm expected_improvement: 10 + allow_failure: true - name: js-ts-class script: end_to_end_test_js_ts_class.py js_project_dir: code_to_optimize/js/code_to_optimize_ts expected_improvement: 30 + continue-on-error: ${{ matrix.allow_failure || false }} environment: ${{ ((github.event_name == 'workflow_dispatch' && github.actor != 'misrasaurabh1' && github.actor != 'KRRT7') || (contains(toJSON(github.event.pull_request.files.*.filename), '.github/workflows/') && github.event.pull_request.user.login != 'misrasaurabh1' && github.event.pull_request.user.login != 'KRRT7')) && 'external-trusted-contributors' || '' }} runs-on: ubuntu-latest env: @@ -397,7 +408,7 @@ jobs: npm install - name: Install uv - uses: astral-sh/setup-uv@v8.0.0 + uses: astral-sh/setup-uv@v8.1.0 with: python-version: 3.11.6 enable-cache: true @@ -414,6 +425,7 @@ jobs: if: >- fromJSON(needs.determine-changes.outputs.flags).e2e_java == 'true' && github.event_name != 'push' + && github.actor != 'dependabot[bot]' strategy: fail-fast: false matrix: @@ -466,7 +478,7 @@ jobs: cache: maven - name: Install uv - uses: astral-sh/setup-uv@v8.0.0 + uses: astral-sh/setup-uv@v8.1.0 with: python-version: 3.11.6 enable-cache: true @@ -476,7 +488,7 @@ jobs: - name: Cache codeflash-runtime JAR id: runtime-jar-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.m2/repository/io/codeflash key: codeflash-runtime-${{ hashFiles('codeflash-java-runtime/pom.xml', 'codeflash-java-runtime/src/**') }} diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index c012a94d1..b804f4cd8 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -57,7 +57,7 @@ jobs: ref: ${{ github.event.pull_request.head.ref || github.ref }} - name: Install uv - uses: astral-sh/setup-uv@v8.0.0 + uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true @@ -319,7 +319,7 @@ jobs: ref: ${{ steps.pr-ref.outputs.ref }} - name: Install uv - uses: astral-sh/setup-uv@v8.0.0 + uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true diff --git a/.github/workflows/codeflash-optimize.yaml b/.github/workflows/codeflash-optimize.yaml index ab08aa1f8..4eb4e4f1c 100644 --- a/.github/workflows/codeflash-optimize.yaml +++ b/.github/workflows/codeflash-optimize.yaml @@ -31,7 +31,7 @@ jobs: fetch-depth: 0 - name: 🐍 Set up Python 3.11 for CLI - uses: astral-sh/setup-uv@v8.0.0 + uses: astral-sh/setup-uv@v8.1.0 with: python-version: 3.11.6 enable-cache: true diff --git a/.github/workflows/java-e2e.yaml b/.github/workflows/java-e2e.yaml index 0bfc979b6..1fcf50461 100644 --- a/.github/workflows/java-e2e.yaml +++ b/.github/workflows/java-e2e.yaml @@ -41,7 +41,7 @@ jobs: cache: maven - name: Install uv - uses: astral-sh/setup-uv@v8.0.0 + uses: astral-sh/setup-uv@v8.1.0 with: python-version: 3.11.6 enable-cache: true @@ -51,7 +51,7 @@ jobs: - name: Cache codeflash-runtime JAR id: runtime-jar-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.m2/repository/io/codeflash key: codeflash-runtime-${{ hashFiles('codeflash-java-runtime/pom.xml', 'codeflash-java-runtime/src/**') }} diff --git a/.github/workflows/label-workflow-changes.yml b/.github/workflows/label-workflow-changes.yml index 3bc8d6c54..72f886318 100644 --- a/.github/workflows/label-workflow-changes.yml +++ b/.github/workflows/label-workflow-changes.yml @@ -13,7 +13,7 @@ jobs: pull-requests: write steps: - name: Label PR with workflow changes - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const labelName = 'workflow-modified'; diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f2b6a3ccd..5b2c32981 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -110,7 +110,7 @@ jobs: - name: Install uv if: steps.should_run.outputs.run == 'true' && steps.check_tag.outputs.exists == 'false' - uses: astral-sh/setup-uv@v8.0.0 + uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true @@ -124,7 +124,7 @@ jobs: - name: Create GitHub Release if: steps.should_run.outputs.run == 'true' && steps.check_tag.outputs.exists == 'false' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.extract_version.outputs.tag }} name: ${{ matrix.release_name_prefix }} ${{ steps.extract_version.outputs.tag }} diff --git a/.gitignore b/.gitignore index 1a4e87d22..53d52f044 100644 --- a/.gitignore +++ b/.gitignore @@ -266,11 +266,12 @@ WARP.MD .tessl/ tessl.json -# Claude Code - track shared rules, ignore local config +# Claude Code - track shared config, ignore local state .claude/* !.claude/rules/ -!.claude/settings.json !.claude/hooks/ +!.claude/skills/ +!.claude/settings.json **/node_modules/** **/dist-nuitka/** **/.npmrc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3ef52eacc..6f0861c36 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,15 @@ repos: -- repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.8 - hooks: - # Run the linter. - - id: ruff-check - # Run the formatter. - - id: ruff-format \ No newline at end of file + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.8 + hooks: + - id: ruff-check + - id: ruff-format + + - repo: local + hooks: + - id: mypy + name: mypy + entry: uv run mypy --non-interactive --config-file pyproject.toml + language: system + types: [python] + require_serial: true diff --git a/CLAUDE.md b/CLAUDE.md index f56a1276c..253a1d6d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,19 +7,29 @@ CodeFlash is an AI-powered code optimizer that automatically improves performanc ## Optimization Pipeline ``` -Discovery → Ranking → Context Extraction → Test Gen + Optimization → Baseline → Candidate Evaluation → PR +Discovery -> Ranking -> Context Extraction -> Test Gen + Optimization -> Baseline -> Candidate Evaluation -> PR ``` See `.claude/rules/architecture.md` for directory mapping and entry points. -# Instructions -- **Bug fix workflow** — follow these steps in order, do not skip ahead: - 1. Read the relevant code to understand the bug - 2. Write a test that reproduces the bug (run it to confirm it fails) - 3. Spawn subagents (using the Agent tool) to attempt the fix — each subagent should apply a fix and run the test to prove it passes - 4. Review the subagent results, pick the best fix, and apply it - 5. Never jump straight to writing a fix yourself — always go through steps 1-4 -- Everything that can be tested should have tests. +## Setup + +```bash +uv sync # Install all dependencies +uv run prek install # Install git pre-commit hooks (ruff + mypy) +``` + +## Bug Fix Workflow + +Follow these steps in order, do not skip ahead: + +1. Read the relevant code to understand the bug +2. Write a test that reproduces the bug (run it to confirm it fails) +3. Spawn subagents (using the Agent tool) to attempt the fix — each subagent should apply a fix and run the test to prove it passes +4. Review the subagent results, pick the best fix, and apply it +5. Never jump straight to writing a fix yourself — always go through steps 1-4 + +Everything that can be tested should have tests. diff --git a/codeflash/languages/javascript/replay_test.py b/codeflash/languages/javascript/replay_test.py new file mode 100644 index 000000000..01b525189 --- /dev/null +++ b/codeflash/languages/javascript/replay_test.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import json +import re +import sqlite3 +import textwrap +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Optional + +if TYPE_CHECKING: + from collections.abc import Generator + + +@dataclass +class JavaScriptFunctionModule: + function_name: str + file_name: Path + module_name: str + class_name: Optional[str] = None + line_no: Optional[int] = None + + +def get_next_arg_and_return( + trace_file: str, function_name: str, file_name: str, class_name: Optional[str] = None, num_to_get: int = 25 +) -> Generator[Any]: + db = sqlite3.connect(trace_file) + cur = db.cursor() + + try: + cur.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = {row[0] for row in cur.fetchall()} + + if "function_calls" in tables: + if class_name: + cursor = cur.execute( + "SELECT args FROM function_calls WHERE function = ? AND filename = ? AND classname = ? AND type = 'call' ORDER BY time_ns ASC LIMIT ?", + (function_name, file_name, class_name, num_to_get), + ) + else: + cursor = cur.execute( + "SELECT args FROM function_calls WHERE function = ? AND filename = ? AND type = 'call' ORDER BY time_ns ASC LIMIT ?", + (function_name, file_name, num_to_get), + ) + + while (val := cursor.fetchone()) is not None: + args_data = val[0] + if isinstance(args_data, bytes): + yield args_data + else: + yield args_data + + elif "traces" in tables: + if class_name: + cursor = cur.execute( + "SELECT args FROM traces WHERE function = ? AND file = ? ORDER BY id ASC LIMIT ?", + (function_name, file_name, num_to_get), + ) + else: + cursor = cur.execute( + "SELECT args FROM traces WHERE function = ? AND file = ? ORDER BY id ASC LIMIT ?", + (function_name, file_name, num_to_get), + ) + + while (val := cursor.fetchone()) is not None: + yield val[0] + + finally: + db.close() + + +def get_function_alias(module: str, function_name: str, class_name: Optional[str] = None) -> str: + module_alias = re.sub(r"[^a-zA-Z0-9]", "_", module).strip("_") + + if class_name: + return f"{module_alias}_{class_name}_{function_name}" + return f"{module_alias}_{function_name}" + + +def create_javascript_replay_test( + trace_file: str, + functions: list[JavaScriptFunctionModule], + max_run_count: int = 100, + framework: str = "jest", + project_root: Optional[Path] = None, +) -> str: + is_vitest = framework.lower() == "vitest" + + imports = [] + + if is_vitest: + imports.append("import { describe, test } from 'vitest';") + + imports.append("const { getNextArg } = require('codeflash/replay');") + imports.append("") + + for func in functions: + if func.function_name in ("__init__", "constructor"): + continue + + alias = get_function_alias(func.module_name, func.function_name, func.class_name) + + if func.class_name: + imports.append(f"const {{ {func.class_name}: {alias}_class }} = require('./{func.module_name}');") + else: + imports.append(f"const {{ {func.function_name}: {alias} }} = require('./{func.module_name}');") + + imports.append("") + + functions_to_test = [f.function_name for f in functions if f.function_name not in ("__init__", "constructor")] + metadata = f"""const traceFilePath = '{trace_file}'; +const functions = {json.dumps(functions_to_test)}; +""" + + test_cases = [] + + for func in functions: + if func.function_name in ("__init__", "constructor"): + continue + + alias = get_function_alias(func.module_name, func.function_name, func.class_name) + test_name = f"{func.class_name}.{func.function_name}" if func.class_name else func.function_name + + if func.class_name: + class_arg = f"'{func.class_name}'" + test_body = textwrap.dedent(f""" +describe('Replay: {test_name}', () => {{ + const traces = getNextArg(traceFilePath, '{func.function_name}', '{func.file_name.as_posix()}', {max_run_count}, {class_arg}); + + test.each(traces.map((args, i) => [i, args]))('call %i', (index, args) => {{ + const instance = new {alias}_class(); + instance.{func.function_name}(...args); + }}); +}}); +""") + else: + test_body = textwrap.dedent(f""" +describe('Replay: {test_name}', () => {{ + const traces = getNextArg(traceFilePath, '{func.function_name}', '{func.file_name.as_posix()}', {max_run_count}); + + test.each(traces.map((args, i) => [i, args]))('call %i', (index, args) => {{ + {alias}(...args); + }}); +}}); +""") + + test_cases.append(test_body) + + return "\n".join( + [ + "// Auto-generated replay test by Codeflash", + "// Do not edit this file directly", + "", + *imports, + metadata, + *test_cases, + ] + ) + + +def get_traced_functions_from_db(trace_file: Path) -> list[JavaScriptFunctionModule]: + if not trace_file.exists(): + return [] + + try: + conn = sqlite3.connect(trace_file) + cursor = conn.cursor() + + cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = {row[0] for row in cursor.fetchall()} + + functions = [] + + if "function_calls" in tables: + cursor.execute( + "SELECT DISTINCT function, filename, classname, line_number FROM function_calls WHERE type = 'call'" + ) + for row in cursor.fetchall(): + func_name = row[0] + file_name = row[1] + class_name = row[2] + line_number = row[3] + + module_path = file_name.replace("\\", "/").replace(".js", "").replace(".ts", "") + module_path = module_path.removeprefix("./") + + functions.append( + JavaScriptFunctionModule( + function_name=func_name, + file_name=Path(file_name), + module_name=module_path, + class_name=class_name, + line_no=line_number, + ) + ) + + elif "traces" in tables: + cursor.execute("SELECT DISTINCT function, file FROM traces") + for row in cursor.fetchall(): + func_name = row[0] + file_name = row[1] + + module_path = file_name.replace("\\", "/").replace(".js", "").replace(".ts", "") + module_path = module_path.removeprefix("./") + + functions.append( + JavaScriptFunctionModule( + function_name=func_name, file_name=Path(file_name), module_name=module_path + ) + ) + + conn.close() + return functions + + except Exception: + return [] + + +def create_replay_test_file( + trace_file: Path, + output_path: Path, + framework: str = "jest", + max_run_count: int = 100, + project_root: Optional[Path] = None, +) -> Optional[Path]: + functions = get_traced_functions_from_db(trace_file) + + if not functions: + return None + + content = create_javascript_replay_test( + trace_file=str(trace_file), + functions=functions, + max_run_count=max_run_count, + framework=framework, + project_root=project_root, + ) + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding="utf-8") + return output_path + except Exception: + return None diff --git a/codeflash/languages/javascript/support.py b/codeflash/languages/javascript/support.py index 2faeb76a3..b0605b19b 100644 --- a/codeflash/languages/javascript/support.py +++ b/codeflash/languages/javascript/support.py @@ -1702,8 +1702,9 @@ class JavaScriptSupport: ) -> str: """Add behavior instrumentation to capture inputs/outputs. - For JavaScript, this wraps functions to capture their arguments - and return values. + For JavaScript, instrumentation is handled at runtime by the Babel tracer plugin + (babel-tracer-plugin.js) via trace-runner.js. This method returns the source + unchanged since no source-level transformation is needed. Args: source: Source code to instrument. @@ -1711,21 +1712,11 @@ class JavaScriptSupport: output_file: Optional output file for traces. Returns: - Instrumented source code. + Source code unchanged (Babel handles instrumentation at runtime). """ - if not functions: - return source - - from codeflash.languages.javascript.tracer import JavaScriptTracer - - # Use first function's file path if output_file not specified - if output_file is None: - file_path = functions[0].file_path - output_file = file_path.parent / ".codeflash" / "traces.db" - - tracer = JavaScriptTracer(output_file) - return tracer.instrument_source(source, functions[0].file_path, list(functions)) + # JavaScript tracing is done at runtime via Babel plugin, not source transformation + return source def instrument_for_benchmarking(self, test_source: str, target_function: FunctionToOptimize) -> str: """Add timing instrumentation to test code. diff --git a/codeflash/languages/javascript/tracer.py b/codeflash/languages/javascript/tracer.py index 2f5791ee0..107c456ce 100644 --- a/codeflash/languages/javascript/tracer.py +++ b/codeflash/languages/javascript/tracer.py @@ -1,35 +1,56 @@ """Function tracing instrumentation for JavaScript. -This module provides functionality to wrap JavaScript functions to capture their -inputs, outputs, and execution behavior. This is used for generating replay tests -and verifying optimization correctness. +This module provides functionality to parse JavaScript function traces and generate +replay tests. Tracing is performed via Babel AST transformation using the +babel-tracer-plugin.js and trace-runner.js in the npm package. + +The tracer uses Babel plugin for AST transformation which: +- Works with both CommonJS and ESM +- Handles async functions, arrow functions, methods correctly +- Preserves source maps and formatting + +Database Schema (matches Python tracer): +- function_calls: Main trace data (type, function, classname, filename, line_number, time_ns, args) +- metadata: Key-value metadata about the trace session """ from __future__ import annotations import json import logging +import re import sqlite3 -from typing import TYPE_CHECKING, Any +import textwrap +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Optional if TYPE_CHECKING: from pathlib import Path - from codeflash.discovery.functions_to_optimize import FunctionToOptimize - logger = logging.getLogger(__name__) -class JavaScriptTracer: - """Instruments JavaScript code to capture function inputs and outputs. +@dataclass +class JavaScriptFunctionInfo: + function_name: str + file_name: str + module_path: str + class_name: Optional[str] = None + line_number: Optional[int] = None - Similar to Python's tracing system, this wraps functions to record: - - Input arguments - - Return values - - Exceptions thrown - - Execution time + +class JavaScriptTracer: + """Parses JavaScript function traces and generates replay tests. + + Tracing is performed via Babel AST transformation (trace-runner.js). + This class handles: + - Parsing trace results from SQLite database + - Extracting traced function information + - Generating replay test files for Jest/Vitest """ + SCHEMA_VERSION = "1.0.0" + def __init__(self, output_db: Path) -> None: """Initialize the tracer. @@ -38,322 +59,15 @@ class JavaScriptTracer: """ self.output_db = output_db - self.tracer_var = "__codeflash_tracer__" - - def instrument_source(self, source: str, file_path: Path, functions: list[FunctionToOptimize]) -> str: - """Instrument JavaScript source code with function tracing. - - Wraps specified functions to capture their inputs and outputs. - - Args: - source: Original JavaScript source code. - file_path: Path to the source file. - functions: List of functions to instrument. - - Returns: - Instrumented source code with tracing. - - """ - if not functions: - return source - - # Add tracer initialization at the top - tracer_init = self._generate_tracer_init() - - # Add instrumentation to each function - lines = source.splitlines(keepends=True) - - # Process functions in reverse order to preserve line numbers - for func in sorted(functions, key=lambda f: f.starting_line, reverse=True): - instrumented = self._instrument_function(func, lines, file_path) - start_idx = func.starting_line - 1 - end_idx = func.ending_line - lines = lines[:start_idx] + instrumented + lines[end_idx:] - - instrumented_source = "".join(lines) - - # Add tracer save at the end - tracer_save = self._generate_tracer_save() - - return tracer_init + "\n" + instrumented_source + "\n" + tracer_save - - def _generate_tracer_init(self) -> str: - """Generate JavaScript code for tracer initialization.""" - return f""" -// Codeflash function tracer initialization -const {self.tracer_var} = {{ - traces: [], - callId: 0, - - serialize: function(value) {{ - try {{ - // Handle special cases - if (value === undefined) return {{ __type__: 'undefined' }}; - if (value === null) return null; - if (typeof value === 'function') return {{ __type__: 'function', name: value.name }}; - if (typeof value === 'symbol') return {{ __type__: 'symbol', value: value.toString() }}; - if (value instanceof Error) return {{ - __type__: 'error', - name: value.name, - message: value.message, - stack: value.stack - }}; - if (typeof value === 'bigint') return {{ __type__: 'bigint', value: value.toString() }}; - if (value instanceof Date) return {{ __type__: 'date', value: value.toISOString() }}; - if (value instanceof RegExp) return {{ __type__: 'regexp', value: value.toString() }}; - if (value instanceof Map) return {{ - __type__: 'map', - value: Array.from(value.entries()).map(([k, v]) => [this.serialize(k), this.serialize(v)]) - }}; - if (value instanceof Set) return {{ - __type__: 'set', - value: Array.from(value).map(v => this.serialize(v)) - }}; - - // Handle circular references with a simple check - return JSON.parse(JSON.stringify(value)); - }} catch (e) {{ - return {{ __type__: 'unserializable', error: e.message }}; - }} - }}, - - wrap: function(originalFunc, funcName, filePath) {{ - const self = this; - - if (originalFunc.constructor.name === 'AsyncFunction') {{ - return async function(...args) {{ - const callId = self.callId++; - const start = process.hrtime.bigint(); - let result, error; - - try {{ - result = await originalFunc.apply(this, args); - }} catch (e) {{ - error = e; - }} - - const end = process.hrtime.bigint(); - - self.traces.push({{ - call_id: callId, - function: funcName, - file: filePath, - args: args.map(a => self.serialize(a)), - result: error ? null : self.serialize(result), - error: error ? self.serialize(error) : null, - runtime_ns: (end - start).toString(), - timestamp: Date.now() - }}); - - if (error) throw error; - return result; - }}; - }} - - return function(...args) {{ - const callId = self.callId++; - const start = process.hrtime.bigint(); - let result, error; - - try {{ - result = originalFunc.apply(this, args); - }} catch (e) {{ - error = e; - }} - - const end = process.hrtime.bigint(); - - self.traces.push({{ - call_id: callId, - function: funcName, - file: filePath, - args: args.map(a => self.serialize(a)), - result: error ? null : self.serialize(result), - error: error ? self.serialize(error) : null, - runtime_ns: (end - start).toString(), - timestamp: Date.now() - }}); - - if (error) throw error; - return result; - }}; - }}, - - saveToDb: function() {{ - const sqlite3 = require('sqlite3').verbose(); - const fs = require('fs'); - const path = require('path'); - - const dbPath = '{self.output_db.as_posix()}'; - const dbDir = path.dirname(dbPath); - - if (!fs.existsSync(dbDir)) {{ - fs.mkdirSync(dbDir, {{ recursive: true }}); - }} - - const db = new sqlite3.Database(dbPath); - - db.serialize(() => {{ - // Create table - db.run(` - CREATE TABLE IF NOT EXISTS traces ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - call_id INTEGER, - function TEXT, - file TEXT, - args TEXT, - result TEXT, - error TEXT, - runtime_ns TEXT, - timestamp INTEGER - ) - `); - - // Insert traces - const stmt = db.prepare(` - INSERT INTO traces (call_id, function, file, args, result, error, runtime_ns, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `); - - for (const trace of this.traces) {{ - stmt.run( - trace.call_id, - trace.function, - trace.file, - JSON.stringify(trace.args), - JSON.stringify(trace.result), - JSON.stringify(trace.error), - trace.runtime_ns, - trace.timestamp - ); - }} - - stmt.finalize(); - }}); - - db.close(); - }}, - - saveToJson: function() {{ - const fs = require('fs'); - const path = require('path'); - - const jsonPath = '{self.output_db.with_suffix(".json").as_posix()}'; - const jsonDir = path.dirname(jsonPath); - - if (!fs.existsSync(jsonDir)) {{ - fs.mkdirSync(jsonDir, {{ recursive: true }}); - }} - - fs.writeFileSync(jsonPath, JSON.stringify(this.traces, null, 2)); - }} -}}; -""" - - def _generate_tracer_save(self) -> str: - """Generate JavaScript code to save tracer results.""" - return f""" -// Save tracer results on process exit -process.on('exit', () => {{ - try {{ - {self.tracer_var}.saveToJson(); - // Try SQLite, but don't fail if sqlite3 is not installed - try {{ - {self.tracer_var}.saveToDb(); - }} catch (e) {{ - // SQLite not available, JSON is sufficient - }} - }} catch (e) {{ - console.error('Failed to save traces:', e); - }} -}}); -""" - - def _instrument_function(self, func: FunctionToOptimize, lines: list[str], file_path: Path) -> list[str]: - """Instrument a single function with tracing. - - Args: - func: Function to instrument. - lines: Source lines. - file_path: Path to source file. - - Returns: - Instrumented function lines. - - """ - func_lines = lines[func.starting_line - 1 : func.ending_line] - func_text = "".join(func_lines) - - # Detect function pattern - func_name = func.function_name - is_arrow = "=>" in func_text.split("\n")[0] - is_method = func.is_method - is_async = func.is_async - - # Generate wrapper code based on function type - if is_arrow: - # For arrow functions: const foo = (a, b) => { ... } - # Replace with: const foo = __codeflash_tracer__.wrap((a, b) => { ... }, 'foo', 'file.js') - return self._wrap_arrow_function(func_lines, func_name, file_path) - if is_method: - # For methods: methodName(a, b) { ... } - # Wrap the method body - return self._wrap_method(func_lines, func_name, file_path, is_async) - # For regular functions: function foo(a, b) { ... } - # Wrap the entire function - return self._wrap_regular_function(func_lines, func_name, file_path, is_async) - - def _wrap_arrow_function(self, func_lines: list[str], func_name: str, file_path: Path) -> list[str]: - """Wrap an arrow function with tracing.""" - # Find the assignment line - first_line = func_lines[0] - indent = len(first_line) - len(first_line.lstrip()) - indent_str = " " * indent - - # Insert wrapper call - func_text = "".join(func_lines).rstrip() - - # Find the '=' and wrap everything after it - if "=" in func_text: - parts = func_text.split("=", 1) - wrapped = f"{parts[0]}= {self.tracer_var}.wrap({parts[1]}, '{func_name}', '{file_path.as_posix()}');\n" - return [wrapped] - - return func_lines - - def _wrap_method(self, func_lines: list[str], func_name: str, file_path: Path, is_async: bool) -> list[str]: - """Wrap a class method with tracing.""" - # For methods, we wrap by reassigning them after definition - # This is complex, so for now we'll return unwrapped - # TODO: Implement method wrapping - logger.warning("Method wrapping not fully implemented for %s", func_name) - return func_lines - - def _wrap_regular_function( - self, func_lines: list[str], func_name: str, file_path: Path, is_async: bool - ) -> list[str]: - """Wrap a regular function declaration with tracing.""" - # Replace: function foo(a, b) { ... } - # With: const __original_foo = function foo(a, b) { ... }; const foo = __codeflash_tracer__.wrap(__original_foo, 'foo', 'file.js'); - - func_text = "".join(func_lines).rstrip() - first_line = func_lines[0] - indent = len(first_line) - len(first_line.lstrip()) - indent_str = " " * indent - - wrapped = ( - f"{indent_str}const __original_{func_name}__ = {func_text};\n" - f"{indent_str}const {func_name} = {self.tracer_var}.wrap(__original_{func_name}__, '{func_name}', '{file_path.as_posix()}');\n" - ) - - return [wrapped] @staticmethod def parse_results(trace_file: Path) -> list[dict[str, Any]]: """Parse tracing results from output file. + Supports both the new function_calls schema and legacy traces schema. + Args: - trace_file: Path to traces JSON file. + trace_file: Path to traces file (SQLite or JSON). Returns: List of trace records. @@ -363,36 +77,59 @@ process.on('exit', () => {{ if json_file.exists(): try: - with json_file.open("r") as f: - return json.load(f) + with json_file.open("r", encoding="utf-8") as f: + data: list[dict[str, Any]] = json.load(f) + return data except Exception as e: logger.exception("Failed to parse trace JSON: %s", e) return [] - # Try SQLite database if not trace_file.exists(): return [] try: conn = sqlite3.connect(trace_file) cursor = conn.cursor() - cursor.execute("SELECT * FROM traces ORDER BY id") + + cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = {row[0] for row in cursor.fetchall()} traces = [] - for row in cursor.fetchall(): - traces.append( - { - "id": row[0], - "call_id": row[1], - "function": row[2], - "file": row[3], - "args": json.loads(row[4]), - "result": json.loads(row[5]), - "error": json.loads(row[6]) if row[6] != "null" else None, - "runtime_ns": int(row[7]), - "timestamp": row[8], - } + + if "function_calls" in tables: + cursor.execute( + "SELECT type, function, classname, filename, line_number, " + "last_frame_address, time_ns, args FROM function_calls ORDER BY time_ns" ) + for row in cursor.fetchall(): + traces.append( + { + "type": row[0], + "function": row[1], + "classname": row[2], + "filename": row[3], + "line_number": row[4], + "last_frame_address": row[5], + "time_ns": row[6], + "args": json.loads(row[7]) if row[7] else [], + } + ) + elif "traces" in tables: + cursor.execute("SELECT * FROM traces ORDER BY id") + for row in cursor.fetchall(): + traces.append( + { + "id": row[0], + "call_id": row[1], + "function": row[2], + "file": row[3], + "args": json.loads(row[4]) if row[4] else [], + "result": json.loads(row[5]) if row[5] else None, + "error": json.loads(row[6]) if row[6] and row[6] != "null" else None, + "runtime_ns": int(row[7]) if row[7] else 0, + "timestamp": row[8] if len(row) > 8 else None, + } + ) conn.close() return traces @@ -400,3 +137,145 @@ process.on('exit', () => {{ except Exception as e: logger.exception("Failed to parse trace database: %s", e) return [] + + @staticmethod + def get_traced_functions(trace_file: Path) -> list[JavaScriptFunctionInfo]: + if not trace_file.exists(): + return [] + + try: + conn = sqlite3.connect(trace_file) + cursor = conn.cursor() + + cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = {row[0] for row in cursor.fetchall()} + + functions = [] + + if "function_calls" in tables: + cursor.execute( + "SELECT DISTINCT function, filename, classname, line_number FROM function_calls WHERE type = 'call'" + ) + for row in cursor.fetchall(): + func_name = row[0] + file_name = row[1] + class_name = row[2] + line_number = row[3] + + module_path = file_name.replace("\\", "/").replace(".js", "").replace(".ts", "") + module_path = module_path.removeprefix("./") + + functions.append( + JavaScriptFunctionInfo( + function_name=func_name, + file_name=file_name, + module_path=module_path, + class_name=class_name, + line_number=line_number, + ) + ) + + conn.close() + return functions + + except Exception as e: + logger.exception("Failed to get traced functions: %s", e) + return [] + + def create_replay_test( + self, + trace_file: Path, + output_path: Path, + framework: str = "jest", + max_run_count: int = 100, + project_root: Optional[Path] = None, + ) -> Optional[str]: + functions = self.get_traced_functions(trace_file) + if not functions: + logger.warning("No traced functions found in %s", trace_file) + return None + + is_vitest = framework.lower() == "vitest" + + imports = [] + if is_vitest: + imports.append("import { describe, test } from 'vitest';") + + imports.append("const { getNextArg } = require('codeflash/replay');") + imports.append("") + + for func in functions: + alias = self._get_function_alias(func.module_path, func.function_name, func.class_name) + if func.class_name: + imports.append(f"const {{ {func.class_name}: {alias}_class }} = require('./{func.module_path}');") + else: + imports.append(f"const {{ {func.function_name}: {alias} }} = require('./{func.module_path}');") + + imports.append("") + + trace_path = trace_file.as_posix() + metadata = [ + f"const traceFilePath = '{trace_path}';", + f"const functions = {json.dumps([f.function_name for f in functions])};", + "", + ] + + test_cases = [] + for func in functions: + alias = self._get_function_alias(func.module_path, func.function_name, func.class_name) + test_name = f"{func.class_name}.{func.function_name}" if func.class_name else func.function_name + class_arg = f"'{func.class_name}'" if func.class_name else "null" + + if func.class_name: + test_cases.append( + textwrap.dedent(f""" +describe('Replay: {test_name}', () => {{ + const traces = getNextArg(traceFilePath, '{func.function_name}', '{func.file_name}', {max_run_count}, {class_arg}); + + test.each(traces.map((args, i) => [i, args]))('call %i', (index, args) => {{ + const instance = new {alias}_class(); + instance.{func.function_name}(...args); + }}); +}}); +""") + ) + else: + test_cases.append( + textwrap.dedent(f""" +describe('Replay: {test_name}', () => {{ + const traces = getNextArg(traceFilePath, '{func.function_name}', '{func.file_name}', {max_run_count}); + + test.each(traces.map((args, i) => [i, args]))('call %i', (index, args) => {{ + {alias}(...args); + }}); +}}); +""") + ) + + content = "\n".join( + [ + "// Auto-generated replay test by Codeflash", + "// Do not edit this file directly", + "", + *imports, + *metadata, + *test_cases, + ] + ) + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding="utf-8") + logger.info("Generated replay test: %s", output_path) + return str(output_path) + except Exception as e: + logger.exception("Failed to write replay test: %s", e) + return None + + @staticmethod + def _get_function_alias(module_path: str, function_name: str, class_name: Optional[str] = None) -> str: + module_alias = re.sub(r"[^a-zA-Z0-9]", "_", module_path).strip("_") + + if class_name: + return f"{module_alias}_{class_name}_{function_name}" + return f"{module_alias}_{function_name}" diff --git a/codeflash/languages/javascript/tracer_runner.py b/codeflash/languages/javascript/tracer_runner.py new file mode 100644 index 000000000..8b4ca5adb --- /dev/null +++ b/codeflash/languages/javascript/tracer_runner.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import json +import logging +import os +import shutil +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING, Any, Optional + +if TYPE_CHECKING: + from argparse import Namespace + +logger = logging.getLogger(__name__) + + +def find_node_executable() -> Optional[Path]: + node_path = shutil.which("node") + if node_path: + return Path(node_path) + + npx_path = shutil.which("npx") + if npx_path: + return Path(npx_path) + + return None + + +def find_trace_runner() -> Optional[Path]: + cwd = Path.cwd() + + local_path = cwd / "node_modules" / "codeflash" / "runtime" / "trace-runner.js" + if local_path.exists(): + return local_path + + try: + result = subprocess.run(["npm", "root", "-g"], capture_output=True, text=True, check=True) + global_modules = Path(result.stdout.strip()) + global_path = global_modules / "codeflash" / "runtime" / "trace-runner.js" + if global_path.exists(): + return global_path + except Exception: + pass + + bundled_path = Path(__file__).parent.parent.parent.parent / "packages" / "codeflash" / "runtime" / "trace-runner.js" + if bundled_path.exists(): + return bundled_path + + return None + + +def run_javascript_tracer(args: Namespace, config: dict[str, Any], project_root: Path) -> dict[str, Any]: + result: dict[str, Any] = {"success": False, "trace_file": None, "replay_test_file": None, "error": None} + + node_path = find_node_executable() + if not node_path: + result["error"] = "Node.js not found. Please install Node.js to use JavaScript tracing." + logger.error(result["error"]) + return result + + trace_runner_path = find_trace_runner() + if not trace_runner_path: + result["error"] = "trace-runner.js not found. Please install the codeflash npm package." + logger.error(result["error"]) + return result + + outfile = getattr(args, "outfile", None) or "codeflash.trace.sqlite" + trace_file = Path(outfile).resolve() + + env = os.environ.copy() + env["CODEFLASH_TRACE_DB"] = str(trace_file) + env["CODEFLASH_PROJECT_ROOT"] = str(project_root) + + max_count = getattr(args, "max_function_count", 256) + env["CODEFLASH_MAX_FUNCTION_COUNT"] = str(max_count) + + timeout = getattr(args, "tracer_timeout", None) + if timeout: + env["CODEFLASH_TRACER_TIMEOUT"] = str(timeout) + + only_functions = getattr(args, "only_functions", None) + if only_functions: + env["CODEFLASH_FUNCTIONS"] = json.dumps(only_functions) + + cmd = [str(node_path), str(trace_runner_path)] + + cmd.extend(["--trace-db", str(trace_file)]) + cmd.extend(["--project-root", str(project_root)]) + + if max_count: + cmd.extend(["--max-function-count", str(max_count)]) + + if timeout: + cmd.extend(["--timeout", str(timeout)]) + + if only_functions: + cmd.extend(["--functions", json.dumps(only_functions)]) + + is_module = getattr(args, "module", False) + script_args = [] + + if hasattr(args, "script_args"): + script_args = args.script_args + elif hasattr(args, "unknown_args"): + script_args = args.unknown_args + + if is_module and script_args and script_args[0] == "jest": + cmd.append("--jest") + cmd.append("--") + cmd.extend(script_args[1:]) + elif is_module and script_args and script_args[0] == "vitest": + cmd.append("--vitest") + cmd.append("--") + cmd.extend(script_args[1:]) + elif script_args: + cmd.extend(script_args) + + logger.info("Running JavaScript tracer: %s", " ".join(cmd)) + + try: + process = subprocess.run(cmd, cwd=project_root, env=env, capture_output=False, check=False) + + if process.returncode != 0: + result["error"] = f"Tracing failed with exit code {process.returncode}" + logger.error(result["error"]) + return result + + except Exception as e: + result["error"] = f"Failed to run tracer: {e}" + logger.exception(result["error"]) + return result + + if not trace_file.exists(): + result["error"] = f"Trace file not created: {trace_file}" + logger.error(result["error"]) + return result + + result["success"] = True + result["trace_file"] = str(trace_file) + + trace_only = getattr(args, "trace_only", False) + if not trace_only: + replay_test_path = generate_replay_test(trace_file=trace_file, project_root=project_root, config=config) + if replay_test_path: + result["replay_test_file"] = str(replay_test_path) + logger.info("Generated replay test: %s", replay_test_path) + + return result + + +def generate_replay_test( + trace_file: Path, project_root: Path, config: dict[str, Any], output_path: Optional[Path] = None +) -> Optional[Path]: + from codeflash.languages.javascript.replay_test import create_replay_test_file + + framework = detect_test_framework(project_root, config) + + if output_path is None: + tests_root = config.get("tests_root", "tests") + tests_dir = project_root / tests_root + output_path = tests_dir / "codeflash_replay.test.js" + + return create_replay_test_file( + trace_file=trace_file, + output_path=output_path, + framework=framework, + max_run_count=100, + project_root=project_root, + ) + + +def detect_test_framework(project_root: Path, config: dict[str, Any]) -> str: + if "test_framework" in config: + framework: str = config["test_framework"] + return framework + + vitest_configs = ["vitest.config.js", "vitest.config.ts", "vitest.config.mjs"] + for conf in vitest_configs: + if (project_root / conf).exists(): + return "vitest" + + jest_configs = ["jest.config.js", "jest.config.ts", "jest.config.mjs", "jest.config.json"] + for conf in jest_configs: + if (project_root / conf).exists(): + return "jest" + + package_json = project_root / "package.json" + if package_json.exists(): + try: + with package_json.open(encoding="utf-8") as f: + pkg = json.load(f) + test_script = pkg.get("scripts", {}).get("test", "") + if "vitest" in test_script: + return "vitest" + if "jest" in test_script: + return "jest" + + deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} + if "vitest" in deps: + return "vitest" + if "jest" in deps: + return "jest" + except Exception: + pass + + return "jest" + + +def check_javascript_tracer_available() -> bool: + if not find_node_executable(): + return False + + if not find_trace_runner(): + return False + + return True + + +def get_tracer_requirements_message() -> str: + missing = [] + + if not find_node_executable(): + missing.append("Node.js (v18+)") + + if not find_trace_runner(): + missing.append("codeflash npm package (npm install codeflash)") + + if not missing: + return "All requirements met for JavaScript tracing." + + return "Missing requirements for JavaScript tracing:\n- " + "\n- ".join(missing) diff --git a/codeflash/tracer.py b/codeflash/tracer.py index 4a7d24585..834c7842d 100644 --- a/codeflash/tracer.py +++ b/codeflash/tracer.py @@ -149,6 +149,11 @@ def main(args: Namespace | None = None) -> ArgumentParser: parser.add_argument( "--limit", type=int, default=None, help="Limit the number of test files to process (for -m pytest mode)" ) + parser.add_argument( + "--language", + help="Language to trace (python, javascript, typescript). Auto-detected if not specified.", + default=None, + ) if args is not None: parsed_args = args @@ -182,6 +187,13 @@ def main(args: Namespace | None = None) -> ArgumentParser: outfile = parsed_args.outfile config, found_config_path = parse_config_file(parsed_args.codeflash_config) project_root = project_root_from_module_root(Path(config["module_root"]), found_config_path) + + language = getattr(parsed_args, "language", None) or config.get("language", "python") + if language in ("javascript", "typescript"): + return run_javascript_tracer_main( + parsed_args=parsed_args, config=config, project_root=project_root, unknown_args=unknown_args + ) + if len(unknown_args) > 0: args_dict = { "functions": parsed_args.only_functions, @@ -332,6 +344,87 @@ def main(args: Namespace | None = None) -> ArgumentParser: return parser +def run_javascript_tracer_main( + parsed_args: Namespace, config: dict, project_root: Path, unknown_args: list[str] +) -> ArgumentParser: + from codeflash.languages.javascript.tracer_runner import ( + check_javascript_tracer_available, + detect_test_framework, + get_tracer_requirements_message, + run_javascript_tracer, + ) + + if not check_javascript_tracer_available(): + console.print("[bold red]Error:[/] JavaScript tracer requirements not met.") + console.print(get_tracer_requirements_message()) + sys.exit(1) + + trace_only = getattr(parsed_args, "trace_only", False) + only_functions = getattr(parsed_args, "only_functions", None) + max_function_count = getattr(parsed_args, "max_function_count", 256) + timeout = getattr(parsed_args, "tracer_timeout", None) + + framework = detect_test_framework(project_root, config) + logger.info("JavaScript tracer: framework=%s, project_root=%s", framework, project_root) + + trace_db_path = get_run_tmp_file(Path("js_trace.sqlite")) + + script_or_test_args = unknown_args if unknown_args else [] + + replay_test_path = run_javascript_tracer( + script_args=script_or_test_args, + trace_db_path=trace_db_path, + project_root=project_root, + functions=only_functions, + max_function_count=max_function_count, + timeout=int(timeout) if timeout else 0, + framework=framework, + ) + + if replay_test_path and not trace_only: + from codeflash.cli_cmds.cli import parse_args as cli_parse_args + from codeflash.cli_cmds.cli import process_pyproject_config + from codeflash.cli_cmds.console import paneled_text + from codeflash.cli_cmds.console_constants import CODEFLASH_LOGO + from codeflash.languages import Language, set_current_language + from codeflash.optimization import optimizer + from codeflash.telemetry import posthog_cf + from codeflash.telemetry.sentry import init_sentry + + language = getattr(parsed_args, "language", None) or config.get("language", "javascript") + if language == "typescript": + set_current_language(Language.TYPESCRIPT) + else: + set_current_language(Language.JAVASCRIPT) + + sys.argv = ["codeflash", "--replay-test", str(replay_test_path)] + args = cli_parse_args() + paneled_text( + CODEFLASH_LOGO, + panel_args={"title": "https://codeflash.ai", "expand": False}, + text_args={"style": "bold gold3"}, + ) + + args = process_pyproject_config(args) + args.previous_checkpoint_functions = None + init_sentry(enabled=not args.disable_telemetry, exclude_errors=True) + posthog_cf.initialize_posthog(enabled=not args.disable_telemetry) + + args.effort = EffortLevel.HIGH.value + optimizer.run_with_args(args) + + # Clean up + trace_db_path.unlink(missing_ok=True) + if replay_test_path: + Path(replay_test_path).unlink(missing_ok=True) + elif replay_test_path: + console.print(f"[bold green]Trace complete.[/] Replay test: {replay_test_path}") + else: + console.print("[bold yellow]Warning:[/] No functions were traced.") + + return ArgumentParser() + + def _run_java_tracer(existing_args: Namespace | None = None) -> ArgumentParser: """Run the Java two-stage tracer (JFR + argument capture) and optionally optimize.""" from codeflash.cli_cmds.cli import parse_args, process_pyproject_config diff --git a/packages/codeflash/package.json b/packages/codeflash/package.json index 68fc947cf..d96500397 100644 --- a/packages/codeflash/package.json +++ b/packages/codeflash/package.json @@ -6,7 +6,8 @@ "types": "runtime/index.d.ts", "bin": { "codeflash": "./bin/codeflash.js", - "codeflash-setup": "./bin/codeflash-setup.js" + "codeflash-setup": "./bin/codeflash-setup.js", + "codeflash-trace": "./runtime/trace-runner.js" }, "publishConfig": { "access": "public" @@ -36,6 +37,18 @@ "./jest-reporter": { "require": "./runtime/jest-reporter.js", "import": "./runtime/jest-reporter.js" + }, + "./tracer": { + "require": "./runtime/tracer.js", + "import": "./runtime/tracer.js" + }, + "./replay": { + "require": "./runtime/replay.js", + "import": "./runtime/replay.js" + }, + "./babel-tracer-plugin": { + "require": "./runtime/babel-tracer-plugin.js", + "import": "./runtime/babel-tracer-plugin.js" } }, "scripts": { @@ -92,5 +105,11 @@ "dependencies": { "better-sqlite3": "^12.0.0", "@msgpack/msgpack": "^3.0.0" + }, + "optionalDependencies": { + "@babel/core": "^7.24.0", + "@babel/register": "^7.24.0", + "@babel/preset-env": "^7.24.0", + "@babel/preset-typescript": "^7.24.0" } } diff --git a/packages/codeflash/runtime/babel-tracer-plugin.js b/packages/codeflash/runtime/babel-tracer-plugin.js new file mode 100644 index 000000000..558e0664f --- /dev/null +++ b/packages/codeflash/runtime/babel-tracer-plugin.js @@ -0,0 +1,434 @@ +/** + * Codeflash Babel Tracer Plugin + * + * A Babel plugin that instruments JavaScript/TypeScript functions for tracing. + * This plugin wraps functions with tracing calls to capture: + * - Function arguments + * - Return values + * - Execution time + * + * The plugin transforms: + * function foo(a, b) { return a + b; } + * + * Into: + * const __codeflash_tracer__ = require('codeflash/tracer'); + * function foo(a, b) { + * return __codeflash_tracer__.wrap(function foo(a, b) { return a + b; }, 'foo', '/path/file.js', 1) + * .apply(this, arguments); + * } + * + * Supported function types: + * - FunctionDeclaration: function foo() {} + * - FunctionExpression: const foo = function() {} + * - ArrowFunctionExpression: const foo = () => {} + * - ClassMethod: class Foo { bar() {} } + * - ObjectMethod: const obj = { foo() {} } + * + * Configuration (via plugin options or environment variables): + * - functions: Array of function names to trace (traces all if not set) + * - files: Array of file patterns to trace (traces all if not set) + * - exclude: Array of patterns to exclude from tracing + * + * Usage with @babel/register: + * require('@babel/register')({ + * plugins: [['codeflash/babel-tracer-plugin', { functions: ['myFunc'] }]], + * }); + * + * Environment Variables: + * CODEFLASH_FUNCTIONS - JSON array of functions to trace + * CODEFLASH_TRACE_FILES - JSON array of file patterns to trace + * CODEFLASH_TRACE_EXCLUDE - JSON array of patterns to exclude + */ + +'use strict'; + +const path = require('path'); + +// Parse environment variables for configuration +function getEnvConfig() { + const config = { + functions: null, + files: null, + exclude: null, + }; + + try { + if (process.env.CODEFLASH_FUNCTIONS) { + config.functions = JSON.parse(process.env.CODEFLASH_FUNCTIONS); + } + } catch (e) { + console.error('[codeflash-babel] Failed to parse CODEFLASH_FUNCTIONS:', e.message); + } + + try { + if (process.env.CODEFLASH_TRACE_FILES) { + config.files = JSON.parse(process.env.CODEFLASH_TRACE_FILES); + } + } catch (e) { + console.error('[codeflash-babel] Failed to parse CODEFLASH_TRACE_FILES:', e.message); + } + + try { + if (process.env.CODEFLASH_TRACE_EXCLUDE) { + config.exclude = JSON.parse(process.env.CODEFLASH_TRACE_EXCLUDE); + } + } catch (e) { + console.error('[codeflash-babel] Failed to parse CODEFLASH_TRACE_EXCLUDE:', e.message); + } + + return config; +} + +/** + * Check if a function should be traced based on configuration. + * + * @param {string} funcName - Function name + * @param {string} fileName - File path + * @param {string|null} className - Class name (for methods) + * @param {Object} config - Plugin configuration + * @returns {boolean} - True if function should be traced + */ +function shouldTraceFunction(funcName, fileName, className, config) { + // Check exclude patterns first + if (config.exclude && config.exclude.length > 0) { + for (const pattern of config.exclude) { + if (typeof pattern === 'string') { + if (funcName === pattern || fileName.includes(pattern)) { + return false; + } + } else if (pattern instanceof RegExp) { + if (pattern.test(funcName) || pattern.test(fileName)) { + return false; + } + } + } + } + + // Check file patterns + if (config.files && config.files.length > 0) { + const matchesFile = config.files.some(pattern => { + if (typeof pattern === 'string') { + return fileName.includes(pattern); + } + if (pattern instanceof RegExp) { + return pattern.test(fileName); + } + return false; + }); + if (!matchesFile) return false; + } + + // Check function names + if (config.functions && config.functions.length > 0) { + const matchesName = config.functions.some(f => { + if (typeof f === 'string') { + return f === funcName || f === `${className}.${funcName}`; + } + // Support object format: { function: 'name', file: 'path', class: 'className' } + if (typeof f === 'object' && f !== null) { + if (f.function && f.function !== funcName) return false; + if (f.file && !fileName.includes(f.file)) return false; + if (f.class && f.class !== className) return false; + return true; + } + return false; + }); + if (!matchesName) return false; + } + + return true; +} + +/** + * Check if a path should be excluded from tracing (node_modules, etc.) + * + * @param {string} fileName - File path + * @returns {boolean} - True if file should be excluded + */ +function isExcludedPath(fileName) { + // Always exclude node_modules + if (fileName.includes('node_modules')) return true; + + // Exclude common test runner internals + if (fileName.includes('jest-runner') || fileName.includes('jest-jasmine')) return true; + if (fileName.includes('@vitest')) return true; + + // Exclude this plugin itself + if (fileName.includes('codeflash/runtime')) return true; + if (fileName.includes('babel-tracer-plugin')) return true; + + return false; +} + +/** + * Create the Babel plugin. + * + * @param {Object} babel - Babel object with types (t) + * @returns {Object} - Babel plugin configuration + */ +module.exports = function codeflashTracerPlugin(babel) { + const { types: t } = babel; + + // Merge environment config with plugin options + const envConfig = getEnvConfig(); + + return { + name: 'codeflash-tracer', + + visitor: { + Program: { + enter(programPath, state) { + // Merge options from plugin config and environment + state.codeflashConfig = { + ...envConfig, + ...(state.opts || {}), + }; + + // Track whether we've added the tracer import + state.tracerImportAdded = false; + + // Get file info + state.fileName = state.filename || state.file.opts.filename || 'unknown'; + + // Check if entire file should be excluded + if (isExcludedPath(state.fileName)) { + state.skipFile = true; + return; + } + + state.skipFile = false; + }, + + exit(programPath, state) { + // Add tracer import if we instrumented any functions + if (state.tracerImportAdded) { + const tracerRequire = t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('__codeflash_tracer__'), + t.callExpression( + t.identifier('require'), + [t.stringLiteral('codeflash/tracer')] + ) + ), + ]); + + // Add at the beginning of the program + programPath.unshiftContainer('body', tracerRequire); + } + }, + }, + + // Handle: function foo() {} + FunctionDeclaration(path, state) { + if (state.skipFile) return; + if (!path.node.id) return; // Skip anonymous functions + + const funcName = path.node.id.name; + const lineNumber = path.node.loc ? path.node.loc.start.line : 0; + + if (!shouldTraceFunction(funcName, state.fileName, null, state.codeflashConfig)) { + return; + } + + // Transform the function body to wrap with tracing + wrapFunctionBody(t, path, funcName, state.fileName, lineNumber, null); + state.tracerImportAdded = true; + }, + + // Handle: const foo = function() {} or const foo = () => {} + VariableDeclarator(path, state) { + if (state.skipFile) return; + if (!t.isIdentifier(path.node.id)) return; + if (!path.node.init) return; + + const init = path.node.init; + if (!t.isFunctionExpression(init) && !t.isArrowFunctionExpression(init)) { + return; + } + + const funcName = path.node.id.name; + const lineNumber = path.node.loc ? path.node.loc.start.line : 0; + + if (!shouldTraceFunction(funcName, state.fileName, null, state.codeflashConfig)) { + return; + } + + // Wrap the function expression with tracer.wrap() + path.node.init = createWrapperCall(t, init, funcName, state.fileName, lineNumber, null); + state.tracerImportAdded = true; + }, + + // Handle: class Foo { bar() {} } + ClassMethod(path, state) { + if (state.skipFile) return; + if (path.node.kind === 'constructor') return; // Skip constructors for now + + const funcName = path.node.key.name || (path.node.key.value && String(path.node.key.value)); + if (!funcName) return; + + // Get class name from parent + const classPath = path.findParent(p => t.isClassDeclaration(p) || t.isClassExpression(p)); + const className = classPath && classPath.node.id ? classPath.node.id.name : null; + + const lineNumber = path.node.loc ? path.node.loc.start.line : 0; + + if (!shouldTraceFunction(funcName, state.fileName, className, state.codeflashConfig)) { + return; + } + + // Wrap the method body + wrapMethodBody(t, path, funcName, state.fileName, lineNumber, className); + state.tracerImportAdded = true; + }, + + // Handle: const obj = { foo() {} } + ObjectMethod(path, state) { + if (state.skipFile) return; + + const funcName = path.node.key.name || (path.node.key.value && String(path.node.key.value)); + if (!funcName) return; + + const lineNumber = path.node.loc ? path.node.loc.start.line : 0; + + if (!shouldTraceFunction(funcName, state.fileName, null, state.codeflashConfig)) { + return; + } + + // Wrap the method body + wrapMethodBody(t, path, funcName, state.fileName, lineNumber, null); + state.tracerImportAdded = true; + }, + }, + }; +}; + +/** + * Create a __codeflash_tracer__.wrap() call expression. + * + * @param {Object} t - Babel types + * @param {Object} funcNode - The function AST node + * @param {string} funcName - Function name + * @param {string} fileName - File path + * @param {number} lineNumber - Line number + * @param {string|null} className - Class name + * @returns {Object} - Call expression AST node + */ +function createWrapperCall(t, funcNode, funcName, fileName, lineNumber, className) { + const args = [ + funcNode, + t.stringLiteral(funcName), + t.stringLiteral(fileName), + t.numericLiteral(lineNumber), + ]; + + if (className) { + args.push(t.stringLiteral(className)); + } else { + args.push(t.nullLiteral()); + } + + return t.callExpression( + t.memberExpression( + t.identifier('__codeflash_tracer__'), + t.identifier('wrap') + ), + args + ); +} + +/** + * Wrap a function declaration's body with tracing. + * Transforms: + * function foo(a, b) { return a + b; } + * Into: + * function foo(a, b) { + * const __original__ = function(a, b) { return a + b; }; + * return __codeflash_tracer__.wrap(__original__, 'foo', 'file.js', 1, null).apply(this, arguments); + * } + * + * @param {Object} t - Babel types + * @param {Object} path - Babel path + * @param {string} funcName - Function name + * @param {string} fileName - File path + * @param {number} lineNumber - Line number + * @param {string|null} className - Class name + */ +function wrapFunctionBody(t, path, funcName, fileName, lineNumber, className) { + const node = path.node; + const isAsync = node.async; + const isGenerator = node.generator; + + // Create a copy of the original function as an expression + const originalFunc = t.functionExpression( + null, // anonymous + node.params, + node.body, + isGenerator, + isAsync + ); + + // Create the wrapper call + const wrapperCall = createWrapperCall(t, originalFunc, funcName, fileName, lineNumber, className); + + // Create: return __codeflash_tracer__.wrap(...).apply(this, arguments) + const applyCall = t.callExpression( + t.memberExpression(wrapperCall, t.identifier('apply')), + [t.thisExpression(), t.identifier('arguments')] + ); + + const returnStatement = t.returnStatement(applyCall); + + // Replace the function body + node.body = t.blockStatement([returnStatement]); +} + +/** + * Wrap a method's body with tracing. + * Similar to wrapFunctionBody but preserves method semantics. + * + * @param {Object} t - Babel types + * @param {Object} path - Babel path + * @param {string} funcName - Function name + * @param {string} fileName - File path + * @param {number} lineNumber - Line number + * @param {string|null} className - Class name + */ +function wrapMethodBody(t, path, funcName, fileName, lineNumber, className) { + const node = path.node; + const isAsync = node.async; + const isGenerator = node.generator; + + // Create a copy of the original function as an expression + const originalFunc = t.functionExpression( + null, // anonymous + node.params, + node.body, + isGenerator, + isAsync + ); + + // Create the wrapper call + const wrapperCall = createWrapperCall(t, originalFunc, funcName, fileName, lineNumber, className); + + // Create: return __codeflash_tracer__.wrap(...).apply(this, arguments) + const applyCall = t.callExpression( + t.memberExpression(wrapperCall, t.identifier('apply')), + [t.thisExpression(), t.identifier('arguments')] + ); + + let returnStatement; + if (isAsync) { + // For async methods, we need to await the result + returnStatement = t.returnStatement(t.awaitExpression(applyCall)); + } else { + returnStatement = t.returnStatement(applyCall); + } + + // Replace the function body + node.body = t.blockStatement([returnStatement]); +} + +// Export helper functions for testing +module.exports.shouldTraceFunction = shouldTraceFunction; +module.exports.isExcludedPath = isExcludedPath; +module.exports.getEnvConfig = getEnvConfig; diff --git a/packages/codeflash/runtime/index.js b/packages/codeflash/runtime/index.js index 982912c24..864b03066 100644 --- a/packages/codeflash/runtime/index.js +++ b/packages/codeflash/runtime/index.js @@ -8,6 +8,8 @@ * - capturePerf: Capture performance metrics (timing only) * - serialize/deserialize: Value serialization for storage * - comparator: Deep equality comparison + * - tracer: Function tracing for replay test generation + * - replay: Replay test utilities * * Usage (CommonJS): * const { capture, capturePerf } = require('codeflash'); @@ -30,6 +32,22 @@ const comparator = require('./comparator'); // Result comparison (used by CLI) const compareResults = require('./compare-results'); +// Function tracing (for replay test generation) +let tracer = null; +try { + tracer = require('./tracer'); +} catch (e) { + // Tracer may not be available if better-sqlite3 is not installed +} + +// Replay test utilities +let replay = null; +try { + replay = require('./replay'); +} catch (e) { + // Replay may not be available +} + // Re-export all public APIs module.exports = { // === Main Instrumentation API === @@ -88,4 +106,24 @@ module.exports = { // === Feature Detection === hasV8: serializer.hasV8, hasMsgpack: serializer.hasMsgpack, + + // === Function Tracing (for replay test generation) === + tracer: tracer ? { + init: tracer.init, + wrap: tracer.wrap, + createWrapper: tracer.createWrapper, + disable: tracer.disable, + enable: tracer.enable, + getStats: tracer.getStats, + } : null, + + // === Replay Test Utilities === + replay: replay ? { + getNextArg: replay.getNextArg, + getTracesWithMetadata: replay.getTracesWithMetadata, + getTracedFunctions: replay.getTracedFunctions, + getTraceMetadata: replay.getTraceMetadata, + generateReplayTest: replay.generateReplayTest, + createReplayTestFromTrace: replay.createReplayTestFromTrace, + } : null, }; diff --git a/packages/codeflash/runtime/replay.js b/packages/codeflash/runtime/replay.js new file mode 100644 index 000000000..733fed41e --- /dev/null +++ b/packages/codeflash/runtime/replay.js @@ -0,0 +1,454 @@ +/** + * Codeflash Replay Test Utilities + * + * This module provides utilities for generating and running replay tests + * from traced function calls. Replay tests allow verifying that optimized + * code produces the same results as the original code. + * + * Usage: + * const { getNextArg, createReplayTest } = require('codeflash/replay'); + * + * // In a test file: + * describe('Replay tests', () => { + * test.each(getNextArg(traceFile, 'myFunction', '/path/file.js', 25)) + * ('myFunction replay %#', (args) => { + * myFunction(...args); + * }); + * }); + * + * The module supports both Jest and Vitest test frameworks. + */ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); + +// Load the codeflash serializer for argument deserialization +const serializer = require('./serializer'); + +// ============================================================================ +// DATABASE ACCESS +// ============================================================================ + +/** + * Open a SQLite database connection. + * + * @param {string} dbPath - Path to the SQLite database + * @returns {Object|null} - Database connection or null if failed + */ +function openDatabase(dbPath) { + try { + const Database = require('better-sqlite3'); + return new Database(dbPath, { readonly: true }); + } catch (e) { + console.error('[codeflash-replay] Failed to open database:', e.message); + return null; + } +} + +/** + * Get traced function calls from the database. + * + * @param {string} traceFile - Path to the trace SQLite database + * @param {string} functionName - Name of the function + * @param {string} fileName - Path to the source file + * @param {string|null} className - Class name (for methods) + * @param {number} limit - Maximum number of traces to retrieve + * @returns {Array} - Array of traced arguments + */ +function getNextArg(traceFile, functionName, fileName, limit = 25, className = null) { + const db = openDatabase(traceFile); + if (!db) { + return []; + } + + try { + let stmt; + let rows; + + if (className) { + stmt = db.prepare(` + SELECT args FROM function_calls + WHERE function = ? AND filename = ? AND classname = ? AND type = 'call' + ORDER BY time_ns ASC + LIMIT ? + `); + rows = stmt.all(functionName, fileName, className, limit); + } else { + stmt = db.prepare(` + SELECT args FROM function_calls + WHERE function = ? AND filename = ? AND type = 'call' + ORDER BY time_ns ASC + LIMIT ? + `); + rows = stmt.all(functionName, fileName, limit); + } + + db.close(); + + // Deserialize arguments + return rows.map((row, index) => { + try { + const args = serializer.deserialize(row.args); + return args; + } catch (e) { + console.warn(`[codeflash-replay] Failed to deserialize args at index ${index}:`, e.message); + return []; + } + }); + } catch (e) { + console.error('[codeflash-replay] Database query failed:', e.message); + db.close(); + return []; + } +} + +/** + * Get traced function calls with full metadata. + * + * @param {string} traceFile - Path to the trace SQLite database + * @param {string} functionName - Name of the function + * @param {string} fileName - Path to the source file + * @param {string|null} className - Class name (for methods) + * @param {number} limit - Maximum number of traces to retrieve + * @returns {Array} - Array of trace objects with args and metadata + */ +function getTracesWithMetadata(traceFile, functionName, fileName, limit = 25, className = null) { + const db = openDatabase(traceFile); + if (!db) { + return []; + } + + try { + let stmt; + let rows; + + if (className) { + stmt = db.prepare(` + SELECT type, function, classname, filename, line_number, time_ns, args + FROM function_calls + WHERE function = ? AND filename = ? AND classname = ? AND type = 'call' + ORDER BY time_ns ASC + LIMIT ? + `); + rows = stmt.all(functionName, fileName, className, limit); + } else { + stmt = db.prepare(` + SELECT type, function, classname, filename, line_number, time_ns, args + FROM function_calls + WHERE function = ? AND filename = ? AND type = 'call' + ORDER BY time_ns ASC + LIMIT ? + `); + rows = stmt.all(functionName, fileName, limit); + } + + db.close(); + + // Deserialize arguments and return with metadata + return rows.map((row, index) => { + let args; + try { + args = serializer.deserialize(row.args); + } catch (e) { + console.warn(`[codeflash-replay] Failed to deserialize args at index ${index}:`, e.message); + args = []; + } + + return { + args, + function: row.function, + className: row.classname, + fileName: row.filename, + lineNumber: row.line_number, + timeNs: row.time_ns, + }; + }); + } catch (e) { + console.error('[codeflash-replay] Database query failed:', e.message); + db.close(); + return []; + } +} + +/** + * Get all traced functions from the database. + * + * @param {string} traceFile - Path to the trace SQLite database + * @returns {Array} - Array of { function, fileName, className, count } objects + */ +function getTracedFunctions(traceFile) { + const db = openDatabase(traceFile); + if (!db) { + return []; + } + + try { + const stmt = db.prepare(` + SELECT function, filename, classname, COUNT(*) as count + FROM function_calls + WHERE type = 'call' + GROUP BY function, filename, classname + ORDER BY count DESC + `); + const rows = stmt.all(); + db.close(); + + return rows.map(row => ({ + function: row.function, + fileName: row.filename, + className: row.classname, + count: row.count, + })); + } catch (e) { + console.error('[codeflash-replay] Failed to get traced functions:', e.message); + db.close(); + return []; + } +} + +/** + * Get metadata from the trace database. + * + * @param {string} traceFile - Path to the trace SQLite database + * @returns {Object} - Metadata key-value pairs + */ +function getTraceMetadata(traceFile) { + const db = openDatabase(traceFile); + if (!db) { + return {}; + } + + try { + const stmt = db.prepare('SELECT key, value FROM metadata'); + const rows = stmt.all(); + db.close(); + + const metadata = {}; + for (const row of rows) { + metadata[row.key] = row.value; + } + return metadata; + } catch (e) { + console.error('[codeflash-replay] Failed to get metadata:', e.message); + db.close(); + return {}; + } +} + +// ============================================================================ +// TEST GENERATION +// ============================================================================ + +/** + * Generate a Jest/Vitest replay test file. + * + * @param {string} traceFile - Path to the trace SQLite database + * @param {Array} functions - Array of { function, fileName, className, modulePath } to test + * @param {Object} options - Generation options + * @returns {string} - Generated test file content + */ +function generateReplayTest(traceFile, functions, options = {}) { + const { + framework = 'jest', // 'jest' or 'vitest' + maxRunCount = 100, + outputPath = null, + } = options; + + const isVitest = framework === 'vitest'; + + // Build imports section + const imports = []; + + if (isVitest) { + imports.push("import { describe, test } from 'vitest';"); + } + + imports.push("const { getNextArg } = require('codeflash/replay');"); + imports.push(''); + + // Build function imports + for (const func of functions) { + const alias = getFunctionAlias(func.modulePath, func.function, func.className); + + if (func.className) { + // Import class for method testing + imports.push(`const { ${func.className}: ${alias}_class } = require('${func.modulePath}');`); + } else { + // Import function directly + imports.push(`const { ${func.function}: ${alias} } = require('${func.modulePath}');`); + } + } + + imports.push(''); + + // Metadata + const metadata = [ + `const traceFilePath = '${traceFile}';`, + `const functions = ${JSON.stringify(functions.map(f => f.function))};`, + '', + ]; + + // Build test cases + const testCases = []; + + for (const func of functions) { + const alias = getFunctionAlias(func.modulePath, func.function, func.className); + const testName = func.className + ? `${func.className}.${func.function}` + : func.function; + + if (func.className) { + // Method test + testCases.push(` +describe('Replay: ${testName}', () => { + const traces = getNextArg(traceFilePath, '${func.function}', '${func.fileName}', ${maxRunCount}, '${func.className}'); + + test.each(traces.map((args, i) => [i, args]))('call %i', (index, args) => { + // For instance methods, first arg is 'this' context + const [thisArg, ...methodArgs] = args; + const instance = thisArg || new ${alias}_class(); + instance.${func.function}(...methodArgs); + }); +}); +`); + } else { + // Function test + testCases.push(` +describe('Replay: ${testName}', () => { + const traces = getNextArg(traceFilePath, '${func.function}', '${func.fileName}', ${maxRunCount}); + + test.each(traces.map((args, i) => [i, args]))('call %i', (index, args) => { + ${alias}(...args); + }); +}); +`); + } + } + + // Combine all parts + const content = [ + '// Auto-generated replay test by Codeflash', + '// Do not edit this file directly', + '', + ...imports, + ...metadata, + ...testCases, + ].join('\n'); + + // Write to file if outputPath provided + if (outputPath) { + const dir = path.dirname(outputPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(outputPath, content); + console.log(`[codeflash-replay] Generated test file: ${outputPath}`); + } + + return content; +} + +/** + * Create a function alias for imports to avoid naming conflicts. + * + * @param {string} modulePath - Module path + * @param {string} functionName - Function name + * @param {string|null} className - Class name + * @returns {string} - Alias name + */ +function getFunctionAlias(modulePath, functionName, className = null) { + // Normalize module path to valid identifier + const moduleAlias = modulePath + .replace(/[^a-zA-Z0-9]/g, '_') + .replace(/^_+|_+$/g, ''); + + if (className) { + return `${moduleAlias}_${className}_${functionName}`; + } + return `${moduleAlias}_${functionName}`; +} + +/** + * Create replay tests from a trace file. + * This is the main entry point for Python integration. + * + * @param {string} traceFile - Path to the trace SQLite database + * @param {string} outputPath - Path to write the test file + * @param {Object} options - Generation options + * @returns {Object} - { success, outputPath, functions } + */ +function createReplayTestFromTrace(traceFile, outputPath, options = {}) { + const { + framework = 'jest', + maxRunCount = 100, + projectRoot = process.cwd(), + } = options; + + // Get all traced functions + const tracedFunctions = getTracedFunctions(traceFile); + + if (tracedFunctions.length === 0) { + console.warn('[codeflash-replay] No traced functions found in database'); + return { success: false, outputPath: null, functions: [] }; + } + + // Convert to the format expected by generateReplayTest + const functions = tracedFunctions.map(tf => { + // Calculate module path from file name + let modulePath = tf.fileName; + + // Make relative to project root + if (path.isAbsolute(modulePath)) { + modulePath = path.relative(projectRoot, modulePath); + } + + // Convert to module path (remove .js extension, use forward slashes) + modulePath = './' + modulePath + .replace(/\\/g, '/') + .replace(/\.js$/, '') + .replace(/\.ts$/, ''); + + return { + function: tf.function, + fileName: tf.fileName, + className: tf.className, + modulePath, + }; + }); + + // Generate the test file + const testContent = generateReplayTest(traceFile, functions, { + framework, + maxRunCount, + outputPath, + }); + + return { + success: true, + outputPath, + functions: functions.map(f => f.function), + content: testContent, + }; +} + +// ============================================================================ +// EXPORTS +// ============================================================================ + +module.exports = { + // Core API + getNextArg, + getTracesWithMetadata, + getTracedFunctions, + getTraceMetadata, + + // Test generation + generateReplayTest, + createReplayTestFromTrace, + getFunctionAlias, + + // Database utilities + openDatabase, +}; diff --git a/packages/codeflash/runtime/trace-runner.js b/packages/codeflash/runtime/trace-runner.js new file mode 100644 index 000000000..f8e34148f --- /dev/null +++ b/packages/codeflash/runtime/trace-runner.js @@ -0,0 +1,381 @@ +#!/usr/bin/env node +/** + * Codeflash Trace Runner + * + * Entry point script that runs JavaScript/TypeScript code with function tracing enabled. + * This script: + * 1. Registers Babel with the tracer plugin for AST transformation + * 2. Sets up environment variables for tracing configuration + * 3. Runs the user's script, tests, or module + * + * Usage: + * # Run a script with tracing + * node trace-runner.js script.js + * + * # Run tests with tracing (Jest) + * node trace-runner.js --jest -- --testPathPattern=mytest + * + * # Run tests with tracing (Vitest) + * node trace-runner.js --vitest -- --run + * + * # Run with specific functions to trace + * node trace-runner.js --functions='["myFunc","otherFunc"]' script.js + * + * Environment Variables (also settable via command line): + * CODEFLASH_TRACE_DB - Path to SQLite database for storing traces + * CODEFLASH_PROJECT_ROOT - Project root for relative path calculation + * CODEFLASH_FUNCTIONS - JSON array of functions to trace + * CODEFLASH_MAX_FUNCTION_COUNT - Maximum traces per function (default: 256) + * CODEFLASH_TRACER_TIMEOUT - Timeout in seconds for tracing + * + * For ESM (ECMAScript modules), use the loader flag: + * node --loader ./esm-loader.mjs trace-runner.js script.mjs + */ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); + +// ============================================================================ +// ARGUMENT PARSING +// ============================================================================ + +function parseArgs(args) { + const config = { + traceDb: process.env.CODEFLASH_TRACE_DB || path.join(process.cwd(), 'codeflash.trace.sqlite'), + projectRoot: process.env.CODEFLASH_PROJECT_ROOT || process.cwd(), + functions: process.env.CODEFLASH_FUNCTIONS || null, + maxFunctionCount: process.env.CODEFLASH_MAX_FUNCTION_COUNT || '256', + tracerTimeout: process.env.CODEFLASH_TRACER_TIMEOUT || null, + traceFiles: process.env.CODEFLASH_TRACE_FILES || null, + traceExclude: process.env.CODEFLASH_TRACE_EXCLUDE || null, + jest: false, + vitest: false, + module: false, + script: null, + scriptArgs: [], + }; + + let i = 0; + while (i < args.length) { + const arg = args[i]; + + if (arg === '--trace-db') { + config.traceDb = args[++i]; + } else if (arg.startsWith('--trace-db=')) { + config.traceDb = arg.split('=')[1]; + } else if (arg === '--project-root') { + config.projectRoot = args[++i]; + } else if (arg.startsWith('--project-root=')) { + config.projectRoot = arg.split('=')[1]; + } else if (arg === '--functions') { + config.functions = args[++i]; + } else if (arg.startsWith('--functions=')) { + config.functions = arg.split('=')[1]; + } else if (arg === '--max-function-count') { + config.maxFunctionCount = args[++i]; + } else if (arg.startsWith('--max-function-count=')) { + config.maxFunctionCount = arg.split('=')[1]; + } else if (arg === '--timeout') { + config.tracerTimeout = args[++i]; + } else if (arg.startsWith('--timeout=')) { + config.tracerTimeout = arg.split('=')[1]; + } else if (arg === '--trace-files') { + config.traceFiles = args[++i]; + } else if (arg.startsWith('--trace-files=')) { + config.traceFiles = arg.split('=')[1]; + } else if (arg === '--trace-exclude') { + config.traceExclude = args[++i]; + } else if (arg.startsWith('--trace-exclude=')) { + config.traceExclude = arg.split('=')[1]; + } else if (arg === '--jest') { + config.jest = true; + } else if (arg === '--vitest') { + config.vitest = true; + } else if (arg === '-m' || arg === '--module') { + config.module = true; + } else if (arg === '--') { + // Everything after -- is passed to the script/test runner + config.scriptArgs = args.slice(i + 1); + break; + } else if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } else if (!arg.startsWith('-')) { + // First non-flag argument is the script + config.script = arg; + config.scriptArgs = args.slice(i + 1); + break; + } + + i++; + } + + return config; +} + +function printHelp() { + console.log(` +Codeflash Trace Runner - JavaScript Function Tracing + +Usage: + trace-runner [options]