fcis
v0.2.1
Published
Functional Core, Imperative Shell analyzer for TypeScript codebases
Downloads
308
Maintainers
Readme
FCIS Analyzer
Functional Core, Imperative Shell analyzer for TypeScript codebases.
Philosophy
FCIS is built on a simple observation: some code is easier to trust than others.
Consider two functions:
// Function A: Pure
function calculateDiscount(price: number, memberYears: number): number {
if (memberYears >= 5) return price * 0.20
if (memberYears >= 2) return price * 0.10
return 0
}
// Function B: Impure
async function applyDiscount(userId: string) {
const user = await db.user.findFirst({ where: { id: userId } })
const cart = await db.cart.findFirst({ where: { userId } })
let discount = 0
if (user.memberSince) {
const years = (Date.now() - user.memberSince.getTime()) / (365 * 24 * 60 * 60 * 1000)
if (years >= 5) discount = cart.total * 0.20
else if (years >= 2) discount = cart.total * 0.10
}
await db.cart.update({ where: { id: cart.id }, data: { discount } })
await sendEmail(user.email, `You saved $${discount}!`)
}Function A can be tested with a simple assertion: expect(calculateDiscount(100, 5)).toBe(20). No mocks, no setup, no database. You can run it a thousand times in milliseconds and know exactly what it does.
Function B requires a test database, mock email service, careful setup of user and cart records, and you still can't be sure the discount logic is correct because it's tangled up with I/O operations.
This is the core insight of the Functional Core, Imperative Shell pattern:
Separate the code you need to think hard about (business logic) from the code that talks to the outside world (I/O). Test the thinking. Integration-test the talking.
What This Tool Measures
FCIS doesn't try to eliminate impure code — you need I/O to build useful software. Instead, it measures:
1. How much of your logic is testable without mocks? → Purity
A function is pure if it has no I/O markers (database calls, network requests, file system access, etc.). Pure functions are trivially testable and easy to reason about.
Purity = pure functions / total functions
2. When you do have I/O, is it well-organized? → Impurity Quality
Impure functions aren't bad — they're necessary. But there's a difference between:
- Well-structured: Gathers data, calls pure functions for decisions, executes effects (GATHER → DECIDE → EXECUTE)
- Tangled: Business logic interleaved with database calls, conditionals mixed with I/O, impossible to test in pieces
FCIS scores impure functions from 0-100 based on structural signals. A score of 70+ means "this I/O code is well-organized."
3. Overall: How confident can you be in this codebase? → Health
Health combines purity and quality into a single number:
- Pure functions are automatically "healthy" (trivially testable)
- Impure functions with quality ≥70 are "healthy" (well-structured, integration-testable)
- Impure functions with quality <70 need attention
Health = functions with OK status / total functions
The goal isn't 100% purity. A codebase with 40% purity and 90% health is better than one with 80% purity and 50% health. The first has well-organized I/O; the second has tangled messes.
The FCIS Pattern
The pattern this tool encourages:
┌─────────────────────────────────────────────────────────────┐
│ IMPERATIVE SHELL │
│ │
│ async function handleRequest(id: string) { │
│ // GATHER - get data from the outside world │
│ const user = await db.user.findFirst(...) │
│ const permissions = await authService.check(...) │
│ │
│ // DECIDE - call pure functions (testable!) │
│ const plan = planUserAction(user, permissions) │
│ │
│ // EXECUTE - write to the outside world │
│ await db.audit.create({ data: plan.auditEntry }) │
│ return plan.response │
│ } │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ FUNCTIONAL CORE │
│ │
│ function planUserAction(user: User, perms: Permissions) { │
│ // Pure logic - no I/O, no side effects │
│ // Easy to test: input → output │
│ if (!perms.canAct) { │
│ return { allowed: false, reason: 'forbidden' } │
│ } │
│ return { │
│ allowed: true, │
│ auditEntry: { userId: user.id, action: 'acted' }, │
│ response: { success: true } │
│ } │
│ } │
└─────────────────────────────────────────────────────────────┘Installation
npm install -g fcis
# or
pnpm add -g fcisQuick Start
# Analyze a project
fcis tsconfig.json
# Set a health threshold for CI
fcis tsconfig.json --min-health 70
# Output JSON for further processing
fcis tsconfig.json --format json --output report.json
# Analyze specific files (for pre-commit hooks)
fcis tsconfig.json --files "src/services/**/*.ts"Example Output
FCIS Analysis
═══════════════════════════════════════════════════════════
Project Health: 77% ████████████████████░░░░░
Purity: 45% (234 pure / 520 total)
Impurity Quality: 68% average
Status Breakdown:
✓ OK: 312 functions (60%) — no action needed
◐ Review: 89 functions (17%) — could be improved
✗ Refactor: 119 functions (23%) — tangled, needs work
Top Refactoring Candidates:
(Sorted by impact: size × complexity)
1. 25/100 processOrder (150 lines)
/src/services/orders.ts:45
Markers: database-call, network-fetch, console-log
2. 32/100 handleUserUpdate (98 lines)
/src/services/users.ts:120
Markers: database-call, await-expressionWhat Makes a Function Impure?
FCIS detects these I/O patterns:
| Marker | Examples |
|--------|----------|
| await-expression | await fetch(), await db.query() |
| database-call | db.user.findFirst(), prisma.post.create() |
| network-fetch | fetch(url) |
| network-http | axios.get() |
| fs-call | fs.readFile(), fs.writeFile() |
| env-access | process.env.NODE_ENV |
| console-log | console.log(), console.error() |
| logging | logger.info() |
| telemetry | trackEvent(), analytics.track() |
| queue-enqueue | queue.enqueue(), queue.add() |
| event-emit | emitter.emit() |
Note: async alone does NOT make a function impure — only actual I/O operations count.
What Makes Impure Code "High Quality"?
FCIS rewards structural patterns that make impure code easier to understand and test:
| Signal | Why It's Good |
|--------|---------------|
| Calls .pure.ts imports | Explicitly separates pure logic |
| Calls plan*/derive*/compute* | Uses pure functions for decisions |
| I/O at start (GATHER) | Clear data-fetching phase |
| I/O at end (EXECUTE) | Clear effect-execution phase |
| Low complexity | Simple orchestration |
| Calls is*/has*/should* | Uses pure predicates |
And penalizes patterns that make code hard to reason about:
| Signal | Why It's Bad | |--------|--------------| | I/O interleaved throughout | Can't separate "what" from "how" | | High cyclomatic complexity | Too much logic mixed with I/O | | Multiple I/O types | Too many responsibilities | | No pure function calls | All logic is inline and untestable | | Very long function | God function, needs decomposition |
Compositional Scoring
Inline callbacks (passed to map, filter, forEach, etc.) are absorbed into their parent function's score. This means:
- A 301-line function with 6 callbacks counts as 1 function, not 7
- If a callback is impure, the parent is considered impure
- Quality scores blend parent and children by line count
This prevents gaming the metrics with lots of small callbacks while leaving a tangled parent function.
CLI Reference
fcis <tsconfig> [options]
Options:
--min-health <N> Exit code 1 if health < N (0-100)
--min-purity <N> Exit code 1 if purity < N (0-100)
--min-quality <N> Exit code 1 if impurity quality < N (0-100)
--files, -f <glob> Analyze only matching files
--format <fmt> Output: console (default), json, summary
--output, -o <file> Write JSON report to file
--dir-depth <N> Roll up directory metrics to depth N
--quiet, -q Suppress output, use exit code only
--verbose, -v Show per-file details
--help Show help
--version Show versionExit Codes
| Code | Meaning | |------|---------| | 0 | Success, all thresholds passed | | 1 | Below threshold | | 2 | Configuration error | | 3 | Analysis error |
CI Integration
GitHub Actions
- name: FCIS Analysis
run: fcis tsconfig.json --min-health 70 --format summaryPre-commit Hook
{
"lint-staged": {
"*.ts": ["fcis tsconfig.json --files"]
}
}Refactoring Example
Before (tangled — quality score ~25):
async function acceptInvite(inviteId: string) {
const invite = await db.invitation.findFirst({ where: { id: inviteId } })
if (!invite) throw new Error('Not found')
const org = await db.organization.findFirst({ where: { id: invite.orgId } })
// Business logic mixed with I/O — hard to test!
if (invite.expiresAt < new Date()) {
await db.invitation.update({ where: { id: inviteId }, data: { status: 'expired' } })
throw new Error('Expired')
}
if (org.memberCount >= org.maxMembers) {
throw new Error('Org full')
}
await db.member.create({ data: { userId: invite.userId, orgId: org.id } })
await db.invitation.update({ where: { id: inviteId }, data: { status: 'accepted' } })
}After (FCIS pattern — quality score ~80):
// PURE: Testable with simple assertions
function planAcceptInvite(
invite: Invitation,
org: Organization
): { action: 'accept', member: MemberData } | { action: 'reject', reason: string } {
if (invite.expiresAt < new Date()) {
return { action: 'reject', reason: 'expired' }
}
if (org.memberCount >= org.maxMembers) {
return { action: 'reject', reason: 'org-full' }
}
return {
action: 'accept',
member: { userId: invite.userId, orgId: org.id }
}
}
// IMPURE: Thin shell, clear GATHER → DECIDE → EXECUTE
async function acceptInvite(inviteId: string) {
// GATHER
const invite = await db.invitation.findFirst({ where: { id: inviteId } })
if (!invite) throw new Error('Not found')
const org = await db.organization.findFirst({ where: { id: invite.orgId } })
// DECIDE
const plan = planAcceptInvite(invite, org)
// EXECUTE
if (plan.action === 'reject') {
await db.invitation.update({ where: { id: inviteId }, data: { status: plan.reason } })
throw new Error(plan.reason)
}
await db.member.create({ data: plan.member })
await db.invitation.update({ where: { id: inviteId }, data: { status: 'accepted' } })
}The business logic (expiration check, capacity check) is now in a pure function that can be tested with simple input/output assertions. The shell just orchestrates I/O.
Limitations
- Analyzes
.tsfiles only (.tsxsupport planned) - Pattern matching is heuristic — may miss custom I/O patterns
- Does not trace transitive purity (a function calling another function)
- Quality weights are opinionated and tuned for specific patterns
Further Reading
- TECHNICAL.md — Implementation details, scoring weights, extension points
- Gary Bernhardt's "Boundaries" talk — Original FCIS concept
- Mark Seemann's "Impureim Sandwich" — Similar pattern
License
MIT
