fcis 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,133 +2,144 @@
2
2
 
3
3
  **Functional Core, Imperative Shell** analyzer for TypeScript codebases.
4
4
 
5
- FCIS is a static analysis tool that measures how well your TypeScript code separates pure business logic from I/O and side effects. It answers the question:
5
+ <p align="center">
6
+ <img src="docs/images/fc-is-submarine.webp" alt="FCIS Submarine: Keep the chaos in the waves, keep the math underwater" width="600" />
7
+ </p>
6
8
 
7
- > "Can this function's logic be tested without mocking anything?"
9
+ <p align="center"><em>Keep the chaos in the waves. Keep the math underwater.</em></p>
8
10
 
9
- ## Installation
11
+ ## Philosophy
10
12
 
11
- ```bash
12
- pnpm install
13
- pnpm build
14
- ```
13
+ FCIS is built on a simple observation: **some code is easier to trust than others.**
15
14
 
16
- ## Quick Start
17
-
18
- ```bash
19
- # Analyze a project
20
- npx fcis tsconfig.json
15
+ Consider two functions:
21
16
 
22
- # Analyze with health threshold (CI gate)
23
- npx fcis tsconfig.json --min-health 70
24
-
25
- # Output JSON report
26
- npx fcis tsconfig.json --format json --output report.json
17
+ ```typescript
18
+ // Function A: Pure
19
+ function calculateDiscount(price: number, memberYears: number): number {
20
+ if (memberYears >= 5) return price * 0.20
21
+ if (memberYears >= 2) return price * 0.10
22
+ return 0
23
+ }
27
24
 
