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.
- 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 +38 -3
- package/TECHNICAL.md +125 -2
- package/dist/cli.js +595 -327
- 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/package.json +2 -1
- package/src/cli-utils.ts +201 -0
- package/src/cli.ts +99 -117
- 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
|
@@ -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
|
|
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
|