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/.plans/003-code-cleanup-consolidation.md +242 -0
- package/.plans/004-directory-depth-rollup.md +408 -0
- package/.plans/005-code-refinements.md +210 -0
- package/.plans/006-minor-refinements.md +149 -0
- package/.plans/007-compositional-function-scoring.md +514 -0
- package/README.md +214 -132
- package/TECHNICAL.md +125 -2
- package/dist/cli.js +599 -328
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +15 -2
- package/dist/index.js +409 -240
- package/dist/index.js.map +1 -1
- package/docs/images/fc-is-submarine.webp +0 -0
- package/package.json +3 -1
- package/src/cli-utils.ts +201 -0
- package/src/cli.ts +105 -118
- package/src/detection/markers.ts +0 -222
- package/src/extraction/extract-functions.ts +106 -2
- package/src/extraction/extractor.ts +35 -74
- package/src/reporting/report-console.ts +188 -102
- package/src/reporting/report-json.ts +26 -3
- package/src/scoring/scorer.ts +425 -160
- package/src/types.ts +9 -2
- package/tests/classifier.test.ts +0 -1
- package/tests/cli.test.ts +356 -0
- package/tests/detect-markers.test.ts +1 -3
- package/tests/extractor.test.ts +95 -1
- package/tests/integration.test.ts +344 -0
- package/tests/report-console.test.ts +92 -0
- package/tests/scorer.test.ts +886 -0
package/README.md
CHANGED
|
@@ -2,108 +2,238 @@
|
|
|
2
2
|
|
|
3
3
|
**Functional Core, Imperative Shell** analyzer for TypeScript codebases.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
132
|
+
fcis tsconfig.json
|
|
21
133
|
|
|
22
|
-
#
|
|
23
|
-
|
|
134
|
+
# Set a health threshold for CI
|
|
135
|
+
fcis tsconfig.json --min-health 70
|
|
24
136
|
|
|
25
|
-
# Output JSON
|
|
26
|
-
|
|
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
|
-
|
|
141
|
+
fcis tsconfig.json --files "src/services/**/*.ts"
|
|
30
142
|
```
|
|
31
143
|
|
|
32
|
-
##
|
|
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
|
-
|
|
146
|
+
```
|
|
147
|
+
FCIS Analysis
|
|
148
|
+
═══════════════════════════════════════════════════════════
|
|
51
149
|
|
|
52
|
-
|
|
150
|
+
Project Health: 77% ████████████████████░░░░░
|
|
151
|
+
Purity: 45% (234 pure / 520 total)
|
|
152
|
+
Impurity Quality: 68% average
|
|
53
153
|
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
+
1. 25/100 processOrder (150 lines)
|
|
163
|
+
/src/services/orders.ts:45
|
|
164
|
+
Markers: database-call, network-fetch, console-log
|
|
62
165
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
##
|
|
171
|
+
## What Makes a Function Impure?
|
|
71
172
|
|
|
72
|
-
|
|
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()
|
|
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()
|
|
184
|
+
| `logging` | `logger.info()` |
|
|
85
185
|
| `telemetry` | `trackEvent()`, `analytics.track()` |
|
|
86
186
|
| `queue-enqueue` | `queue.enqueue()`, `queue.add()` |
|
|
87
|
-
| `event-emit` | `emitter.emit()
|
|
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
|
-
|
|
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 |
|
|
119
|
-
| 2 | Configuration error
|
|
120
|
-
| 3 | Analysis error
|
|
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:
|
|
258
|
+
run: fcis tsconfig.json --min-health 70 --format summary
|
|
156
259
|
```
|
|
157
260
|
|
|
158
|
-
### Pre-commit Hook
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
//
|
|
212
|
-
|
|
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
|
-
|
|
313
|
+
member: { userId: invite.userId, orgId: org.id }
|
|
222
314
|
}
|
|
223
315
|
}
|
|
224
316
|
|
|
225
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
- I/O
|
|
258
|
-
-
|
|
259
|
-
-
|
|
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
|
-
##
|
|
346
|
+
## Further Reading
|
|
264
347
|
|
|
265
|
-
-
|
|
266
|
-
-
|
|
267
|
-
-
|
|
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:
|