28
- # Analyze specific files (for pre-commit hooks)
29
- npx fcis tsconfig.json --files "src/services/**/*.ts"
25
+ // Function B: Impure
26
+ async function applyDiscount(userId: string) {
27
+ const user = await db.user.findFirst({ where: { id: userId } })
28
+ const cart = await db.cart.findFirst({ where: { userId } })
29
+ let discount = 0
30
+ if (user.memberSince) {
31
+ const years = (Date.now() - user.memberSince.getTime()) / (365 * 24 * 60 * 60 * 1000)
32
+ if (years >= 5) discount = cart.total * 0.20
33
+ else if (years >= 2) discount = cart.total * 0.10
34
+ }
35
+ await db.cart.update({ where: { id: cart.id }, data: { discount } })
36
+ await sendEmail(user.email, `You saved $${discount}!`)
37
+ }
30
38
  ```
31
39
 
32
- ## What It Measures
40
+ **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.
33
41
 
34
- ### Purity (0-100%)
42
+ **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.
35
43
 
36
- Percentage of **top-level** functions that are pure no I/O markers detected. Pure functions:
37
- - Take arguments and return values
38
- - Have no side effects
39
- - Can be tested without mocking
44
+ This is the core insight of the **Functional Core, Imperative Shell** pattern:
40
45
 
41
- ### Impurity Quality (0-100)
46
+ > 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.
42
47
 
43
- For impure functions, measures how **well-structured** the I/O code is:
44
- - **High (≥70):** I/O is organized, calls pure functions, follows GATHER→DECIDE→EXECUTE pattern
45
- - **Medium (40-69):** Some structure, room for improvement
46
- - **Low (<40):** Tangled code, business logic mixed with I/O
48
+ ## What This Tool Measures
47
49
 
48
- ### Health (0-100%)
50
+ FCIS doesn't try to eliminate impure code — **you need I/O to build useful software**. Instead, it measures:
49
51
 
50
- Percentage of top-level functions with status **OK** (either pure, or impure with quality ≥70).
52
+ ### 1. How much of your logic is testable without mocks? **Purity**
51
53
 
52
- ### Compositional Scoring
54
+ 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.
53
55
 
54
- Inline callbacks (passed to `map`, `filter`, `forEach`, etc.) are **absorbed into their parent function's score** rather than counted independently. This prevents:
55
- - Inflated function counts (a 301-line function with 6 callbacks is 1 function, not 7)
56
- - Diluted health scores (callbacks can't mask a problematic parent)
57
- - Double-counted line counts
56
+ *Purity = pure functions / total functions*
58
57
 
59
- If a callback is impure, the parent is considered effectively impure. Quality scores are blended by line count.
58
+ ### 2. When you do have I/O, is it well-organized? **Impurity Quality**
60
59
 
61
- See [TECHNICAL.md](./TECHNICAL.md#compositional-function-scoring) for details.
60
+ Impure functions aren't bad — they're necessary. But there's a difference between:
62
61
 
63
- ## Function Classification
62
+ - **Well-structured:** Gathers data, calls pure functions for decisions, executes effects (GATHER → DECIDE → EXECUTE)
63
+ - **Tangled:** Business logic interleaved with database calls, conditionals mixed with I/O, impossible to test in pieces
64
64
 
65
- Every function is classified as:
65
+ FCIS scores impure functions from 0-100 based on structural signals. A score of 70+ means "this I/O code is well-organized."
66
66
 
67
- | Classification | Criteria | Testable without mocks? |
68
- |---------------|----------|------------------------|
69
- | **Pure** | No I/O markers | ✅ Yes |
70
- | **Impure** | Has I/O markers (await, db, fetch, fs, etc.) | ❌ No |
67
+ ### 3. Overall: How confident can you be in this codebase? → **Health**
71
68
 
72
- ### Status Derivation
69
+ **Health** combines purity and quality into a single number:
73
70
 
74
- | Classification | Quality Score | Status | Action |
75
- |---------------|---------------|--------|--------|
76
- | Pure | n/a | OK | None needed |
77
- | Impure | ≥ 70 | ✓ OK | Well-structured |
78
- | Impure | 40-69 | ◐ Review | Consider improving |
79
- | Impure | < 40 | ✗ Refactor | Prioritize cleanup |
71
+ - Pure functions are automatically "healthy" (trivially testable)
72
+ - Impure functions with quality ≥70 are "healthy" (well-structured, integration-testable)
73
+ - Impure functions with quality <70 need attention
80
74
 
81
- ## Impurity Markers
75
+ *Health = functions with OK status / total functions*
82
76
 
83
- The analyzer detects these I/O patterns:
77
+ **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.
84
78
 
85
- | Marker | Examples |
86
- |--------|----------|
87
- | `await-expression` | `await fetch()`, `await db.query()` |
88
- | `database-call` | `db.user.findFirst()`, `prisma.post.create()` |
89
- | `network-fetch` | `fetch(url)` |
90
- | `network-http` | `axios.get()`, imports from `axios` |
91
- | `fs-call` | `fs.readFile()`, `fs.writeFile()` |
92
- | `env-access` | `process.env.NODE_ENV` |
93
- | `console-log` | `console.log()`, `console.error()` |
94
- | `logging` | `logger.info()`, imports from logger |
95
- | `telemetry` | `trackEvent()`, `analytics.track()` |
96
- | `queue-enqueue` | `queue.enqueue()`, `queue.add()` |
97
- | `event-emit` | `emitter.emit()`, `dispatcher.dispatch()` |
98
-
99
- **Note:** `async` alone does NOT make a function impure — only actual I/O markers count.
79
+ ## The FCIS Pattern
100
80
 
101
- ## CLI Reference
81
+ The pattern this tool encourages:
102
82
 
103
83
  ```
