fcis 0.1.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,108 +2,238 @@
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>
10
+
11
+ ## Philosophy
12
+
13
+ FCIS is built on a simple observation: **some code is easier to trust than others.**
14
+
15
+ Consider two functions:
16
+
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
+ }
24
+
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
+ }
38
+ ```
39
+
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.
41
+
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.
43
+
44
+ This is the core insight of the **Functional Core, Imperative Shell** pattern:
45
+
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.
47
+
48
+ ## What This Tool Measures
49
+
50
+ FCIS doesn't try to eliminate impure code — **you need I/O to build useful software**. Instead, it measures:
51
+
52
+ ### 1. How much of your logic is testable without mocks? → **Purity**
53
+
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.
55
+
56
+ *Purity = pure functions / total functions*
57
+
58
+ ### 2. When you do have I/O, is it well-organized? → **Impurity Quality**
59
+
60
+ Impure functions aren't bad — they're necessary. But there's a difference between:
61
+
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
+
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
+
67
+ ### 3. Overall: How confident can you be in this codebase? → **Health**
68
+
69
+ **Health** combines purity and quality into a single number:
70
+
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
74
+
75
+ *Health = functions with OK status / total functions*
76
+
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.
78
+
79
+ ## The FCIS Pattern
80
+
81
+ The pattern this tool encourages:
82
+
83
+ ```
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
+ ```
8
119
 
9
120
  ## Installation
10
121
 
11
122
  ```bash
12
- pnpm install
13
- pnpm build
123
+ npm install -g fcis
124
+ # or
125
+ pnpm add -g fcis
14
126
  ```
15
127
 
16
128
  ## Quick Start
17
129
 
18
130
  ```bash
19
131
  # Analyze a project
20
- npx fcis tsconfig.json
132
+ fcis tsconfig.json
21
133
 
22
- # Analyze with health threshold (CI gate)
23
- npx fcis tsconfig.json --min-health 70
134
+ # Set a health threshold for CI
135
+ fcis tsconfig.json --min-health 70
24
136
 
25
- # Output JSON report
26
- npx fcis tsconfig.json --format json --output report.json
137
+ # Output JSON for further processing
138
+ fcis tsconfig.json --format json --output report.json
27
139
 
28
140
  # Analyze specific files (for pre-commit hooks)
29
- npx fcis tsconfig.json --files "src/services/**/*.ts"
141
+ fcis tsconfig.json --files "src/services/**/*.ts"
30
142
  ```
31
143
 
32
- ## What It Measures
33
-
34
- ### Purity (0-100%)
35
-
36
- Percentage of 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
40
-
41
- ### Impurity Quality (0-100)
42
-
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
47
-
48
- ### Health (0-100%)
144
+ ## Example Output
49
145
 
50
- Percentage of functions with status **OK** (either pure, or impure with quality ≥70).
146
+ ```
147
+ FCIS Analysis
148
+ ═══════════════════════════════════════════════════════════
51
149
 
52
- ## Function Classification
150
+ Project Health: 77% ████████████████████░░░░░
151
+ Purity: 45% (234 pure / 520 total)
152
+ Impurity Quality: 68% average
53
153
 
54
- Every function is classified as:
154
+ Status Breakdown:
155
+ ✓ OK: 312 functions (60%) — no action needed
156
+ ◐ Review: 89 functions (17%) — could be improved
157
+ ✗ Refactor: 119 functions (23%) — tangled, needs work
55
158
 
56
- | Classification | Criteria | Testable without mocks? |
57
- |---------------|----------|------------------------|
58
- | **Pure** | No I/O markers | ✅ Yes |
59
- | **Impure** | Has I/O markers (await, db, fetch, fs, etc.) | ❌ No |
159
+ Top Refactoring Candidates:
160
+ (Sorted by impact: size × complexity)
60
161
 
61
- ### Status Derivation
162
+ 1. 25/100 processOrder (150 lines)
163
+ /src/services/orders.ts:45
164
+ Markers: database-call, network-fetch, console-log
62
165
 
