This file provides guidance to Claude Code (claude.ai/code) when working with the Tsonic compiler codebase.
This repo is "airplane-grade": correctness > speed, but we still want fast iteration loops.
YOU MUST ALWAYS ASK FOR PERMISSION BEFORE:
- Making architectural decisions or changes
- Implementing new features or functionality
- Modifying compiler behavior or type mappings
- Changing IR structure or code generation patterns
- Adding new dependencies or packages
ONLY make changes AFTER the user explicitly approves. When you identify issues or potential improvements, explain them clearly and wait for the user's decision. Do NOT assume what the user wants or make "helpful" changes without permission.
🚨 CRITICAL ARCHITECTURAL FACT: Tsonic ALWAYS compiles with noLib: true. 🚨
Tsonic targets .NET, NOT Node.js or browser JavaScript. TypeScript's standard library (lib.d.ts, lib.es5.d.ts, etc.) defines types for JavaScript runtimes - these are completely irrelevant for Tsonic.
Implications:
- NEVER rely on
checker.getResolvedSignature()finding declarations from lib.d.ts - NEVER assume TypeScript knows about Array, Promise, Map, etc. from its built-in libs
- ALWAYS use the TypeRegistry to look up type declarations
- ALWAYS define Tsonic's own type declarations for built-in types (Array, Promise, etc.)
How type resolution works in Tsonic:
- TypeRegistry stores all type declarations from source files (classes, interfaces, type aliases)
- NominalEnv computes type parameter substitutions through inheritance chains
- When resolving method signatures (like
Array.map), use TypeRegistry - NOT TypeScript's type checker - TypeScript's checker is only used for:
- Parsing and AST generation
- Basic symbol resolution within the same file
- NOT for cross-file type inference or built-in type lookups
Example: For nums.map((n) => n * 2) where nums: number[]:
- TypeScript with
noLib: trueseesnumber[]but has NO built-in Array definition - Tsonic's TypeRegistry has the Array interface with map method
- Use TypeRegistry.resolveNominal("Array") to get the map signature
- Apply NominalEnv substitution (T → number) to get parameter types
🚨 CRITICAL RULE: NEVER modify test code to work around compiler limitations. 🚨
- NEVER simplify test cases to avoid compiler errors
- NEVER change test expectations to match incorrect behavior
- NEVER remove test cases that expose bugs
- NEVER weaken test assertions
- ALWAYS fix the compiler/validator when tests fail
- ALWAYS ask for permission before changing any test code
When a test fails due to a compiler limitation:
- Report the limitation clearly
- Ask whether to fix the compiler or defer
- DO NOT modify the test without explicit permission
Tests exist to validate compiler correctness. Modifying tests to pass defeats their purpose.
🚨 CRITICAL RULE: NEVER switch git branches unless the user explicitly tells you to. 🚨
- NEVER run
git checkout <branch>to switch branches on your own - NEVER run
git switch <branch>without explicit user instruction - ALWAYS stay on the current branch until told otherwise
- ALWAYS complete all work on the current branch before switching
If you need to switch branches for any reason, ASK THE USER FIRST.
CRITICAL RULE: If the user asks you a question - whether as part of a larger text or just the question itself - you MUST:
- Answer ONLY that question
- STOP your response completely
- DO NOT continue with any other tasks or implementation
- DO NOT proceed with previous tasks
- Wait for the user's next instruction
This applies to ANY question, even if it seems like part of a larger task or discussion.
CRITICAL RULE: When you encounter a bug or issue, do NOT implement workarounds or temporary fixes.
- NEVER say "for now, let's..." and implement a hack
- NEVER work around a problem with a different approach just to make progress
- NEVER use escape hatches like
as unknown as Xto bypass type errors - ALWAYS stop and report the issue clearly
- ALWAYS wait for direction on whether to fix the root cause or defer
When you hit a blocker:
- Explain exactly what the issue is
- Explain why it's happening (root cause)
- Stop and ask for direction
Workarounds hide problems and create technical debt. The correct response to a bug is to fix it or explicitly defer it - never to silently work around it.
🚨 CRITICAL RULE: ALWAYS pipe test output to a log file for later analysis. 🚨
Tests in this project are slow (30+ seconds) and cannot be quickly re-run. If you lose the output, you lose valuable debugging information.
ALWAYS do this:
# Run tests and save output to log file
npm test 2>&1 | tee .tests/test.log
# Then analyze the log in a separate step
grep -E "(failing|Error|FAIL)" .tests/test.logNEVER do this:
# DON'T run tests without saving output
npm test 2>&1 | tail -10 # Output is LOST if interrupted!
# DON'T pipe test output through grep inline
npm run test:emitter 2>&1 | grep -A 5 "1 failing" # Output is LOST!
# DON'T re-run tests just to see output you already captured
npm run test:emitter # WRONG if you already have the log!Why this matters:
- Tests take 30+ seconds to run (some take minutes)
- If the command is interrupted, ALL output is lost
- You cannot re-run quickly to see what failed
- ALWAYS save to log first, THEN analyze the log separately
- The
.tests/directory is gitignored, safe for logs
MANDATORY: This codebase follows strict functional programming principles:
- NO MUTABLE VARIABLES - Only use
const, neverletorvar - NO MUTATIONS - Never modify objects/arrays, always create new ones
- PURE FUNCTIONS ONLY - No side effects except necessary I/O
- NO STATEFUL CLASSES - Classes only for data structures, not logic
- EXPLICIT DEPENDENCIES - All dependencies passed as parameters
If you write mutable code, you MUST immediately rewrite it functionally.
🚨 CRITICAL RULE: NEVER EVER attempt automated fixes via scripts or mass updates. 🚨
- NEVER create scripts to automate replacements (JS, Python, shell, etc.)
- NEVER use sed, awk, grep, or other text processing tools for bulk changes
- NEVER write code that modifies multiple files automatically
- ALWAYS make changes manually using the Edit tool
- Even if there are hundreds of similar changes, do them ONE BY ONE
Automated scripts break syntax in unpredictable ways and destroy codebases.
🚨 CRITICAL RULE: NEVER use npx to run the Tsonic CLI. 🚨
- NEVER use
npx tsonic - NEVER use
npxfor any Tsonic-related commands - ALWAYS use the local CLI build from this repo:
node ./packages/cli/dist/index.js(run from the repo root)
Why this matters:
npxfetches the published npm package, which may be weeks or months old- Local changes to the compiler will NOT be reflected when using
npx - You cannot test compiler fixes or new features with
npx - Using
npxgives you stale, outdated behavior
Correct usage:
# ALWAYS use the local CLI (from this repo)
node ./packages/cli/dist/index.js generate src/App.ts
node ./packages/cli/dist/index.js build src/App.ts
# Or from proof-is-in-the-pudding projects:
node ../../../../tsonic/packages/cli/dist/index.js build src/App.tsNEVER do this:
# DON'T use npx - it fetches old published version!
npx tsonic build src/App.ts # WRONG!
npx tsonic generate src/App.ts # WRONG!IMPORTANT: Always use the dedicated tools instead of bash commands for file operations:
- Read files: Use the
Readtool, NOTcat,head, ortail - Edit files: Use the
Edittool, NOTsedorawk - Create files: Use the
Writetool, NOTcatwith heredoc orechoredirection - Search files: Use the
Greptool, NOTgreporrgcommands - Find files: Use the
Globtool, NOTfindorls
Reserve bash exclusively for actual system commands (git, npm, dotnet, etc.) that require shell execution.
- NEVER delete remote branches/tags, and NEVER force-push.
- Only push new branches and open PRs; the maintainer will handle remote cleanup.
- Hard rule: no unmerged parallel branches with unique commits. Any branch that's ahead of
mainmust be either:- the current active PR branch, or
- immediately turned into a PR (or explicitly abandoned) before starting new work.
- Do not create a new branch without explicit maintainer approval.
- Before branching, first verify all current work is already PR'ed and merged (or is the one active PR branch).
- If any "dangling" branches exist (ahead of
main), stop and ask what to do with them.
- Prefer "one active branch" per repo: keep adding commits to the current PR branch until it's merged.
- NEVER open/announce a PR while the working tree is dirty.
- Before creating or sharing a PR, run
git status --porcelainand ensure it is empty. - If there are local changes, either commit them to the PR branch (and push) or explicitly discard them first.
- Before creating or sharing a PR, run
- After a PR is opened, do not continue unrelated local edits on that branch. Keep PR commits intentional and synchronized with what is pushed/reviewed.
🚨 CRITICAL RULE: NEVER use commands that permanently delete uncommitted changes. 🚨
These commands cause PERMANENT DATA LOSS that cannot be recovered:
- NEVER use
git reset --hard - NEVER use
git reset --soft - NEVER use
git reset --mixed - NEVER use
git reset HEAD - NEVER use
git checkout -- . - NEVER use
git checkout -- <file> - NEVER use
git restoreto discard changes - NEVER use
git clean -fd
Why this matters for AI sessions:
- Uncommitted work is invisible to future AI sessions
- Once discarded, changes cannot be recovered
- AI cannot help fix problems it cannot see
What to do instead:
| Situation | ❌ WRONG | ✅ CORRECT |
|---|---|---|
| Need to switch branches | git checkout main (loses changes) |
Commit first, then switch |
| Made mistakes | git reset --hard |
Commit to temp branch, start fresh |
| Want clean slate | git restore . |
Commit current state, then revert |
| On wrong branch | git checkout -- |
Commit here, then cherry-pick |
Safe workflow:
# Always commit before switching context
git add -A
git commit -m "wip: current progress on feature X"
git checkout other-branch
# If commit was wrong, fix with new commit or revert
git revert HEAD # Creates new commit that undoes last commit
# OR
git commit -m "fix: correct the previous commit"🚨 CRITICAL RULE: NEVER use git stash - it hides work and causes data loss. 🚨
- NEVER use
git stash - NEVER use
git stash push - NEVER use
git stash pop - NEVER use
git stash apply - NEVER use
git stash drop
Why stash is dangerous:
- Stashed changes are invisible to AI sessions
- Easy to forget what's stashed
- Stash can be accidentally dropped
- Causes merge conflicts when applied
- No clear history of when/why stashed
What to do instead - Use WIP branches:
# Instead of stash, create a timestamped WIP branch
git checkout -b wip/feature-name-$(date +%Y%m%d-%H%M%S)
git add -A
git commit -m "wip: in-progress work on feature X"
git push -u origin wip/feature-name-$(date +%Y%m%d-%H%M%S)
# Now switch to other work safely
git checkout main
# ... do other work ...
# Return to your WIP later
git checkout wip/feature-name-20251108-084530
# Continue working...
# When done, squash WIP commits or rebase
git rebase -i mainBenefits of WIP branches over stash:
- ✅ Work is visible in git history
- ✅ Work is backed up on remote
- ✅ AI can see the work in future sessions
- ✅ Can have multiple WIP branches
- ✅ Clear timestamps show when work was done
- ✅ Can share WIP with others if needed
ALWAYS commit before switching branches:
# Check current status
git status
# If there are changes, commit them first
git add -A
git commit -m "wip: current state before switching"
# NOW safe to switch
git checkout other-branchIf you accidentally started work on wrong branch:
# DON'T use git reset or git checkout --
# Instead, commit the work here
git add -A
git commit -m "wip: work started on wrong branch"
# Create correct branch from current state
git checkout -b correct-branch-name
# Previous branch will still have the commit
# You can cherry-pick it or just continue on new branchIf you realize you made a mistake AFTER committing:
# ✅ CORRECT: Create a fix commit
git commit -m "fix: correct the mistake from previous commit"
# ✅ CORRECT: Revert the bad commit
git revert HEAD
# ❌ WRONG: Try to undo with reset
git reset --hard HEAD~1 # NEVER DO THIS - loses historyIf you accidentally committed to main:
# DON'T panic or use git reset
# Just create a feature branch from current position
git checkout -b feat/your-feature-name
# Push the branch
git push -u origin feat/your-feature-name
# When merged, it will fast-forward (no conflicts)
# Main will catch up to the same commitIMPORTANT: Never create temporary files in the project root or package directories. Use dedicated gitignored directories for different purposes.
Purpose: Save test run output for analysis without re-running tests
Usage:
# Create directory (gitignored)
mkdir -p .tests
# Run tests with tee - shows output AND saves to file
npm test | tee .tests/run-$(date +%s).txt
# Analyze saved output later without re-running:
grep "failing" .tests/run-*.txt
tail -50 .tests/run-*.txt
grep -A10 "specific test name" .tests/run-*.txtBenefits:
- See test output in real-time (unlike
>redirection) - Analyze failures without expensive re-runs
- Keep historical test results for comparison
- Search across multiple test runs
Key Rule: ALWAYS use tee for test output, NEVER plain redirection (> or 2>&1)
Purpose: Keep analysis artifacts separate from source code
Usage:
# Create directory (gitignored)
mkdir -p .analysis
# Use for:
# - Code complexity reports
# - API documentation generation
# - Dependency analysis
# - Performance profiling results
# - Architecture diagrams and documentation
# - Parser output investigations
# - Temporary debugging scriptsBenefits:
- Keeps analysis work separate from source code
- Allows iterative analysis without cluttering repository
- Safe place for temporary debugging scripts
- Gitignored - no risk of committing debug artifacts
Purpose: Track multi-step tasks across conversation sessions
Usage:
# Create task file: YYYY-MM-DD-task-name.md
# Example: 2025-01-13-sql-generation.md
# Task file must include:
# - Task overview and objectives
# - Current status (completed work)
# - Detailed remaining work list
# - Important decisions made
# - Code locations affected
# - Testing requirements
# - Special considerations
# Mark complete: YYYY-MM-DD-task-name-COMPLETED.mdBenefits:
- Resume complex tasks across sessions with full context
- No loss of progress or decisions
- Gitignored for persistence
Purpose: Store temporary scripts and one-off debugging files
Usage:
# Create directory (gitignored)
mkdir -p .temp
# Use for:
# - Quick test scripts
# - Debug output files
# - One-off data transformations
# - Temporary TypeScript/JavaScript for testing
# NEVER use /tmp or system temp directories
# .temp keeps files visible and within the projectKey Rule: ALWAYS use .temp/ instead of /tmp/ or system temp directories. This keeps temporary work visible and accessible within the project.
Note: All four directories (.tests/, .analysis/, .todos/, .temp/) should be added to .gitignore
When you begin working on this project, you MUST:
- Read this entire CLAUDE.md file to understand the project conventions
- Read the user documentation for context:
/docs/index.md- Project overview and user guide/docs/language/module-system.md- ESM import rules/docs/language/type-mappings.md- TypeScript → C# mappings/CODING-STANDARDS.md- Mandatory coding patterns
- Read engineering specs if modifying compiler internals:
/spec/index.md- Engineering specification index/spec/architecture/README.md- How to read architecture docs/spec/architecture/00-overview.md- Compiler principles
- Check implementation plan:
/spec/appendices/implementation-plan.md - Review examples in
/docs/examples/for expected behavior
Only after reading these documents should you proceed with implementation tasks.
Tsonic is a TypeScript to C# to NativeAOT compiler that:
- Parses TypeScript using the TypeScript Compiler API
- Builds an IR (Intermediate Representation)
- Emits C# with exact JavaScript semantics via
Tsonic.Runtime - Compiles to NativeAOT using dotnet CLI
- ESM-Only: Every local import MUST have
.tsextension - Directory = Namespace: Exact case-preserved mapping
- File name = Class name: File stem becomes class name exactly
- JS names preserved:
ArraystaysArrayinTsonic.Runtime, notArray - No magic: Error clearly instead of guessing
// ✅ CORRECT - Create new object
const addExport = (module: IrModule, exp: IrExport): IrModule => ({
...module,
exports: [...module.exports, exp],
});
// ❌ WRONG - Never mutate
function addExport(module: IrModule, exp: IrExport): void {
module.exports.push(exp); // NEVER DO THIS
}// ✅ CORRECT - Pure function returns value
const resolveNamespace = (filePath: string, rootNs: string): string => {
const parts = path.dirname(filePath).split(path.sep);
return [rootNs, ...parts].join(".");
};
// ❌ WRONG - Side effect modifying registry
function resolveNamespace(filePath: string, registry: Registry): void {
registry.set(filePath, namespace); // Side effect
}// ✅ CORRECT - Return Result type
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
const parseModule = (source: string): Result<IrModule, Diagnostic[]> =>
// Implementation
// ❌ WRONG - Throwing exceptions
function parseModule(source: string): IrModule {
throw new Error("Parse failed"); // Don't throw
};The compilation pipeline is strictly layered:
- Frontend: TypeScript → IR (no C# knowledge)
- Emitter: IR → C# (no TypeScript knowledge)
- Backend: C# → NativeAOT (no compiler knowledge)
Never mix concerns between layers.
JavaScript semantics must be preserved exactly:
// TypeScript sparse array
const arr = [];
arr[10] = "ten";
console.log(arr.length); // 11
// Must compile to C# with same behavior
var arr = new Tsonic.Runtime.Array<string>();
arr[10] = "ten";
console.log(arr.length); // 11// ✅ CORRECT - Local import with .ts extension
import { User } from "./models/User.ts";
// ✅ CORRECT - .NET import without extension
import { File } from "System.IO";
// ❌ WRONG - Missing extension for local
import { User } from "./models/User"; // ERROR TSN1001
// ❌ WRONG - Extension for .NET
import { File } from "System.IO.ts"; // Makes no sense- Write test first showing expected behavior
- Run test to see it fail
- Implement minimal code to pass
- Refactor while keeping tests green
For code generation, use golden tests:
// Input TypeScript
const input = `
export function greet(name: string): string {
return \`Hello \${name}\`;
}
`;
// Expected C# output
const expected = `
public static string greet(string name)
{
return $"Hello {name}";
}
`;
// Test exact match
assert.equal(emitCSharp(parseTS(input)), expected);NEVER use mutable variables or modify objects:
// ❌ WRONG
let count = 0;
for (const item of items) {
count++; // Mutation
}
// ✅ CORRECT
const count = items.reduce((acc) => acc + 1, 0);2. Hidden Dependencies
ALWAYS pass dependencies explicitly:
// ❌ WRONG - Hidden config dependency
import { config } from "./config.js";
const emit = (ir: IrModule) => {
if (config.debug) {
/* ... */
}
};
// ✅ CORRECT - Explicit parameter
const emit = (ir: IrModule, config: Config) => {
if (config.debug) {
/* ... */
}
};NEVER use classes for logic, only data:
// ❌ WRONG - Class with logic
class Emitter {
emit(ir: IrModule): string {
/* ... */
}
}
// ✅ CORRECT - Pure function
const emit = (ir: IrModule): string => {
/* ... */
};# Install dependencies
npm install
# Build all packages
./scripts/build/all.sh
# Run tests
npm test
# Run specific test
npm test -- --grep "pattern"
# Run E2E tests
./test/scripts/run-all.sh
# Run a subset of E2E fixtures (iteration only)
./test/scripts/run-all.sh --filter <pattern>
# Clean build artifacts
./scripts/build/clean.sh
# Clean everything including node_modules
./scripts/build/clean.sh --all
# Format code
./scripts/build/format.sh
# Lint code
./scripts/build/lint.shTest workflow (airplane-grade):
Fast iteration (OK while developing / on external testbed projects):
- Run a focused unit/golden subset (Mocha
--grepworks):npm run test:emitter -- --grep <pattern>npm run test:frontend -- --grep <pattern>npm run test:cli -- --grep <pattern>
- Run focused fixtures (typecheck + E2E) without unit/golden:
./test/scripts/run-e2e.sh --filter <pattern>- (equivalent)
./test/scripts/run-all.sh --no-unit --filter <pattern>
Final verification (REQUIRED before merge/publish):
./test/scripts/run-all.sh(no--quick/ no--filter)
Policy:
- Filtered runs are for iteration only; they must never be used as the final gate.
--no-unit/run-e2e.share for iteration only; final verification must include unit + golden tests.- If a change is substantial (emitter/type system/CLI/runtime behavior), run the full suite even during development.
npm testis NOT "full suite". It only runs unit + golden tests. When reporting test results, NEVER callnpm testresults "full suite" or "all tests pass". The only command that constitutes a full test run is./test/scripts/run-all.sh(no flags). Always be explicit about which command was actually run.
- NEVER commit to main directly
- Create feature branches:
feat/feature-nameorfix/bug-name - Verify branch before commit:
git branch --show-current
- Commit before switching contexts: See Git Safety Rules above
- Format code: Run
./scripts/format-all.shbefore committing - Run tests: Ensure all tests pass with
npm test - Clear commit message: Describe what and why
- No force push: Never use
git push --force
NEVER create pull requests using gh pr create or similar CLI commands.
The user will create all pull requests manually through the GitHub web interface. Your job is to:
- Create feature branches
- Commit changes
- Push branches to remote
- STOP - Do not create PRs
Critical rules (see detailed Git Safety Rules section above):
- ✅ ALWAYS commit before switching contexts - Even if work is incomplete
- ✅ NEVER discard uncommitted work - Use WIP branches instead
- ✅ NEVER use git stash - Use timestamped WIP branches
- ✅ NEVER use git reset --hard - Use git revert for fixes
- ✅ Verify branch:
git branch --show-currentbefore committing - ✅ Push WIP branches: Backup work on remote
- ✅ Use git revert not git reset - To undo commits
Standard workflow:
# 1. Verify you're on correct branch
git branch --show-current
# 2. Make changes and commit frequently
git add -A
git commit -m "feat: descriptive message"
# 3. Format and test before pushing
./scripts/format-all.sh
npm test
# 4. Push to remote
git pushThis repo uses PRs for main. The goal is that main is never behind the versions already published to npm.
- Always publish from
main(never fromrelease/*branches). - Use
./scripts/publish-npm.sh:- If versions are already published (local == npm), it prepares a
release/vX.Y.Zbump branch and exits.- Open a PR for that branch, merge it to
main, then re-run./scripts/publish-npm.shto publish.
- Open a PR for that branch, merge it to
- If versions are ahead (local > npm), it runs the full build + full test suite and publishes.
- If versions are already published (local == npm), it prepares a
- If you ever discover npm has a higher version than
main, do not rewrite history: bumpmainto the next patch and publish from there.
Follow the phases in /spec/implementation-plan.md:
- Phase 0: Project setup
- Phase 1: TypeScript frontend
- Phase 2: IR builder
- Phase 3: C# emitter
- Phase 4: Runtime implementation
- Phase 5: Backend (dotnet CLI)
- Phase 6: CLI
- Phase 7-10: Advanced features
If you encounter issues:
- STOP immediately - Don't implement workarounds
- Explain the issue clearly - Show what's blocking you
- Propose solutions - Suggest approaches
- Wait for user decision - Don't proceed without approval
// ❌ WRONG - Shell injection
exec(`dotnet ${userInput}`);
// ✅ CORRECT - Safe spawn
spawn("dotnet", [userInput]);// ✅ CORRECT - Validate paths
const safePath = (input: string): string | null => {
const normalized = path.normalize(input);
if (normalized.includes("..")) return null;
return normalized;
};- Spec documents:
/spec/*.md- Complete specification - Examples:
/spec/examples/*.md- Input/output examples - Coding standards:
/CODING-STANDARDS.md- Mandatory patterns - Implementation plan:
/spec/implementation-plan.md- Development phases
- Functional programming only - No mutations ever
- Pure functions - Return values, no side effects
- Explicit over implicit - Pass all dependencies
- Error over guess - Clear diagnostics instead of magic
- Test everything - TDD approach
- Ask before changing - Get user approval first
- Commit before switching - Never discard uncommitted work
- Never use git stash - Use WIP branches instead