104
- fcis <tsconfig> [options]
84
+ ┌─────────────────────────────────────────────────────────────┐
85
+ │ IMPERATIVE SHELL │
86
+ │ │
87
+ │ async function handleRequest(id: string) { │
88
+ │ // GATHER - get data from the outside world │
89
+ │ const user = await db.user.findFirst(...) │
90
+ │ const permissions = await authService.check(...) │
91
+ │ │
92
+ │ // DECIDE - call pure functions (testable!) │
93
+ │ const plan = planUserAction(user, permissions) │
94
+ │ │
95
+ │ // EXECUTE - write to the outside world │
96
+ │ await db.audit.create({ data: plan.auditEntry }) │
97
+ │ return plan.response │
98
+ │ } │
99
+ └─────────────────────────────────────────────────────────────┘
100
+
101
+
102
+ ┌─────────────────────────────────────────────────────────────┐
103
+ │ FUNCTIONAL CORE │
104
+ │ │
105
+ │ function planUserAction(user: User, perms: Permissions) { │
106
+ │ // Pure logic - no I/O, no side effects │
107
+ │ // Easy to test: input → output │
108
+ │ if (!perms.canAct) { │
109
+ │ return { allowed: false, reason: 'forbidden' } │
110
+ │ } │
111
+ │ return { │
112
+ │ allowed: true, │
113
+ │ auditEntry: { userId: user.id, action: 'acted' }, │
114
+ │ response: { success: true } │
115
+ │ } │
116
+ │ } │
117
+ └─────────────────────────────────────────────────────────────┘
118
+ ```
105
119
 
106
- Arguments:
107
- tsconfig Path to tsconfig.json
120
+ ## Installation
108
121
 
109
- Options:
110
- --json Output JSON to stdout
111
- --output, -o <file> Write JSON report to file
112
- --min-health <N> Exit code 1 if health < N (0-100)
113
- --min-purity <N> Exit code 1 if purity < N (0-100)
114
- --min-quality <N> Exit code 1 if impurity quality < N (0-100)
115
- --files, -f <glob> Analyze only matching files
116
- --format <fmt> Output: console (default), json, summary
117
- --dir-depth <N> Roll up directory metrics to depth N (e.g., 1 for top-level)
118
- --quiet, -q Suppress output, use exit code only
119
- --verbose, -v Show per-file details
120
- --help Show help
121
- --version Show version
122
+ ```bash
123
+ npm install -g fcis
124
+ # or
125
+ pnpm add -g fcis
122
126
  ```
123
127
 
124
- ### Exit Codes
128
+ ## Quick Start
125
129
 
126
- | Code | Meaning |
127
- |------|---------|
128
- | 0 | Success, all thresholds passed |
129
- | 1 | Analysis completed but below threshold |
130
- | 2 | Configuration error (invalid options, tsconfig not found) |
131
- | 3 | Analysis error (no files could be analyzed) |
130
+ ```bash
131
+ # Analyze a project
132
+ fcis tsconfig.json
133
+
134
+ # Set a health threshold for CI
135
+ fcis tsconfig.json --min-health 70
136
+
137
+ # Output JSON for further processing
138
+ fcis tsconfig.json --format json --output report.json
139
+
140
+ # Analyze specific files (for pre-commit hooks)
141
+ fcis tsconfig.json --files "src/services/**/*.ts"
142
+ ```
132
143
 
133
144
  ## Example Output
134
145
 
@@ -157,29 +168,86 @@ Top Refactoring Candidates:
157
168
  Markers: database-call, await-expression
158
169
  ```
159
170
 
160
- ### Directory Rollup
171
+ ## What Makes a Function Impure?
161
172
 
162
- To see aggregate metrics for top-level directories instead of leaf directories:
173
+ FCIS detects these I/O patterns:
163
174
 