63
- | Classification | Quality Score | Status | Action |
64
- |---------------|---------------|--------|--------|
65
- | Pure | n/a | ✓ OK | None needed |
66
- | Impure | ≥ 70 | ✓ OK | Well-structured |
67
- | Impure | 40-69 | ◐ Review | Consider improving |
68
- | Impure | < 40 | ✗ Refactor | Prioritize cleanup |
166
+ 2. 32/100 handleUserUpdate (98 lines)
167
+ /src/services/users.ts:120
168
+ Markers: database-call, await-expression
169
+ ```
69
170
 
70
- ## Impurity Markers
171
+ ## What Makes a Function Impure?
71
172
 
72
- The analyzer detects these I/O patterns:
173
+ FCIS detects these I/O patterns:
73
174
 
74
175
  | Marker | Examples |
75
176
  |--------|----------|
76
177
  | `await-expression` | `await fetch()`, `await db.query()` |
77
178
  | `database-call` | `db.user.findFirst()`, `prisma.post.create()` |
78
179
  | `network-fetch` | `fetch(url)` |
79
- | `network-http` | `axios.get()`, imports from `axios` |
180
+ | `network-http` | `axios.get()` |
80
181
  | `fs-call` | `fs.readFile()`, `fs.writeFile()` |
81
- | `fs-import` | Import from `fs`, `node:fs` |
82
182
  | `env-access` | `process.env.NODE_ENV` |
83
183
  | `console-log` | `console.log()`, `console.error()` |
84
- | `logging` | `logger.info()`, imports from logger |
184
+ | `logging` | `logger.info()` |
85
185
  | `telemetry` | `trackEvent()`, `analytics.track()` |
86
186
  | `queue-enqueue` | `queue.enqueue()`, `queue.add()` |
87
- | `event-emit` | `emitter.emit()`, `dispatcher.dispatch()` |
187
+ | `event-emit` | `emitter.emit()` |
188
+
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
88
221
 
89
- **Note:** `async` alone does NOT make a function impure only actual I/O markers count.
222
+ This prevents gaming the metrics with lots of small callbacks while leaving a tangled parent function.
90
223
 
91
224
  ## CLI Reference
92
225
 
93
226
  ```
94
227
  fcis <tsconfig> [options]
95
228
 
96
- Arguments:
97
- tsconfig Path to tsconfig.json
98
-
99
229
  Options:
100
- --json Output JSON to stdout
101
- --output, -o <file> Write JSON report to file
102
230
  --min-health <N> Exit code 1 if health < N (0-100)
103
231
  --min-purity <N> Exit code 1 if purity < N (0-100)
104
232
  --min-quality <N> Exit code 1 if impurity quality < N (0-100)
105
233
  --files, -f <glob> Analyze only matching files
106
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
107
237
  --quiet, -q Suppress output, use exit code only
108
238
  --verbose, -v Show per-file details
109
239
  --help Show help
@@ -115,36 +245,9 @@ Options:
115
245
  | Code | Meaning |
116
246
  |------|---------|
117
247
  | 0 | Success, all thresholds passed |
118
- | 1 | Analysis completed but below threshold |
119
- | 2 | Configuration error (invalid options, tsconfig not found) |
120
- | 3 | Analysis error (no files could be analyzed) |
121
-
122
- ## Example Output
123
-
124
- ```
125
- FCIS Analysis
126
- ═══════════════════════════════════════════════════════════
127
-
128
- Project Health: 77% ████████████████████░░░░░
129
- Purity: 45% (234 pure / 520 total)
130
- Impurity Quality: 68% average
131
-
132
- Status Breakdown:
133
- ✓ OK: 312 functions (60%) — no action needed
134
- ◐ Review: 89 functions (17%) — could be improved
135
- ✗ Refactor: 119 functions (23%) — tangled, needs work
136
-
137
- Top Refactoring Candidates:
138
- (Sorted by impact: size × complexity)
139
-
140
- 1. 25/100 processOrder (150 lines)
141
- /src/services/orders.ts:45
142
- Markers: database-call, network-fetch, console-log
143
-
144
- 2. 32/100 handleUserUpdate (98 lines)
145
- /src/services/users.ts:120
146
- Markers: database-call, await-expression
147
- ```
248
+ | 1 | Below threshold |
249
+ | 2 | Configuration error |
250
+ | 3 | Analysis error |
148
251
 
