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.
@@ -0,0 +1,242 @@
1
+ # Plan 003: FCIS Code Cleanup and Consolidation
2
+
3
+ ## Background
4
+
5
+ After implementing Plan 002 (analyzer improvements), a code review identified several areas of technical debt:
6
+
7
+ 1. **Duplicated code**: tsconfig JSONC validation logic exists in both `cli.ts` and `extractor.ts`
8
+ 2. **Dead code**: `markers.ts` exports many utility functions that are never used
9
+ 3. **Orphaned types**: The `fs-import` marker type is defined but never detected after Phase 1 changes
10
+ 4. **Large function**: The CLI `main()` function handles too many responsibilities (~150 lines)
11
+ 5. **Missing validation**: The `--files` glob pattern is not validated
12
+
13
+ The codebase follows FCIS patterns well architecturally, but accumulated cruft during iterative development.
14
+
15
+ ## Problem Statement
16
+
17
+ Technical debt in the FCIS analyzer creates:
18
+ - **Maintenance burden**: Duplicated validation logic must be kept in sync manually
19
+ - **Confusion**: Dead code and orphaned types mislead future developers
20
+ - **Test gaps**: The monolithic CLI function is difficult to unit test
21
+ - **Poor UX**: Invalid glob patterns cause confusing runtime behavior
22
+
23
+ ## Success Criteria
24
+
25
+ 1. Single source of truth for tsconfig JSONC validation
26
+ 2. Zero dead/unused exports in `markers.ts`
27
+ 3. `MarkerType` union only contains actively detected markers
28
+ 4. CLI logic decomposed into testable pure functions
29
+ 5. Invalid `--files` glob patterns rejected with clear error message
30
+ 6. All existing tests continue to pass
31
+ 7. No new functionality—purely cleanup and consolidation
32
+
33
+ ## The Gap
34
+
35
+ | Issue | Current State | Target State |
36
+ |-------|---------------|--------------|
37
+ | tsconfig validation | Duplicated in cli.ts and extractor.ts | Single exported function in extractor.ts |
38
+ | markers.ts exports | 15+ unused functions/constants | Only used exports remain |
39
+ | `fs-import` marker | In MarkerType but never detected | Removed from types and catalog |
40
+ | CLI main() | ~150 line god function | Decomposed into 4-5 focused functions |
41
+ | --files validation | Accepts any string | Validates glob syntax |
42
+
43
+ ## Phases and Tasks
44
+
45
+ ### Phase 1: Extract Shared tsconfig Validation ✅
46
+
47
+ - Create `validateTsconfigContent()` function in `extractor.ts` ✅
48
+ - Export the function for use by CLI ✅
49
+ - Update `cli.ts` to import and use the shared function ✅
50
+ - Update `loadProject()` to use the shared function internally ✅
51
+ - Add unit test for `validateTsconfigContent()` ✅
52
+
53
+ Function signature:
54
+ ```typescript
55
+ export function validateTsconfigContent(
56
+ content: string
57
+ ): { valid: true } | { valid: false; error: string }
58
+ ```
59
+
60
+ ### Phase 2: Remove Dead Code from markers.ts ✅
61
+
62
+ - Audit all exports in `markers.ts` against actual usage ✅
63
+ - Remove unused functions: `isDatabaseCall`, `isFetchCall`, `isHttpClientCall`, `isFsCall`, `isFsModule`, `isEnvAccess`, `isConsoleCall`, `isLoggingCall`, `isTelemetryCall`, `isQueueEnqueueCall`, `isEventEmitCall` ✅
64
+ - Remove unused functions: `getMarkersByCategory`, `getMarkersBySeverity` ✅
65
+ - Remove unused constants: `DATABASE_PREFIXES`, `FS_MODULES`, `HTTP_MODULES`, `CONSOLE_METHODS`, `TELEMETRY_PATTERNS` ✅
66
+ - Verify no imports break after removal ✅
67
+ - Update `index.ts` exports if any removed items were re-exported ✅
68
+
69
+ ### Phase 3: Remove Orphaned fs-import Marker Type ✅
70
+
71
+ - Remove `'fs-import'` from `MarkerType` union in `types.ts` ✅
72
+ - Remove `'fs-import'` entry from `MARKER_CATALOG` in `markers.ts` ✅
73
+ - Update TECHNICAL.md marker detection table to remove fs-import row 🔴 (Phase 6)
74
+ - Verify no code references the removed marker type ✅
75
+
76
+ ### Phase 4: Decompose CLI main() Function ✅
77
+
78
+ Extract these pure functions from `main()`:
79
+
80
+ - `validateCliFlags()` - validate threshold and format flags ✅
81
+ - `buildAnalyzerConfig()` - construct AnalyzerConfig from flags ✅
82
+ - `handleAnalysisOutput()` - format and output results based on flags ✅
83
+ - `buildThresholdConfig()` - extract threshold config from flags ✅
84
+ - Refactor `main()` to orchestrate the extracted functions ✅
85
+ - Add unit tests for extracted pure functions ✅
86
+
87
+ Note: Pure functions extracted to `cli-utils.ts` to enable unit testing without triggering CLI entry point.
88
+
89
+ Function signatures:
90
+ ```typescript
91
+ function validateCliFlags(flags: CliFlags):
92
+ { valid: true } | { valid: false; error: string }
93
+
94
+ function buildAnalyzerConfig(
95
+ tsconfigPath: string,
96
+ flags: CliFlags
97
+ ): AnalyzerConfig
98
+
99
+ function buildThresholdConfig(flags: CliFlags): {
100
+ minHealth?: number
101
+ minPurity?: number
102
+ minQuality?: number
103
+ }
104
+ ```
105
+
106
+ ### Phase 5: Add --files Glob Validation ✅
107
+
108
+ - Add `validateGlobPattern()` function ✅
109
+ - Integrate validation into CLI before analysis ✅
110
+ - Add test for invalid glob patterns ✅
111
+ - Display helpful error message for invalid patterns ✅
112
+
113
+ Validation should catch:
114
+ - Unbalanced brackets `[` or `{`
115
+ - Empty pattern
116
+ - Pattern starting with `/` (absolute paths not supported)
117
+
118
+ ### Phase 6: Documentation Updates ✅
119
+
120
+ - Update TECHNICAL.md to remove fs-import from marker table ✅
121
+ - Update README.md to remove fs-import from marker table ✅
122
+ - Review and update any outdated documentation ✅
123
+
124
+ ## Tests
125
+
126
+ ### Unit Tests
127
+
128
+ **`extractor.test.ts`** — Add test for extracted validation:
129
+ ```typescript
130
+ describe('validateTsconfigContent', () => {
131
+ it('should return valid for correct JSONC', () => {
132
+ const result = validateTsconfigContent('{ "compilerOptions": {} }')
133
+ expect(result).toEqual({ valid: true })
134
+ })
135
+
136
+ it('should return error for invalid JSON', () => {
137
+ const result = validateTsconfigContent('{ invalid }')
138
+ expect(result.valid).toBe(false)
139
+ expect(result.error).toContain('Parse error')
140
+ })
141
+
142
+ it('should accept trailing commas', () => {
143
+ const result = validateTsconfigContent('{ "a": [1,], }')
144
+ expect(result).toEqual({ valid: true })
145
+ })
146
+ })
147
+ ```
148
+
149
+ **`cli.test.ts`** (new file) — Test extracted CLI functions:
150
+ ```typescript
151
+ describe('validateCliFlags', () => {
152
+ it('should reject minHealth > 100', () => {
153
+ const result = validateCliFlags({ minHealth: 101 })
154
+ expect(result.valid).toBe(false)
155
+ })
156
+
157
+ it('should reject invalid format', () => {
158
+ const result = validateCliFlags({ format: 'xml' })
159
+ expect(result.valid).toBe(false)
160
+ })
161
+ })
162
+
163
+ describe('validateGlobPattern', () => {
164
+ it('should reject unbalanced brackets', () => {
165
+ const result = validateGlobPattern('src/[incomplete')
166
+ expect(result.valid).toBe(false)
167
+ })
168
+
169
+ it('should accept valid glob', () => {
170
+ const result = validateGlobPattern('src/**/*.ts')
171
+ expect(result).toEqual({ valid: true })
172
+ })
173
+ })
174
+ ```
175
+
176
+ ## Transitive Effect Analysis
177
+
178
+ ### Phase 1 (tsconfig validation)
179
+ - `cli.ts` → imports from `extractor.ts` ✓ (new import, no breaks)
180
+ - `extractor.ts` → used by `analyzer.ts` ✓ (internal change, no breaks)
181
+ - Tests: `extractor.test.ts` ✓ (add new tests)
182
+
183
+ ### Phase 2 (dead code removal)
184
+ - `markers.ts` → imported by `detect-markers.ts` (only uses `MARKER_CATALOG`)
185
+ - `markers.ts` → re-exported by `index.ts` (must update exports)
186
+ - External consumers using removed exports would break (considered acceptable as they're unused internally)
187
+
188
+ ### Phase 3 (fs-import removal)
189
+ - `types.ts` → imported everywhere (MarkerType narrowing)
190
+ - `markers.ts` → MARKER_CATALOG type depends on MarkerType
191
+ - `detect-markers.ts` → no longer detects fs-import (already true)
192
+ - Tests checking for `fs-import` markers → update or remove
193
+
194
+ ### Phase 4 (CLI decomposition)
195
+ - `cli.ts` → internal refactor only
196
+ - No external consumers of CLI internals
197
+ - New test file depends on extracted functions
198
+
199
+ ### Phase 5 (glob validation)
200
+ - `cli.ts` → new validation step before `analyze()`
201
+ - `extractor.ts` → no changes (glob passed through)
202
+
203
+ ## Resources for Implementation
204
+
205
+ **Files to modify:**
206
+ - `fcis/src/extraction/extractor.ts` — Phase 1
207
+ - `fcis/src/cli.ts` — Phases 1, 4, 5
208
+ - `fcis/src/detection/markers.ts` — Phases 2, 3
209
+ - `fcis/src/types.ts` — Phase 3
210
+ - `fcis/src/index.ts` — Phase 2 (update exports)
211
+ - `fcis/TECHNICAL.md` — Phase 6
212
+
213
+ **Files to create:**
214
+ - `fcis/tests/cli.test.ts` — Phases 4, 5
215
+
216
+ **Files to update tests:**
217
+ - `fcis/tests/extractor.test.ts` — Phase 1
218
+ - `fcis/tests/detect-markers.test.ts` — Phase 3 (if any fs-import tests exist)
219
+
220
+ **Reference:**
221
+ - Current `cli.ts` main() function structure
222
+ - Current `markers.ts` export list
223
+ - `jsonc-parser` API for validation
224
+
225
+ ## Documentation Updates
226
+
227
+ ### TECHNICAL.md
228
+
229
+ Remove `fs-import` row from marker detection table:
230
+
231
+ ```markdown
232
+ | Marker Type | Detection Pattern |
233
+ |-------------|-------------------|
234
+ | `await-expression` | Any `await` keyword in function body |
235
+ | `database-call` | `db.*.findFirst`, `prisma.*.create`, etc. |
236
+ | `network-fetch` | `fetch(...)` call |
237
+ | `network-http` | `axios.*` call |
238
+ | `fs-call` | `fs.readFile`, `fs.writeFile`, `readFile`, `writeFile`, etc. |
239
+ ...
240
+ ```
241
+
242
+ (Remove the `fs-import` row that previously existed)
@@ -0,0 +1,408 @@
1
+ # Plan 004: Directory Depth Rollup
2
+
3
+ ## Background
4
+
5
+ The FCIS analyzer currently shows directory-level metrics at the leaf level (deepest directories containing files). When analyzing large codebases like `apps/web/src/server/**/*.ts`, this produces a long list of deeply nested directories that's hard to get a high-level overview from.
6
+
7
+ Users want to see aggregate metrics for top-level directories (e.g., `src/agents`, `src/services`, `src/api`) to quickly identify which areas of the codebase need the most attention.
8
+
9
+ ## Problem Statement
10
+
11
+ The current "Directories Needing Attention" output:
12
+ 1. Only shows directories with health < 70% (hides healthy directories)
13
+ 2. Limited to 5 directories
14
+ 3. Shows leaf directories, not aggregate rollups
15
+ 4. Doesn't give a holistic view of codebase health by area
16
+
17
+ Users need a way to see ALL top-level directories with aggregate metrics to understand the health distribution across the codebase at a glance.
18
+
19
+ ## Success Criteria
20
+
21
+ 1. New `--dir-depth N` CLI flag controls directory aggregation depth
22
+ 2. When specified, directories are grouped by their Nth path segment relative to project root
23
+ 3. ALL directories at that depth are shown (not just failing ones)
24
+ 4. Metrics (health, purity, quality, function counts) are correctly aggregated
25
+ 5. Output is sorted alphabetically by path for easy scanning
26
+ 6. Default behavior (no flag) remains unchanged
27
+
28
+ ## The Gap
29
+
30
+ | Aspect | Current State | Target State |
31
+ |--------|---------------|--------------|
32
+ | Directory display | Leaf directories only | Configurable depth rollup |
33
+ | Filtering | Only health < 70% | Show all at specified depth |
34
+ | Limit | Max 5 directories | Show all at specified depth |
35
+ | Aggregation | Per-directory only | Hierarchical rollup |
36
+
37
+ ## Example Output
38
+
39
+ With `--dir-depth 1`:
40
+
41
+ ```
42
+ Directory Breakdown (depth=1):
43
+ ────────────────────────────────────────────────────────────────────────────────
44
+ Directory Health Purity Quality Functions
45
+ ────────────────────────────────────────────────────────────────────────────────
46
+ src/agents 45% 30% 52% 12/40
47
+ src/api 62% 55% 48% 28/51
48
+ src/graph-flow 38% 25% 41% 45/120
49
+ src/providers 85% 80% 72% 8/10
50
+ src/services 52% 40% 55% 89/171
51
+ ```
52
+
53
+ ## Design Decisions
54
+
55
+ ### DD1: Extract shared aggregation logic
56
+
57
+ Both `scoreDirectory()` and the new `rollupDirectoriesByDepth()` need to compute weighted averages for the same metrics. To avoid duplication, we'll extract a generic `aggregateMetrics()` helper that both can use.
58
+
59
+ ```typescript
60
+ type Aggregatable = {
61
+ pureCount: number
62
+ impureCount: number
63
+ impurityQuality: number | null
64
+ statusBreakdown: StatusBreakdown
65
+ pureLineCount: number
66
+ impureLineCount: number
67
+ excludedCount: number
68
+ }
69
+
70
+ function aggregateMetrics<T extends Aggregatable>(items: T[]): Aggregatable
71
+ ```
72
+
73
+ ### DD2: Path handling strategy
74
+
75
+ The codebase uses absolute paths internally (from `sourceFile.getFilePath()`). The rollup function needs to:
76
+ 1. Convert absolute paths to relative paths using `path.relative(projectRoot, absolutePath)`
77
+ 2. Parse depth using forward slashes (normalize Windows paths)
78
+ 3. Handle edge cases where directories are shallower than requested depth (keep as-is)
79
+
80
+ We'll add a pure helper function:
81
+
82
+ ```typescript
83
+ function getPathAtDepth(relativePath: string, depth: number): string {
84
+ const segments = relativePath.split('/').filter(Boolean)
85
+ if (depth >= segments.length) {
86
+ return relativePath // Can't truncate shallower than actual depth
87
+ }
88
+ return segments.slice(0, depth + 1).join('/')
89
+ }
90
+ ```
91
+
92
+ ### DD3: DirectoryScore reuse for rolled-up directories
93
+
94
+ For rolled-up directories, we'll reuse `DirectoryScore` with `fileScores: []`. This is pragmatic for MVP — semantically a rolled-up directory doesn't have direct files, but an empty array is unambiguous. We can revisit with a discriminated union type if needed later.
95
+
96
+ ### DD4: Rollup is a presentation concern
97
+
98
+ The rollup happens in the **reporting layer**, not the analyzer. The `ProjectScore.directoryScores` always contains the full leaf-level data. The rollup is computed on-demand when displaying or generating JSON output with `--dir-depth`.
99
+
100
+ This keeps the analyzer pure and allows JSON consumers to always get full data alongside rolled-up data.
101
+
102
+ ### DD5: JSON output includes both full and rolled-up data
103
+
104
+ When `--dir-depth` is specified, JSON output will include:
105
+ - `directoryScores` — full leaf-level data (always present)
106
+ - `rolledUpDirectories` — aggregated data at specified depth (only when `--dir-depth` used)
107
+
108
+ This is non-breaking for existing consumers.
109
+
110
+ ### DD6: Coupling depth with "show all"
111
+
112
+ When `--dir-depth N` is specified, ALL directories at that depth are shown (no health < 70% filter). This coupling is intentional — the purpose of depth rollup is to get a holistic view, and filtering would defeat that purpose.
113
+
114
+ ## Phases and Tasks
115
+
116
+ ### Phase 0: Refactor aggregation logic ✅
117
+
118
+ Extract shared aggregation logic from `scoreDirectory()` to enable reuse.
119
+
120
+ - Create `aggregateMetrics()` helper function in `scorer.ts` ✅
121
+ - Refactor `scoreDirectory()` to use the helper ✅
122
+ - Refactor `scoreProject()` to use the helper ✅
123
+ - Ensure all existing tests still pass ✅
124
+
125
+ This is a pure refactoring phase — no behavior changes.
126
+
127
+ ### Phase 1: Add Directory Rollup Logic ✅
128
+
129
+ - Add `getPathAtDepth()` path utility function to `scorer.ts` ✅
130
+ - Add `rollupDirectoriesByDepth()` function to `scorer.ts` ✅
131
+ - Function takes `DirectoryScore[]`, depth, and projectRoot; returns aggregated `DirectoryScore[]` ✅
132
+ - Use `aggregateMetrics()` helper for weighted averages ✅
133
+ - Add unit tests for path utility and rollup logic ✅
134
+
135
+ Key function signatures:
136
+ ```typescript
137
+ function getPathAtDepth(relativePath: string, depth: number): string
138
+
139
+ function rollupDirectoriesByDepth(
140
+ directories: DirectoryScore[],
141
+ depth: number,
142
+ projectRoot: string
143
+ ): DirectoryScore[]
144
+ ```
145
+
146
+ ### Phase 2: Add CLI Flag ✅
147
+
148
+ - Add `--dir-depth` flag to cleye configuration in `cli.ts` ✅
149
+ - Add `dirDepth` to `CliFlags` type in `cli-utils.ts` ✅
150
+ - Add validation using Zod: `z.number().int().min(1)` ✅
151
+ - Pass depth through to reporter via options ✅
152
+ - Add tests for flag validation ✅
153
+
154
+ ### Phase 3: Update Console Report ✅
155
+
156
+ - Update `printConsoleReport()` signature to accept `dirDepth?: number` option ✅
157
+ - Add `printRolledUpDirectories()` function for depth-based display ✅
158
+ - When depth specified, call rollup and show all directories alphabetically ✅
159
+ - Update table header to indicate depth: "Directory Breakdown (depth=N):" ✅
160
+ - Preserve existing behavior when depth not specified ✅
161
+
162
+ ### Phase 4: Update JSON Report ✅
163
+
164
+ - Add optional `rolledUpDirectories?: DirectoryScore[]` to `ProjectScore` type ✅
165
+ - Update `generateJsonReport()` to accept depth option ✅
166
+ - Compute and include rolled-up directories when depth specified ✅
167
+ - Keep `directoryScores` unchanged (full data always present) ✅
168
+
169
+ ### Phase 5: Documentation ✅
170
+
171
+ - Update README.md CLI reference with `--dir-depth` flag ✅
172
+ - Update TECHNICAL.md with rollup algorithm description ✅
173
+ - Add example output to README.md ✅
174
+
175
+ ## Tests
176
+
177
+ ### Unit Tests
178
+
179
+ **`scorer.test.ts`** — Test path utility:
180
+
181
+ ```typescript
182
+ describe('getPathAtDepth', () => {
183
+ it('should return first N+1 segments for depth N', () => {
184
+ expect(getPathAtDepth('src/services/auth/utils', 1)).toBe('src/services')
185
+ expect(getPathAtDepth('src/services/auth/utils', 2)).toBe('src/services/auth')
186
+ })
187
+
188
+ it('should return full path when depth exceeds segments', () => {
189
+ expect(getPathAtDepth('src/utils', 5)).toBe('src/utils')
190
+ })
191
+
192
+ it('should handle single segment paths', () => {
193
+ expect(getPathAtDepth('src', 0)).toBe('src')
194
+ expect(getPathAtDepth('src', 1)).toBe('src')
195
+ })
196
+ })
197
+ ```
198
+
199
+ **`scorer.test.ts`** — Test rollup logic:
200
+
201
+ ```typescript
202
+ describe('rollupDirectoriesByDepth', () => {
203
+ it('should aggregate directories at depth 1', () => {
204
+ const dirs = [
205
+ createDirScore('/project/src/services/auth', { pureCount: 5, impureCount: 5 }),
206
+ createDirScore('/project/src/services/users', { pureCount: 3, impureCount: 7 }),
207
+ createDirScore('/project/src/utils/format', { pureCount: 8, impureCount: 2 }),
208
+ ]
209
+
210
+ const rolled = rollupDirectoriesByDepth(dirs, 1, '/project')
211
+
212
+ expect(rolled).toHaveLength(2)
213
+ expect(rolled.find(d => d.dirPath === 'src/services')?.pureCount).toBe(8)
214
+ expect(rolled.find(d => d.dirPath === 'src/utils')?.pureCount).toBe(8)
215
+ })
216
+
217
+ it('should calculate weighted health correctly', () => {
218
+ const dirs = [
219
+ createDirScore('/project/src/a', {
220
+ pureCount: 10, impureCount: 0, // 100% health, 10 functions
221
+ statusBreakdown: { ok: 10, review: 0, refactor: 0 }
222
+ }),
223
+ createDirScore('/project/src/b', {
224
+ pureCount: 0, impureCount: 10, // 0% health, 10 functions
225
+ statusBreakdown: { ok: 0, review: 0, refactor: 10 }
226
+ }),
227
+ ]
228
+
229
+ const rolled = rollupDirectoriesByDepth(dirs, 0, '/project')
230
+
231
+ // Weighted by function count: (10*100 + 10*0) / 20 = 50%
232
+ expect(rolled[0]?.health).toBe(50)
233
+ })
234
+
235
+ it('should handle depth greater than actual nesting', () => {
236
+ const dirs = [
237
+ createDirScore('/project/src', { pureCount: 5, impureCount: 5 }),
238
+ ]
239
+
240
+ const rolled = rollupDirectoriesByDepth(dirs, 5, '/project')
241
+
242
+ expect(rolled).toHaveLength(1)
243
+ expect(rolled[0]?.dirPath).toBe('src')
244
+ })
245
+
246
+ it('should return empty array for empty input', () => {
247
+ const rolled = rollupDirectoriesByDepth([], 1, '/project')
248
+ expect(rolled).toHaveLength(0)
249
+ })
250
+
251
+ it('should sort results alphabetically by path', () => {
252
+ const dirs = [
253
+ createDirScore('/project/z/deep', { pureCount: 1, impureCount: 1 }),
254
+ createDirScore('/project/a/deep', { pureCount: 1, impureCount: 1 }),
255
+ createDirScore('/project/m/deep', { pureCount: 1, impureCount: 1 }),
256
+ ]
257
+
258
+ const rolled = rollupDirectoriesByDepth(dirs, 0, '/project')
259
+
260
+ expect(rolled.map(d => d.dirPath)).toEqual(['a', 'm', 'z'])
261
+ })
262
+ })
263
+ ```
264
+
265
+ **`cli.test.ts`** — Test flag validation:
266
+
267
+ ```typescript
268
+ describe('--dir-depth validation', () => {
269
+ it('should accept positive integer', () => {
270
+ const flags = createDefaultFlags({ dirDepth: 2 })
271
+ expect(validateCliFlags(flags)).toEqual({ valid: true })
272
+ })
273
+
274
+ it('should accept depth of 1', () => {
275
+ const flags = createDefaultFlags({ dirDepth: 1 })
276
+ expect(validateCliFlags(flags)).toEqual({ valid: true })
277
+ })
278
+
279
+ it('should reject zero', () => {
280
+ const flags = createDefaultFlags({ dirDepth: 0 })
281
+ expect(validateCliFlags(flags).valid).toBe(false)
282
+ })
283
+
284
+ it('should reject negative numbers', () => {
285
+ const flags = createDefaultFlags({ dirDepth: -1 })
286
+ expect(validateCliFlags(flags).valid).toBe(false)
287
+ })
288
+
289
+ it('should reject non-integers', () => {
290
+ const flags = createDefaultFlags({ dirDepth: 1.5 })
291
+ expect(validateCliFlags(flags).valid).toBe(false)
292
+ })
293
+
294
+ it('should accept undefined (flag not provided)', () => {
295
+ const flags = createDefaultFlags({ dirDepth: undefined })
296
+ expect(validateCliFlags(flags)).toEqual({ valid: true })
297
+ })
298
+ })
299
+ ```
300
+
301
+ ## Transitive Effect Analysis
302
+
303
+ ### Phase 0 (Refactor)
304
+ - `scorer.ts` → internal refactoring only, no API changes
305
+ - All existing tests must pass unchanged
306
+
307
+ ### Phase 1 (Rollup Logic)
308
+ - `scorer.ts` → new pure functions added, no breaking changes
309
+ - Types remain unchanged (reusing `DirectoryScore` with empty `fileScores`)
310
+
311
+ ### Phase 2 (CLI Flag)
312
+ - `cli.ts` → new flag, no breaking changes
313
+ - `cli-utils.ts` → extend `CliFlags` type, new validation
314
+ - `cli.test.ts` → add new tests
315
+
316
+ ### Phase 3 (Console Report)
317
+ - `report-console.ts` → extended options object (backward compatible)
318
+ - `cli.ts` → pass depth to reporter
319
+ - Existing behavior preserved when depth not specified
320
+
321
+ ### Phase 4 (JSON Report)
322
+ - `types.ts` → add optional `rolledUpDirectories` to `ProjectScore`
323
+ - `report-json.ts` → extended options, include new field when present
324
+ - External consumers of JSON may see new field (non-breaking, additive)
325
+
326
+ ## Resources for Implementation
327
+
328
+ **Files to modify:**
329
+ - `fcis/src/scoring/scorer.ts` — Phase 0, Phase 1
330
+ - `fcis/src/cli.ts` — Phase 2
331
+ - `fcis/src/cli-utils.ts` — Phase 2
332
+ - `fcis/src/reporting/report-console.ts` — Phase 3
333
+ - `fcis/src/reporting/report-json.ts` — Phase 4
334
+ - `fcis/src/types.ts` — Phase 4
335
+ - `fcis/README.md` — Phase 5
336
+ - `fcis/TECHNICAL.md` — Phase 5
337
+
338
+ **Files to add/update tests:**
339
+ - `fcis/tests/scorer.test.ts` — Phase 0, Phase 1
340
+ - `fcis/tests/cli.test.ts` — Phase 2
341
+
342
+ **Reference:**
343
+ - Existing `scoreDirectory()` function for aggregation pattern
344
+ - Existing `printDirectoryBreakdown()` for output format
345
+ - `DirectoryScore` type definition
346
+ - `toRelativePath()` in `report-console.ts` for path handling pattern
347
+
348
+ ## Documentation Updates
349
+
350
+ ### README.md
351
+
352
+ Add to CLI Reference:
353
+
354
+ ```markdown
355
+ --dir-depth <N> Roll up directory metrics to depth N (e.g., 1 for top-level)
356
+ ```
357
+
358
+ Add example:
359
+
360
+ ```markdown
361
+ ### Directory Rollup
362
+
363
+ To see aggregate metrics for top-level directories:
364
+
365
+ ```bash
366
+ fcis tsconfig.json --dir-depth 1
367
+ ```
368
+
369
+ This shows ALL directories at the specified depth with aggregated metrics, providing a high-level overview of codebase health by area.
370
+ ```
371
+
372
+ ### TECHNICAL.md
373
+
374
+ Add section:
375
+
376
+ ```markdown
377
+ ## Directory Rollup
378
+
379
+ When `--dir-depth N` is specified, directory metrics are aggregated hierarchically:
380
+
381
+ 1. Absolute directory paths are converted to paths relative to the project root
382
+ 2. Paths are truncated to N+1 segments (e.g., depth 1 → `src/services`)
383
+ 3. Directories with the same truncated path are grouped together
384
+ 4. Metrics are aggregated using weighted averages (weighted by function count):
385
+ - Health = (total ok functions) / (total functions) × 100
386
+ - Purity = (total pure functions) / (total functions) × 100
387
+ - Impurity Quality = weighted average by impure function count
388
+ 5. All directories at the specified depth are shown, sorted alphabetically
389
+
390
+ This provides a high-level view of codebase health by area without the noise of deeply nested leaf directories.
391
+
392
+ ### Edge Cases
393
+
394
+ - **Depth exceeds nesting:** Directories shallower than the requested depth are shown at their actual depth
395
+ - **Single directory:** Works correctly with a single input directory
396
+ - **Empty directories:** Directories with no functions are excluded from aggregation
397
+ ```
398
+
399
+ ## Estimated Line Impact
400
+
401
+ - Phase 0 (Refactor): ~40 lines changed in `scorer.ts`
402
+ - Phase 1 (Rollup): ~60 lines added to `scorer.ts`, ~80 lines tests
403
+ - Phase 2 (CLI): ~15 lines in `cli.ts`, ~20 lines in `cli-utils.ts`, ~30 lines tests
404
+ - Phase 3 (Console): ~40 lines in `report-console.ts`
405
+ - Phase 4 (JSON): ~15 lines in `report-json.ts`, ~5 lines in `types.ts`
406
+ - Phase 5 (Docs): ~30 lines
407
+
408
+ **Total: ~335 lines** — well under the 2000 line PR limit.