164
- ```bash
165
- fcis tsconfig.json --dir-depth 1
166
- ```
175
+ | Marker | Examples |
176
+ |--------|----------|
177
+ | `await-expression` | `await fetch()`, `await db.query()` |
178
+ | `database-call` | `db.user.findFirst()`, `prisma.post.create()` |
179
+ | `network-fetch` | `fetch(url)` |
180
+ | `network-http` | `axios.get()` |
181
+ | `fs-call` | `fs.readFile()`, `fs.writeFile()` |
182
+ | `env-access` | `process.env.NODE_ENV` |
183
+ | `console-log` | `console.log()`, `console.error()` |
184
+ | `logging` | `logger.info()` |
185
+ | `telemetry` | `trackEvent()`, `analytics.track()` |
186
+ | `queue-enqueue` | `queue.enqueue()`, `queue.add()` |
187
+ | `event-emit` | `emitter.emit()` |
167
188
 
168
- Example output:
189
+ **Note:** `async` alone does NOT make a function impure — only actual I/O operations count.
190
+
191
+ ## What Makes Impure Code "High Quality"?
192
+
193
+ FCIS rewards structural patterns that make impure code easier to understand and test:
194
+
195
+ | Signal | Why It's Good |
196
+ |--------|---------------|
197
+ | Calls `.pure.ts` imports | Explicitly separates pure logic |
198
+ | Calls `plan*/derive*/compute*` | Uses pure functions for decisions |
199
+ | I/O at start (GATHER) | Clear data-fetching phase |
200
+ | I/O at end (EXECUTE) | Clear effect-execution phase |
201
+ | Low complexity | Simple orchestration |
202
+ | Calls `is*/has*/should*` | Uses pure predicates |
203
+
204
+ And penalizes patterns that make code hard to reason about:
205
+
206
+ | Signal | Why It's Bad |
207
+ |--------|--------------|
208
+ | I/O interleaved throughout | Can't separate "what" from "how" |
209
+ | High cyclomatic complexity | Too much logic mixed with I/O |
210
+ | Multiple I/O types | Too many responsibilities |
211
+ | No pure function calls | All logic is inline and untestable |
212
+ | Very long function | God function, needs decomposition |
213
+
214
+ ## Compositional Scoring
215
+
216
+ Inline callbacks (passed to `map`, `filter`, `forEach`, etc.) are absorbed into their parent function's score. This means:
217
+
218
+ - A 301-line function with 6 callbacks counts as **1 function**, not 7
219
+ - If a callback is impure, the parent is considered impure
220
+ - Quality scores blend parent and children by line count
221
+
222
+ This prevents gaming the metrics with lots of small callbacks while leaving a tangled parent function.
223
+
224
+ ## CLI Reference
169
225
 
170
226
  ```
171
- Directory Breakdown (depth=1):
172
- ────────────────────────────────────────────────────────────────────────────────
173
- Directory Health Purity Quality Functions
174
- ────────────────────────────────────────────────────────────────────────────────
175
- src/agents 45% 30% 52% 12/40
176
- src/api 62% 55% 48% 28/51
177
- src/graph-flow 38% 25% 41% 45/120
178
- src/providers 85% 80% 72% 8/10
179
- src/services 52% 40% 55% 89/171
227
+ fcis <tsconfig> [options]
228
+
229
+ Options:
230
+ --min-health <N> Exit code 1 if health < N (0-100)
231
+ --min-purity <N> Exit code 1 if purity < N (0-100)
232
+ --min-quality <N> Exit code 1 if impurity quality < N (0-100)
233
+ --files, -f <glob> Analyze only matching files
234
+ --format <fmt> Output: console (default), json, summary
235
+ --output, -o <file> Write JSON report to file
236
+ --dir-depth <N> Roll up directory metrics to depth N
237
+ --quiet, -q Suppress output, use exit code only
238
+ --verbose, -v Show per-file details
239
+ --help Show help
240
+ --version Show version
180
241
  ```
181
242
 
182
- This shows ALL directories at the specified depth with aggregated metrics, providing a high-level overview of codebase health by area. When using `--dir-depth` with `--json` or `--output`, the JSON output will include a `rolledUpDirectories` field alongside the full `directoryScores`.
243
+ ### Exit Codes
244
+
245
+ | Code | Meaning |
246
+ |------|---------|
247
+ | 0 | Success, all thresholds passed |
248
+ | 1 | Below threshold |
249
+ | 2 | Configuration error |
250
+ | 3 | Analysis error |
183
251
 