149
252
  ## CI Integration
150
253
 
@@ -152,10 +255,10 @@ Top Refactoring Candidates:
152
255
 
153
256
  ```yaml
154
257
  - name: FCIS Analysis
155
- run: npx fcis tsconfig.json --min-health 70 --format summary
258
+ run: fcis tsconfig.json --min-health 70 --format summary
156
259
  ```
157
260
 
158
- ### Pre-commit Hook (lint-staged)
261
+ ### Pre-commit Hook
159
262
 
160
263
  ```json
161
264
  {
@@ -165,25 +268,10 @@ Top Refactoring Candidates:
165
268
  }
166
269
  ```
167
270
 
168
- ## The FCIS Pattern
169
-
170
- The **Functional Core, Imperative Shell** pattern separates code into:
171
-
172
- ### Pure Core (Functional)
173
- - Business logic, calculations, validations
174
- - Takes data in, returns data out
175
- - No side effects
176
- - Trivially testable
271
+ ## Refactoring Example
177
272
 
178
- ### Impure Shell (Imperative)
179
- - I/O operations (database, network, file system)
180
- - Orchestrates: GATHER data → call pure functions → EXECUTE effects
181
- - Thin wrapper around pure core
182
- - Requires integration tests
273
+ **Before (tangled quality score ~25):**
183
274
 
184
- ### Example Refactoring
185
-
186
- **Before (tangled):**
187
275
  ```typescript
188
276
  async function acceptInvite(inviteId: string) {
189
277
  const invite = await db.invitation.findFirst({ where: { id: inviteId } })
@@ -191,7 +279,7 @@ async function acceptInvite(inviteId: string) {
191
279
 
192
280
  const org = await db.organization.findFirst({ where: { id: invite.orgId } })
193
281
 
194
- // Business logic mixed with I/O
282
+ // Business logic mixed with I/O — hard to test!
195
283
  if (invite.expiresAt < new Date()) {
196
284
  await db.invitation.update({ where: { id: inviteId }, data: { status: 'expired' } })
197
285
  throw new Error('Expired')
@@ -206,10 +294,14 @@ async function acceptInvite(inviteId: string) {
206
294
  }
207
295
  ```
208
296
 
209
- **After (FCIS):**
297
+ **After (FCIS pattern — quality score ~80):**
298
+
210
299
  ```typescript
211
- // Pure core - testable without mocks
212
- 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 } {
213
305
  if (invite.expiresAt < new Date()) {
214
306
  return { action: 'reject', reason: 'expired' }
215
307
  }
@@ -218,17 +310,18 @@ export function planAcceptInvite(invite: Invitation, org: Organization): AcceptI
218
310
  }
219
311
  return {
220
312
  action: 'accept',
221
- memberData: { userId: invite.userId, orgId: org.id }
313
+ member: { userId: invite.userId, orgId: org.id }
222
314
  }
223
315
  }
224
316
 
225
- // Impure shell - thin orchestration
317
+ // IMPURE: Thin shell, clear GATHER → DECIDE → EXECUTE
226
318
  async function acceptInvite(inviteId: string) {
227
319
  // GATHER
228
320
  const invite = await db.invitation.findFirst({ where: { id: inviteId } })
321
+ if (!invite) throw new Error('Not found')
229
322
  const org = await db.organization.findFirst({ where: { id: invite.orgId } })
230
323
 
231
- // DECIDE (pure)
324
+ // DECIDE
232
325
  const plan = planAcceptInvite(invite, org)
233
326
 
234
327
  // EXECUTE
@@ -236,37 +329,26 @@ async function acceptInvite(inviteId: string) {
236
329
  await db.invitation.update({ where: { id: inviteId }, data: { status: plan.reason } })
237
330
  throw new Error(plan.reason)
238
331
  }
239
-
240
- await db.member.create({ data: plan.memberData })
332
+ await db.member.create({ data: plan.member })
241
333
  await db.invitation.update({ where: { id: inviteId }, data: { status: 'accepted' } })
242
334
  }
243
335
  ```
244
336
 
245
- ## 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.
246
338
 
