fcis 0.1.0 → 0.2.0

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.
@@ -0,0 +1,514 @@
1
+ # Plan 007: Compositional Function Scoring
2
+
3
+ ## Background
4
+
5
+ The FCIS analyzer currently treats every function independently, including inline arrow functions passed as callbacks to methods like `.map()`, `.filter()`, `.forEach()`, and `.reduce()`. This creates several problems:
6
+
7
+ 1. **Inflated function counts:** A single 301-line function with 6 inline callbacks is counted as 7 functions
8
+ 2. **Diluted health scores:** Non-trivial callbacks (≥3 statements or with conditionals) that score "ok" can mask a severely problematic parent function. Note: trivial callbacks (< 3 statements, no conditionals) are already excluded by `shouldExcludeFunction()`, so the dilution effect applies specifically to substantive callbacks — still common in `.map()` calls that build objects, `.forEach()` with conditional logic, etc.
9
+ 3. **Double-counting impurity:** If a callback is impure (e.g., calls `log()`), that impurity should bubble up to the parent
10
+ 4. **Double-counting line counts:** Parent `bodyLineCount` already includes nested callback lines, but callbacks are also counted separately — summing all functions' line counts overcounts actual code
11
+ 5. **Misrepresentation:** High function counts suggest good decomposition when the reality is a monolith with inline callbacks
12
+ 6. **Pre-existing bug — `checkForConditionals` leaks across boundaries:** Unlike `extractCallSites()`, `checkForAwait()`, and `extractPropertyAccessChains()` which all use `traversal.skip()` at nested function boundaries, `checkForConditionals()` descends into nested functions. This means a ternary inside a callback inflates the parent's `hasConditionals` flag, affecting both trivial-function exclusion and complexity estimation in the quality scorer.
13
+
14
+ ### Example: `missionControlData.ts`
15
+
16
+ Current scoring:
17
+ - 7 functions counted (1 main + 6 callbacks)
18
+ - 71% health (5 ok, 1 review, 1 refactor)
19
+ - 57% purity
20
+
21
+ Reality:
22
+ - 1 main function (301 lines, severely tangled)
23
+ - 3 pure helper functions
24
+ - Health should be ~50% to reflect the severity of the main function
25
+
26
+ ## Problem Statement
27
+
28
+ The current function-by-function scoring model doesn't account for compositional relationships between functions. Inline callbacks should be absorbed into their parent function's score rather than counted independently.
29
+
30
+ ## Success Criteria
31
+
32
+ 1. `checkForConditionals` correctly scopes to the immediate function body (no leaking)
33
+ 2. Inline callbacks are marked with their parent function reference during extraction
34
+ 3. Scoring absorbs inline callbacks into their parent function
35
+ 4. Impurity in callbacks bubbles up to the parent function
36
+ 5. Child quality scores influence parent quality through weighted composition
37
+ 6. Function counts reflect "top-level" functions, not inline callbacks
38
+ 7. Line counts no longer double-count nested callback lines
39
+ 8. Detailed callback data remains available for drill-down analysis
40
+ 9. All existing tests continue to pass (with updated expectations where appropriate)
41
+
42
+ ## The Gap
43
+
44
+ | Aspect | Current State | Target State |
45
+ |--------|---------------|--------------|
46
+ | `checkForConditionals` | Leaks into nested functions | Scoped to immediate body |
47
+ | Inline callbacks | Scored independently | Absorbed into parent |
48
+ | Function counts | Includes all callbacks | Only top-level functions |
49
+ | Line counts | Double-counted for nested functions | No double-counting |
50
+ | Impurity inheritance | None | Bubbles up from children |
51
+ | Quality composition | None | Children influence parent |
52
+ | Data availability | Flat list | Hierarchical with parent refs |
53
+
54
+ ## Design
55
+
56
+ ### Type Changes
57
+
58
+ ```typescript
59
+ // In types.ts - ExtractedFunction
60
+ export type ExtractedFunction = {
61
+ // ... existing fields ...
62
+
63
+ // NEW: Reference to parent function (for inline callbacks)
64
+ // null when: (a) not an inline callback, or (b) callback is at module scope (no enclosing function)
65
+ enclosingFunctionStartLine: number | null
66
+
67
+ // NEW: Flag indicating this is an inline callback to a known HOF
68
+ isInlineCallback: boolean
69
+ }
70
+
71
+ // In types.ts - ClassifiedFunction (inherits from ExtractedFunction)
72
+ // No additional changes needed - inherits new fields
73
+ ```
74
+
75
+ ### Higher-Order Function Detection
76
+
77
+ Callbacks passed to these methods are considered inline callbacks:
78
+ - Array methods: `map`, `filter`, `reduce`, `forEach`, `find`, `findIndex`, `some`, `every`, `flatMap`, `sort`, `toSorted`
79
+ - Promise methods: `then`, `catch`, `finally`
80
+ - Other common HOFs: `setTimeout`, `setInterval`, `addEventListener`, `on`, `once`
81
+
82
+ **Explicit exclusion — NOT inline callbacks:**
83
+ - tRPC handlers (`query`, `mutation`, `subscription`) — these are conceptually separate endpoints
84
+ - Express/framework middleware (`use`, `get`, `post`, `put`, `delete`, `patch`)
85
+ - Custom application HOFs (e.g., `withAuth`, `createHandler`)
86
+
87
+ These are intentionally excluded because they represent distinct units of behavior, not helper logic absorbed into a parent. They should continue to be scored independently.
88
+
89
+ ### Enclosing Function Discovery
90
+
91
+ Finding the enclosing function for a callback requires an **AST upward walk** from the callback node:
92
+
93
+ ```typescript
94
+ function findEnclosingFunctionStartLine(node: Node): number | null {
95
+ let current = node.getParent()
96
+ while (current) {
97
+ const kind = current.getKind()
98
+ if (
99
+ kind === SyntaxKind.FunctionDeclaration ||
100
+ kind === SyntaxKind.MethodDeclaration ||
101
+ kind === SyntaxKind.ArrowFunction ||
102
+ kind === SyntaxKind.FunctionExpression ||
103
+ kind === SyntaxKind.GetAccessor ||
104
+ kind === SyntaxKind.SetAccessor
105
+ ) {
106
+ return current.getStartLineNumber()
107
+ }
108
+ current = current.getParent()
109
+ }
110
+ return null // module-scope callback, no enclosing function
111
+ }
112
+ ```
113
+
114
+ When `enclosingFunctionStartLine` is `null`, the callback is at module scope and should be treated independently (not absorbed into any parent).
115
+
116
+ ### Naming for Inline Callbacks
117
+
118
+ Currently, `parentContext` is overloaded — it serves as both the function's `name` (when no intrinsic name exists) and the calling context. For inline callbacks, this means a callback to `.forEach()` gets `name = "forEach"`.
119
+
120
+ After adding `isInlineCallback`, we preserve this behavior. The `name` field continues to use `parentContext` as before — the `isInlineCallback` flag provides the semantic disambiguation. Consumers can check `isInlineCallback` to know that `name = "forEach"` means "anonymous callback passed to forEach" rather than "a function named forEach."
121
+
122
+ ### Impurity Bubbling Rules
123
+
124
+ 1. If a callback is impure, the parent function is also impure
125
+ 2. Exception: Pure higher-order functions that don't execute their callback immediately (e.g., returning a function) — deferred to future iteration
126
+ 3. For v1, we use a simple rule: **any impure child makes the parent impure**
127
+
128
+ **Positive finding from research:** `extractCallSites()`, `checkForAwait()`, and `extractPropertyAccessChains()` already use `traversal.skip()` to stop at nested function boundaries. Markers are correctly attributed only to the function that directly contains the call. This means Phase 3 can simply look at classified children's markers and propagate them upward — no changes to the detection layer are needed.
129
+
130
+ ### Quality Composition Formula
131
+
132
+ **Revised:** The original multiplicative formula (`parentQuality × childFactor`) was too punitive — a parent with quality 60 and a single impure child with quality 40 would drop to 24, turning a "review" into "refactor" disproportionately.
133
+
134
+ Instead, we use a **line-count-weighted blend** of parent and children qualities:
135
+
136
+ ```
137
+ composedQuality = (parentBaseQuality × parentOwnLineCount + Σ(childQuality × childLineCount)) / totalLineCount
138
+
139
+ where:
140
+ parentOwnLineCount = parent.bodyLineCount - Σ(child.bodyLineCount)
141
+ totalLineCount = parentOwnLineCount + Σ(child.bodyLineCount)
142
+ ```
143
+
144
+ This means:
145
+ - A 50-line parent (quality 60) with a 10-line impure child (quality 40): `(60×40 + 40×10) / 50 = 56` → stays "review"
146
+ - A 50-line parent (quality 60) with a 40-line impure child (quality 20): `(60×10 + 20×40) / 50 = 28` → correctly drops to "refactor" because the child dominates
147
+ - All children pure: composed quality = parent quality (children don't contribute impure quality)
148
+
149
+ Note: `parentOwnLineCount` is clamped to `max(1, ...)` to avoid division by zero.
150
+
151
+ When **all children are pure**, only the parent's impure quality matters — pure children don't factor into the impurity quality calculation. The formula only blends when there are impure children.
152
+
153
+ ## Phases and Tasks
154
+
155
+ ### Phase 0: Fix `checkForConditionals` Scoping Bug ✅
156
+
157
+ - Add `traversal.skip()` for nested function nodes in `checkForConditionals()` ✅
158
+ - Add test verifying conditionals in nested callbacks don't leak to parent ✅
159
+ - Add test verifying conditionals in the parent itself are still detected ✅
160
+
161
+ This is a pre-existing bug that should be fixed before building compositional scoring on top of the extraction data. It's a ~5-line fix.
162
+
163
+ **Files to modify:**
164
+ - `fcis/src/extraction/extract-functions.ts`
165
+
166
+ **Files to add/update tests:**
167
+ - `fcis/tests/integration.test.ts` (or a new focused extraction test)
168
+
169
+ ### Phase 1: Update Extraction to Mark Inline Callbacks ✅
170
+
171
+ - Add `enclosingFunctionStartLine` and `isInlineCallback` fields to `ExtractedFunction` type ✅
172
+ - Update `extractFunctionData()` to accept and set these fields ✅
173
+ - Implement `findEnclosingFunctionStartLine()` AST upward-walk helper ✅
174
+ - Update `extractFunctions()` to detect inline callbacks in the `forEachDescendant` sweep:
175
+ - Check if the arrow/function-expression's parent is a `CallExpression` with a known HOF name (via `inferParentContext`) ✅
176
+ - If so, set `isInlineCallback = true` and walk up the AST to find `enclosingFunctionStartLine` ✅
177
+ - If the callback is at module scope (no enclosing function), `enclosingFunctionStartLine = null` — treat independently ✅
178
+ - Add tests for inline callback detection ✅
179
+
180
+ Known HOF names to detect:
181
+ ```typescript
182
+ const INLINE_CALLBACK_METHODS = new Set([
183
+ // Array methods
184
+ 'map', 'filter', 'reduce', 'forEach', 'find', 'findIndex',
185
+ 'some', 'every', 'flatMap', 'sort', 'toSorted',
186
+ // Promise methods
187
+ 'then', 'catch', 'finally',
188
+ // Timing
189
+ 'setTimeout', 'setInterval',
190
+ // Events
191
+ 'addEventListener', 'on', 'once',
192
+ ])
193
+ ```
194
+
195
+ ### Phase 2: Update Scoring to Absorb Callbacks ✅
196
+
197
+ - Create `groupFunctionsByParent()` helper to build parent-child relationships using `enclosingFunctionStartLine` ✅
198
+ - Create `computeCompositionalScore()` to calculate scores with child absorption ✅
199
+ - Update `scoreFile()` to use compositional scoring ✅
200
+ - Ensure function counts only include top-level functions (where `isInlineCallback === false` or `enclosingFunctionStartLine === null`) ✅
201
+ - Fix line count double-counting: only sum top-level function line counts (parent `bodyLineCount` already includes nested callback lines) ✅
202
+ - Add tests for compositional scoring ✅
203
+
204
+ ### Phase 3: Update Classification for Impurity Bubbling ✅
205
+
206
+ - Update classification logic to check children's impurity ✅ (done in scorer via `isEffectivelyImpure()`)
207
+ - If any child is impure, parent is impure ✅ (done in scorer via `isEffectivelyImpure()`)
208
+ - Aggregate markers from children into parent (detection layer already scopes markers correctly — no detection changes needed) ✅ (done in refactoring candidates)
209
+ - Add tests for impurity bubbling ✅
210
+
211
+ ### Phase 4: Update Quality Scoring for Composition ✅
212
+
213
+ - Implement line-count-weighted blend formula for composed quality ✅
214
+ - Calculate `parentOwnLineCount` as `parent.bodyLineCount - Σ(child.bodyLineCount)`, clamped to `max(1, ...)` ✅
215
+ - Only blend impure children into the quality calculation (pure children don't affect impurity quality) ✅
216
+ - Add tests for quality composition ✅
217
+
218
+ ### Phase 5: Update Reporting ✅
219
+
220
+ - Update console report to show composed function counts ✅ (automatic via scorer changes)
221
+ - Keep inline callbacks in JSON output for drill-down (with `isInlineCallback: true`) ✅ (functions array preserved)
222
+ - Update refactoring candidates to use composed scores ✅ (done in Phase 2/3)
223
+ - Add tests for reporting changes ✅ (verified via CLI output)
224
+
225
+ ### Phase 6: Documentation ✅
226
+
227
+ - Update TECHNICAL.md with compositional scoring explanation ✅
228
+ - Update README.md if user-facing behavior changes ✅
229
+
230
+ ## Tests
231
+
232
+ ### Phase 0: Scoping Bug Fix Tests
233
+
234
+ ```typescript
235
+ describe('checkForConditionals scoping', () => {
236
+ it('should NOT detect conditionals inside nested callbacks', () => {
237
+ const code = `
238
+ function outer() {
239
+ const x = items.map(item => item.active ? item.name : 'unknown')
240
+ return x
241
+ }
242
+ `
243
+ const functions = extractFunctions(createSourceFile(code))
244
+ const outer = functions.find(f => f.name === 'outer')
245
+ expect(outer?.hasConditionals).toBe(false)
246
+ })
247
+
248
+ it('should still detect conditionals in the function itself', () => {
249
+ const code = `
250
+ function outer() {
251
+ if (items.length === 0) return []
252
+ return items
253
+ }
254
+ `
255
+ const functions = extractFunctions(createSourceFile(code))
256
+ const outer = functions.find(f => f.name === 'outer')
257
+ expect(outer?.hasConditionals).toBe(true)
258
+ })
259
+ })
260
+ ```
261
+
262
+ ### Phase 1: Extraction Tests
263
+
264
+ ```typescript
265
+ describe('inline callback detection', () => {
266
+ it('should mark arrow functions passed to map as inline callbacks', () => {
267
+ const code = `
268
+ const result = items.map(item => item.value)
269
+ `
270
+ const functions = extractFunctions(createSourceFile(code))
271
+ const callback = functions.find(f => f.parentContext === 'map')
272
+ expect(callback?.isInlineCallback).toBe(true)
273
+ expect(callback?.enclosingFunctionStartLine).toBeNull() // module-scope
274
+ })
275
+
276
+ it('should link nested callbacks to their enclosing function', () => {
277
+ const code = `
278
+ function outer() {
279
+ items.forEach(item => {
280
+ console.log(item)
281
+ })
282
+ }
283
+ `
284
+ const functions = extractFunctions(createSourceFile(code))
285
+ const outer = functions.find(f => f.name === 'outer')
286
+ const callback = functions.find(f => f.parentContext === 'forEach')
287
+ expect(callback?.isInlineCallback).toBe(true)
288
+ expect(callback?.enclosingFunctionStartLine).toBe(outer?.startLine)
289
+ })
290
+
291
+ it('should NOT mark callbacks to unknown methods as inline', () => {
292
+ const code = `
293
+ const result = customMethod(item => item.value)
294
+ `
295
+ const functions = extractFunctions(createSourceFile(code))
296
+ const callback = functions.find(f => f.parentContext === 'customMethod')
297
+ expect(callback?.isInlineCallback).toBe(false)
298
+ })
299
+
300
+ it('should NOT mark tRPC handlers as inline callbacks', () => {
301
+ const code = `
302
+ export const router = createRouter({
303
+ getUser: query(async ({ ctx }) => {
304
+ return await ctx.db.user.findFirst()
305
+ }),
306
+ })
307
+ `
308
+ const functions = extractFunctions(createSourceFile(code))
309
+ const handler = functions.find(f => f.parentContext === 'query')
310
+ expect(handler?.isInlineCallback).toBe(false)
311
+ })
312
+
313
+ it('should handle deeply nested callbacks with correct enclosing function', () => {
314
+ const code = `
315
+ function process() {
316
+ items.forEach(item => {
317
+ item.tags.map(tag => tag.name)
318
+ })
319
+ }
320
+ `
321
+ const functions = extractFunctions(createSourceFile(code))
322
+ const process = functions.find(f => f.name === 'process')
323
+ const forEachCb = functions.find(f => f.parentContext === 'forEach')
324
+ const mapCb = functions.find(f => f.parentContext === 'map')
325
+ // Both callbacks link to the enclosing named function
326
+ expect(forEachCb?.enclosingFunctionStartLine).toBe(process?.startLine)
327
+ // The map callback's enclosing function is the forEach callback
328
+ expect(mapCb?.enclosingFunctionStartLine).toBe(forEachCb?.startLine)
329
+ })
330
+ })
331
+ ```
332
+
333
+ ### Phase 2: Scoring Tests
334
+
335
+ ```typescript
336
+ describe('compositional scoring', () => {
337
+ it('should count only top-level functions', () => {
338
+ // File with 1 function containing 3 inline callbacks
339
+ const score = scoreFile(path, classifiedFunctions)
340
+ expect(score.pureCount + score.impureCount).toBe(1) // not 4
341
+ })
342
+
343
+ it('should not double-count line counts', () => {
344
+ // Parent: 50 lines (includes callback), Callback: 10 lines
345
+ // pureLineCount + impureLineCount should equal 50, not 60
346
+ })
347
+
348
+ it('should treat module-scope inline callbacks independently', () => {
349
+ // Top-level: items.map(item => { ... complex ... })
350
+ // No enclosing function — should count as its own function
351
+ })
352
+ })
353
+ ```
354
+
355
+ ### Phase 3: Impurity Bubbling Tests
356
+
357
+ ```typescript
358
+ describe('impurity bubbling', () => {
359
+ it('should make parent impure if callback is impure', () => {
360
+ // Parent function with pure code
361
+ // Callback that calls console.log
362
+ // Parent should be classified as impure
363
+ })
364
+
365
+ it('should aggregate markers from children', () => {
366
+ // Parent with database-call
367
+ // Callback with logging
368
+ // Parent should have both markers
369
+ })
370
+
371
+ it('should not affect parent if callback is pure', () => {
372
+ // Parent with database-call
373
+ // Callback that is pure (e.g., items.map(x => x.name))
374
+ // Parent markers unchanged
375
+ })
376
+ })
377
+ ```
378
+
379
+ ### Phase 4: Quality Composition Tests
380
+
381
+ ```typescript
382
+ describe('quality composition', () => {
383
+ it('should blend parent and impure child quality by line count', () => {
384
+ // Parent: 50 total lines, 40 own lines, quality 60
385
+ // Child: 10 lines, quality 40
386
+ // Composed: (60*40 + 40*10) / 50 = 56
387
+ })
388
+
389
+ it('should not affect quality if all children are pure', () => {
390
+ // Parent base quality: 60
391
+ // All children pure
392
+ // Composed: 60 (unchanged)
393
+ })
394
+
395
+ it('should weight heavily toward child when child dominates line count', () => {
396
+ // Parent: 50 total lines, 10 own lines, quality 60
397
+ // Child: 40 lines, quality 20
398
+ // Composed: (60*10 + 20*40) / 50 = 28
399
+ })
400
+ })
401
+ ```
402
+
403
+ ## Transitive Effect Analysis
404
+
405
+ ### Phase 0 (Bug Fix)
406
+ - `extract-functions.ts` → `checkForConditionals()` adds `traversal.skip()` for nested functions
407
+ - May change `hasConditionals` values for functions that contain callbacks with conditionals
408
+ - Could affect `shouldExcludeFunction()` decisions (some parent functions may now be correctly excluded as trivial)
409
+ - Could affect quality scorer's complexity estimation (more accurate now)
410
+
411
+ ### Phase 1 (Extraction)
412
+ - `types.ts` → adds new fields to `ExtractedFunction`
413
+ - `extract-functions.ts` → uses new fields, adds AST upward-walk helper
414
+ - All consumers of `ExtractedFunction` will see new fields (backward compatible, fields have defaults)
415
+
416
+ ### Phase 2 (Scoring)
417
+ - `scorer.ts` → new grouping and composition logic
418
+ - `scoreFile()` return values change (function counts may decrease, line counts decrease)
419
+ - Tests that assert specific function counts will need updates
420
+
421
+ ### Phase 3 (Classification)
422
+ - `classifier.ts` → needs access to child functions for impurity bubbling
423
+ - `analyzer.ts` → may need to pass additional context to classification
424
+ - Detection layer (`detect-markers.ts`) does NOT need changes — markers are already correctly scoped
425
+
426
+ ### Phase 4 (Quality)
427
+ - `quality-scorer.ts` → new composition logic using weighted blend
428
+ - Quality scores may change for functions with impure children
429
+
430
+ ### Phase 5 (Reporting)
431
+ - `report-console.ts` → function counts reflect composed scores
432
+ - `report-json.ts` → includes `isInlineCallback` field for filtering
433
+ - Downstream JSON consumers may see different counts
434
+
435
+ ## Resources for Implementation
436
+
437
+ **Files to modify:**
438
+ - `fcis/src/extraction/extract-functions.ts` — Phase 0, Phase 1
439
+ - `fcis/src/types.ts` — Phase 1
440
+ - `fcis/src/scoring/scorer.ts` — Phase 2
441
+ - `fcis/src/classification/classifier.ts` — Phase 3
442
+ - `fcis/src/classification/quality-scorer.ts` — Phase 4
443
+ - `fcis/src/reporting/report-console.ts` — Phase 5
444
+ - `fcis/src/reporting/report-json.ts` — Phase 5
445
+ - `fcis/src/analyzer.ts` — Phase 2, 3 (orchestration changes)
446
+
447
+ **Files to add/update tests:**
448
+ - `fcis/tests/integration.test.ts` — Phase 0, Phase 1
449
+ - `fcis/tests/scorer.test.ts` — Phase 2
450
+ - `fcis/tests/classifier.test.ts` — Phase 3
451
+ - `fcis/tests/quality-scorer.test.ts` — Phase 4
452
+
453
+ **Reference files:**
454
+ - `apps/web/src/server/services/spaces/missionControlData.ts` — real-world test case
455
+ - `apps/web/src/server/utils/history/filterSystemMessages.ts` — pure functions baseline
456
+
457
+ ## Documentation Updates
458
+
459
+ ### TECHNICAL.md
460
+
461
+ Add section:
462
+
463
+ ```markdown
464
+ ## Compositional Function Scoring
465
+
466
+ FCIS uses compositional scoring to handle inline callbacks (arrow functions passed to `map`, `filter`, `forEach`, etc.):
467
+
468
+ ### Inline Callback Detection
469
+
470
+ Functions are marked as inline callbacks when passed to known higher-order functions:
471
+ - Array methods: `map`, `filter`, `reduce`, `forEach`, `find`, `some`, `every`, etc.
472
+ - Promise methods: `then`, `catch`, `finally`
473
+ - Timing/events: `setTimeout`, `addEventListener`, etc.
474
+
475
+ Framework-specific handlers (tRPC `query`/`mutation`, Express middleware, etc.) are intentionally NOT treated as inline callbacks — they represent distinct behavioral units.
476
+
477
+ ### Enclosing Function Discovery
478
+
479
+ Each inline callback is linked to its enclosing function via an AST upward walk. Callbacks at module scope (no enclosing function) are treated independently.
480
+
481
+ ### Impurity Bubbling
482
+
483
+ If an inline callback is impure, the enclosing function is also considered impure. Markers from children are aggregated into the parent. The detection layer already scopes markers to the immediate function, so bubbling is a classification-time concern.
484
+
485
+ ### Quality Composition
486
+
487
+ Parent quality is blended with impure children's quality, weighted by line count:
488
+
489
+ ```
490
+ composedQuality = (parentQuality × parentOwnLines + Σ(childQuality × childLines)) / totalLines
491
+ ```
492
+
493
+ Where `parentOwnLines = parent.bodyLineCount - Σ(child.bodyLineCount)`. Pure children do not factor into the impurity quality calculation.
494
+
495
+ ### Function Counts
496
+
497
+ Only top-level functions are counted in health/purity metrics. Inline callbacks are absorbed into their parent's score but remain available in JSON output with `isInlineCallback: true` for detailed analysis. Line counts no longer double-count nested callback lines.
498
+ ```
499
+
500
+ ## Estimated Line Impact
501
+
502
+ - Phase 0 (Bug Fix): ~15 lines changed/added (including tests)
503
+ - Phase 1 (Extraction): ~100 lines changed/added (includes AST upward-walk helper)
504
+ - Phase 2 (Scoring): ~100 lines changed/added
505
+ - Phase 3 (Classification): ~50 lines changed/added
506
+ - Phase 4 (Quality): ~40 lines changed/added
507
+ - Phase 5 (Reporting): ~30 lines changed/added
508
+ - Phase 6 (Documentation): ~40 lines
509
+ - Tests: ~250 lines added
510
+
511
+ **Total: ~625 lines** — within the 2000 line PR limit, but consider splitting into 2-3 PRs:
512
+ - PR 1: Phase 0 (bug fix — tiny, can merge independently)
513
+ - PR 2: Phases 1-2 (extraction + scoring foundation)
514
+ - PR 3: Phases 3-6 (classification, quality, reporting, docs)
package/README.md CHANGED
@@ -33,7 +33,7 @@ npx fcis tsconfig.json --files "src/services/**/*.ts"
33
33
 
34
34
  ### Purity (0-100%)
35
35
 
36
- Percentage of functions that are **pure** — no I/O markers detected. Pure functions:
36
+ Percentage of **top-level** functions that are pure — no I/O markers detected. Pure functions:
37
37
  - Take arguments and return values
38
38
  - Have no side effects
39
39
  - Can be tested without mocking
@@ -47,7 +47,18 @@ For impure functions, measures how **well-structured** the I/O code is:
47
47
 
48
48
  ### Health (0-100%)
49
49
 
50
- Percentage of functions with status **OK** (either pure, or impure with quality ≥70).
50
+ Percentage of top-level functions with status **OK** (either pure, or impure with quality ≥70).
51
+
52
+ ### Compositional Scoring
53
+
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
58
+
59
+ If a callback is impure, the parent is considered effectively impure. Quality scores are blended by line count.
60
+
61
+ See [TECHNICAL.md](./TECHNICAL.md#compositional-function-scoring) for details.
51
62
 
52
63
  ## Function Classification
53
64
 
@@ -78,7 +89,6 @@ The analyzer detects these I/O patterns:
78
89
  | `network-fetch` | `fetch(url)` |
79
90
  | `network-http` | `axios.get()`, imports from `axios` |
80
91
  | `fs-call` | `fs.readFile()`, `fs.writeFile()` |
81
- | `fs-import` | Import from `fs`, `node:fs` |
82
92
  | `env-access` | `process.env.NODE_ENV` |
83
93
  | `console-log` | `console.log()`, `console.error()` |
84
94
  | `logging` | `logger.info()`, imports from logger |
@@ -104,6 +114,7 @@ Options:
104
114
  --min-quality <N> Exit code 1 if impurity quality < N (0-100)
105
115
  --files, -f <glob> Analyze only matching files
106
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)
107
118
  --quiet, -q Suppress output, use exit code only
108
119
  --verbose, -v Show per-file details
109
120
  --help Show help
@@ -146,6 +157,30 @@ Top Refactoring Candidates:
146
157
  Markers: database-call, await-expression
147
158
  ```
148
159
 
160
+ ### Directory Rollup
161
+
162
+ To see aggregate metrics for top-level directories instead of leaf directories:
163
+
164
+ ```bash
165
+ fcis tsconfig.json --dir-depth 1
166
+ ```
167
+
168
+ Example output:
169
+
170
+ ```
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
180
+ ```
181
+
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`.
183
+
149
184
  ## CI Integration
150
185
 
151
186
  ### GitHub Actions