184
252
  ## CI Integration
185
253
 
@@ -187,10 +255,10 @@ This shows ALL directories at the specified depth with aggregated metrics, provi
187
255
 
188
256
  ```yaml
189
257
  - name: FCIS Analysis
190
- run: npx fcis tsconfig.json --min-health 70 --format summary
258
+ run: fcis tsconfig.json --min-health 70 --format summary
191
259
  ```
192
260
 
193
- ### Pre-commit Hook (lint-staged)
261
+ ### Pre-commit Hook
194
262
 
195
263
  ```json
196
264
  {
@@ -200,25 +268,10 @@ This shows ALL directories at the specified depth with aggregated metrics, provi
200
268
  }
201
269
  ```
202
270
 
203
- ## The FCIS Pattern
204
-
205
- The **Functional Core, Imperative Shell** pattern separates code into:
206
-
207
- ### Pure Core (Functional)
208
- - Business logic, calculations, validations
209
- - Takes data in, returns data out
210
- - No side effects
211
- - Trivially testable
271
+ ## Refactoring Example
212
272
 
213
- ### Impure Shell (Imperative)
214
- - I/O operations (database, network, file system)
215
- - Orchestrates: GATHER data → call pure functions → EXECUTE effects
216
- - Thin wrapper around pure core
217
- - Requires integration tests
273
+ **Before (tangled quality score ~25):**
218
274
 
219
- ### Example Refactoring
220
-
221
- **Before (tangled):**
222
275
  ```typescript
223
276
  async function acceptInvite(inviteId: string) {
224
277
  const invite = await db.invitation.findFirst({ where: { id: inviteId } })
@@ -226,7 +279,7 @@ async function acceptInvite(inviteId: string) {
226
279
 
227
280
  const org = await db.organization.findFirst({ where: { id: invite.orgId } })
228
281
 
229
- // Business logic mixed with I/O
282
+ // Business logic mixed with I/O — hard to test!
230
283
  if (invite.expiresAt < new Date()) {
231
284
  await db.invitation.update({ where: { id: inviteId }, data: { status: 'expired' } })
232
285
  throw new Error('Expired')
@@ -241,10 +294,14 @@ async function acceptInvite(inviteId: string) {
241
294
  }
242
295
  ```
243
296
 
244
- **After (FCIS):**
297
+ **After (FCIS pattern — quality score ~80):**
298
+
245
299
  ```typescript
246
- // Pure core - testable without mocks
247
- export function planAcceptInvite(invite: Invitation, org: Organization): AcceptInvitePlan {
300
+ // PURE: Testable with simple assertions
301
+ function planAcceptInvite(
302
+ invite: Invitation,
303
+ org: Organization
304
+ ): { action: 'accept', member: MemberData } | { action: 'reject', reason: string } {
248
305
  if (invite.expiresAt < new Date()) {
249
306
  return { action: 'reject', reason: 'expired' }
250
307
  }
@@ -253,17 +310,18 @@ export function planAcceptInvite(invite: Invitation, org: Organization): AcceptI
253
310
  }
254
311
  return {
255
312
  action: 'accept',
256
- memberData: { userId: invite.userId, orgId: org.id }
313
+ member: { userId: invite.userId, orgId: org.id }
257
314
  }
258
315
  }
259
316
 
260
- // Impure shell - thin orchestration
317
+ // IMPURE: Thin shell, clear GATHER → DECIDE → EXECUTE
261
318
  async function acceptInvite(inviteId: string) {
262
319
  // GATHER
263
320
  const invite = await db.invitation.findFirst({ where: { id: inviteId } })
321
+ if (!invite) throw new Error('Not found')
264
322
  const org = await db.organization.findFirst({ where: { id: invite.orgId } })
265
323
 
266
- // DECIDE (pure)
324
+ // DECIDE
267
325
  const plan = planAcceptInvite(invite, org)
268
326
 
269
327
  // EXECUTE
@@ -271,37 +329,26 @@ async function acceptInvite(inviteId: string) {
271
329
  await db.invitation.update({ where: { id: inviteId }, data: { status: plan.reason } })
272
330
  throw new Error(plan.reason)
273
331
  }
274
-
275
- await db.member.create({ data: plan.memberData })
332
+ await db.member.create({ data: plan.member })
276
333
  await db.invitation.update({ where: { id: inviteId }, data: { status: 'accepted' } })
277
334
  }
278
335
  ```