247
- ### Positive (increase score)
248
- - Calls functions from `.pure.ts` files (+30)
249
- - Calls `plan*/derive*/compute*/transform*` functions (+20)
250
- - I/O concentrated at start (GATHER pattern) (+15)
251
- - I/O concentrated at end (EXECUTE pattern) (+15)
252
- - Low cyclomatic complexity (+10)
253
- - Shell naming convention (`handle*/fetch*/save*`) (+5)
254
- - Calls predicate functions (`is*/has*/should*`) (+5)
339
+ ## Limitations
255
340
 
256
- ### Negative (decrease score)
257
- - I/O interleaved throughout (-20)
258
- - High cyclomatic complexity (-15)
259
- - Multiple I/O types (db + http + fs) (-10)
260
- - No pure function calls (-10)
261
- - 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
262
345
 
263
- ## Limitations
346
+ ## Further Reading
264
347
 
265
- - **v1 analyzes `.ts` files only** `.tsx` files are deferred to v2
266
- - Pattern matching is heuristic-basedmay miss custom I/O patterns
267
- - Does not analyze transitive purity (function calling another function)
268
- - 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
269
351
 
270
352
  ## License
271
353
 
272
- MIT
354
+ MIT
package/TECHNICAL.md CHANGED
@@ -64,11 +64,14 @@ type ExtractedFunction = {
64
64
  bodyLineCount: number
65
65
  statementCount: number
66
66
  hasConditionals: boolean
67
- parentContext: string | null
67
+ parentContext: string | null // class name, variable name, or HOF method name
68
68
  callSites: CallSite[]
69
69
  hasAwait: boolean
70
70
  propertyAccessChains: string[]
71
71
  kind: 'function' | 'method' | 'arrow' | 'function-expression' | 'getter' | 'setter'
72
+ // Compositional scoring fields
73
+ enclosingFunctionStartLine: number | null // parent function's startLine, null if module-scope
74
+ isInlineCallback: boolean // true if callback to known HOF (map, filter, etc.)
72
75
  }
73
76
 
74
77
  // Marker types - strict union for exhaustive matching
@@ -77,7 +80,6 @@ type MarkerType =
77
80
  | 'database-call'
78
81
  | 'network-fetch'
79
82
  | 'network-http'
80
- | 'fs-import'
81
83
  | 'fs-call'
82
84
  | 'env-access'
83
85
  | 'console-log'