279
336
 
280
- ## Quality Score Signals
337
+ 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.
281
338
 
282
- ### Positive (increase score)
283
- - Calls functions from `.pure.ts` files (+30)
284
- - Calls `plan*/derive*/compute*/transform*` functions (+20)
285
- - I/O concentrated at start (GATHER pattern) (+15)
286
- - I/O concentrated at end (EXECUTE pattern) (+15)
287
- - Low cyclomatic complexity (+10)
288
- - Shell naming convention (`handle*/fetch*/save*`) (+5)
289
- - Calls predicate functions (`is*/has*/should*`) (+5)
339
+ ## Limitations
290
340
 
291
- ### Negative (decrease score)
292
- - I/O interleaved throughout (-20)
293
- - High cyclomatic complexity (-15)
294
- - Multiple I/O types (db + http + fs) (-10)
295
- - No pure function calls (-10)
296
- - Very long function (>100 lines) (-10)
341
+ - Analyzes `.ts` files only (`.tsx` support planned)
342
+ - Pattern matching is heuristic — may miss custom I/O patterns
343
+ - Does not trace transitive purity (a function calling another function)
344
+ - Quality weights are opinionated and tuned for specific patterns
297
345
 
298
- ## Limitations
346
+ ## Further Reading
299
347
 
300
- - **v1 analyzes `.ts` files only** `.tsx` files are deferred to v2
301
- - Pattern matching is heuristic-basedmay miss custom I/O patterns
302
- - Does not analyze transitive purity (function calling another function)
303
- - Quality scoring weights are tuned for SchoolAI patterns
348
+ - [TECHNICAL.md](./TECHNICAL.md)Implementation details, scoring weights, extension points
349
+ - [Gary Bernhardt's "Boundaries" talk](https://www.destroyallsoftware.com/talks/boundaries)Original FCIS concept
350
+ - [Mark Seemann's "Impureim Sandwich"](https://blog.ploeh.dk/2020/03/02/impureim-sandwich/) Similar pattern
304
351
 
305
352
  ## License
306
353
 
307
- MIT
354
+ MIT
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from 'module';
2
3
  import { cli } from 'cleye';
3
4
  import chalk from 'chalk';
4
5
  import * as fs3 from 'fs';
@@ -1912,6 +1913,8 @@ var EXIT_SUCCESS = 0;
1912
1913
  var EXIT_THRESHOLD_FAILED = 1;
1913
1914
  var EXIT_CONFIG_ERROR = 2;
1914
1915
  var EXIT_ANALYSIS_ERROR = 3;
1916
+ var require2 = createRequire(import.meta.url);
1917
+ var pkg = require2("../package.json");
1915
1918
  function handleAnalysisOutput(score, flags) {
1916
1919
  if (!flags.quiet) {
1917
1920
  if (flags.json || flags.format === "json") {
@@ -1963,7 +1966,7 @@ function ensureOutputDirectory(outputPath) {
1963
1966
  }
1964
1967
  var argv = cli({
1965
1968
  name: "fcis",
1966
- version: "0.1.0",
1969
+ version: pkg.version,
1967
1970
  flags: {
1968
1971
  json: {
1969
1972
  type: Boolean,