@@ -374,6 +376,127 @@ project.createSourceFile('/test.ts', `
374
376
  | `chalk` | Colored console output |
375
377
  | `ts-pattern` | Pattern matching (optional) |
376
378
 
379
+ ## Directory Rollup
380
+
381
+ When `--dir-depth N` is specified, directory metrics are aggregated hierarchically.
382
+
383
+ ### FC/IS Architecture
384
+
385
+ The rollup feature follows the Functional Core / Imperative Shell pattern:
386
+
387
+ - **Shell layer** (`report-console.ts`, `report-json.ts`): Converts absolute paths to relative paths using `path.relative()`
388
+ - **Pure core** (`scorer.ts`): The `rollupDirectoriesByDepth()` function operates on already-relative paths, keeping the scoring module free of I/O dependencies
389
+
390
+ ### Algorithm
391
+
392
+ 1. Shell layer converts absolute directory paths to relative paths
393
+ 2. Pure function truncates paths to N+1 segments (e.g., depth 1 → `src/services`)
394
+ 3. Directories with the same truncated path are grouped together
395
+ 4. Metrics are aggregated using weighted averages (weighted by function count):
396
+ - Health = (total ok functions) / (total functions) × 100
397
+ - Purity = (total pure functions) / (total functions) × 100
398
+ - Impurity Quality = weighted average by impure function count
399
+ 5. All directories at the specified depth are shown, sorted alphabetically
400
+
401
+ This provides a high-level view of codebase health by area without the noise of deeply nested leaf directories.
402
+
403
+ ### Edge Cases
404
+
405
+ - **Depth exceeds nesting:** Directories shallower than the requested depth are shown at their actual depth
406
+ - **Single directory:** Works correctly with a single input directory
407
+ - **Empty directories:** Directories with no functions are excluded from aggregation
408
+
409
+ ### JSON Output
410
+
411
+ When `--dir-depth` is used with `--json` or `--output`, the JSON includes:
412
+ - `directoryScores` — full leaf-level data (always present)
413
+ - `rolledUpDirectories` — aggregated data at specified depth (only when `--dir-depth` used)
414
+
415
+ This is non-breaking for existing consumers.
416
+
417
+ ## Compositional Function Scoring
418
+
419
+ FCIS uses compositional scoring to handle inline callbacks (arrow functions passed to `map`, `filter`, `forEach`, etc.). This prevents inflated function counts and diluted health scores.
420
+
421
+ ### Inline Callback Detection
422
+
423
+ Functions are marked as inline callbacks (`isInlineCallback: true`) when passed to known higher-order functions:
424
+
425
+ **Included (absorbed into parent):**
426
+ - Array methods: `map`, `filter`, `reduce`, `forEach`, `find`, `findIndex`, `some`, `every`, `flatMap`, `sort`, `toSorted`
427
+ - Promise methods: `then`, `catch`, `finally`
428
+ - Timing/events: `setTimeout`, `setInterval`, `addEventListener`, `on`, `once`
429
+
430
+ **Excluded (remain independent):**
431
+ - tRPC handlers: `query`, `mutation`, `subscription`
432
+ - Express middleware: `use`, `get`, `post`, `put`, `delete`, `patch`
433
+ - Custom application HOFs
434
+
435
+ Framework-specific handlers represent distinct behavioral units and should be scored independently.
436
+
437
+ ### Enclosing Function Discovery
438
+
439
+ Each inline callback is linked to its enclosing function via AST upward walk:
440
+
441
+ ```typescript
442
+ type ExtractedFunction = {
443
+ // ... other fields ...
444
+ enclosingFunctionStartLine: number | null // null if module-scope
445
+ isInlineCallback: boolean
446
+ }
447
+ ```
448
+
449
+ Callbacks at module scope (no enclosing function) are treated as top-level functions.
450
+
451
+ ### Impurity Bubbling
452
+
453
+ If an inline callback is impure, the enclosing function is **effectively impure**. The function's own classification remains unchanged (for drill-down), but metrics use the effective classification:
454
+
455
+ ```typescript
456
+ // In scorer.ts
457
+ function isEffectivelyImpure(entry: FunctionWithChildren): boolean {
458
+ if (entry.fn.classification === 'impure') return true
459
+ return entry.children.some(child => child.classification === 'impure')
460
+ }
461
+ ```
462
+
463
+ Markers from children are aggregated into the parent's refactoring candidate entry.
464
+
465
+ ### Quality Composition
466
+
467
+ Parent quality is blended with impure children's quality, weighted by line count:
468
+
469
+ ```
470
+ composedQuality = (parentQuality × parentOwnLines + Σ(childQuality × childLines)) / totalLines
471
+ ```
472
+
473
+ Where:
474
+ - `parentOwnLines = parent.bodyLineCount - Σ(child.bodyLineCount)` (clamped to min 1)
475
+ - `totalLines = parentOwnLines + Σ(child.bodyLineCount)`
476
+
477
+ **Example:**
478
+ - Parent: 50 total lines, 40 own lines, quality 60
479
+ - Child: 10 lines, quality 40
480
+ - Composed: `(60×40 + 40×10) / 50 = 56`
481
+
482
+ Pure children do not factor into the impurity quality calculation.
483
+
484
+ ### Function Counts
485
+
486
+ Only top-level functions are counted in health/purity metrics:
487
+ - `isInlineCallback === false`, OR
488
+ - `isInlineCallback === true` but `enclosingFunctionStartLine === null` (module-scope)
489
+
490
+ This prevents a 301-line function with 6 callbacks from being counted as 7 functions.
491
+
492
+ ### Line Counts
493
+
494
+ Line counts no longer double-count nested callbacks. Parent `bodyLineCount` already includes nested callback lines, so only top-level functions are summed.
495
+
496
+ ### JSON Output
497
+
498
+ All functions (including inline callbacks) remain in the JSON output with `isInlineCallback: true` for detailed analysis. The metrics reflect composed scores, but drill-down data is preserved.
499
+
377
500
  ## File Exclusions
378
501
 
379
502
  Automatically excluded from analysis: