fcis 0.1.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.
Files changed (151) hide show
  1. package/.plans/001-fcis-analyzer.md +832 -0
  2. package/.plans/002-fcis-analyzer-improvements.md +205 -0
  3. package/README.md +272 -0
  4. package/TECHNICAL.md +386 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +1836 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/index.d.ts +709 -0
  9. package/dist/index.js +1845 -0
  10. package/dist/index.js.map +1 -0
  11. package/package.json +47 -0
  12. package/pnpm-workspace.yaml +0 -0
  13. package/src/analyzer.ts +266 -0
  14. package/src/classification/classifier.ts +156 -0
  15. package/src/classification/derive-status.ts +171 -0
  16. package/src/classification/quality-scorer.ts +481 -0
  17. package/src/cli.ts +286 -0
  18. package/src/detection/detect-markers.ts +480 -0
  19. package/src/detection/markers.ts +332 -0
  20. package/src/extraction/extract-functions.ts +570 -0
  21. package/src/extraction/extractor.ts +188 -0
  22. package/src/index.ts +111 -0
  23. package/src/reporting/report-console.ts +416 -0
  24. package/src/reporting/report-json.ts +232 -0
  25. package/src/scoring/scorer.ts +504 -0
  26. package/src/types.ts +248 -0
  27. package/tests/classifier.test.ts +480 -0
  28. package/tests/derive-status.test.ts +464 -0
  29. package/tests/detect-markers.test.ts +639 -0
  30. package/tests/extractor.test.ts +155 -0
  31. package/tests/integration.test.ts +706 -0
  32. package/tests/quality-scorer.test.ts +650 -0
  33. package/tests/scorer.test.ts +768 -0
  34. package/tsconfig.json +34 -0
  35. package/tsup.config.ts +17 -0
  36. package/vendor/ts-morph/.editorconfig +10 -0
  37. package/vendor/ts-morph/.gitattributes +11 -0
  38. package/vendor/ts-morph/.github/CODE_OF_CONDUCT.md +77 -0
  39. package/vendor/ts-morph/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
  40. package/vendor/ts-morph/.github/ISSUE_TEMPLATE/custom.md +4 -0
  41. package/vendor/ts-morph/.github/ISSUE_TEMPLATE/feature_request.md +18 -0
  42. package/vendor/ts-morph/.github/workflows/ci.yml +50 -0
  43. package/vendor/ts-morph/.github/workflows/publish.yml +53 -0
  44. package/vendor/ts-morph/.vscode/settings.json +10 -0
  45. package/vendor/ts-morph/CONTRIBUTING.md +23 -0
  46. package/vendor/ts-morph/DEVELOPMENT.md +32 -0
  47. package/vendor/ts-morph/LICENSE +21 -0
  48. package/vendor/ts-morph/deno.json +8 -0
  49. package/vendor/ts-morph/deno.lock +1233 -0
  50. package/vendor/ts-morph/docs/CNAME +1 -0
  51. package/vendor/ts-morph/docs/Gemfile +2 -0
  52. package/vendor/ts-morph/docs/_config.yml +5 -0
  53. package/vendor/ts-morph/docs/_layouts/default.html +159 -0
  54. package/vendor/ts-morph/docs/_script-templates/main.ts +116 -0
  55. package/vendor/ts-morph/docs/assets/css/style.scss +212 -0
  56. package/vendor/ts-morph/docs/details/ambient.md +38 -0
  57. package/vendor/ts-morph/docs/details/async.md +31 -0
  58. package/vendor/ts-morph/docs/details/classes.md +314 -0
  59. package/vendor/ts-morph/docs/details/comment-ranges.md +7 -0
  60. package/vendor/ts-morph/docs/details/comments.md +122 -0
  61. package/vendor/ts-morph/docs/details/decorators.md +119 -0
  62. package/vendor/ts-morph/docs/details/documentation.md +73 -0
  63. package/vendor/ts-morph/docs/details/enums.md +117 -0
  64. package/vendor/ts-morph/docs/details/exports.md +308 -0
  65. package/vendor/ts-morph/docs/details/expressions.md +46 -0
  66. package/vendor/ts-morph/docs/details/functions.md +150 -0
  67. package/vendor/ts-morph/docs/details/generators.md +27 -0
  68. package/vendor/ts-morph/docs/details/identifiers.md +79 -0
  69. package/vendor/ts-morph/docs/details/imports.md +191 -0
  70. package/vendor/ts-morph/docs/details/index.md +52 -0
  71. package/vendor/ts-morph/docs/details/initializers.md +40 -0
  72. package/vendor/ts-morph/docs/details/interfaces.md +218 -0
  73. package/vendor/ts-morph/docs/details/literals.md +20 -0
  74. package/vendor/ts-morph/docs/details/modifiers.md +38 -0
  75. package/vendor/ts-morph/docs/details/modules.md +113 -0
  76. package/vendor/ts-morph/docs/details/namespaces.md +7 -0
  77. package/vendor/ts-morph/docs/details/object-literal-expressions.md +106 -0
  78. package/vendor/ts-morph/docs/details/parameters.md +64 -0
  79. package/vendor/ts-morph/docs/details/signatures.md +41 -0
  80. package/vendor/ts-morph/docs/details/source-files.md +292 -0
  81. package/vendor/ts-morph/docs/details/type-aliases.md +34 -0
  82. package/vendor/ts-morph/docs/details/type-parameters.md +72 -0
  83. package/vendor/ts-morph/docs/details/types.md +254 -0
  84. package/vendor/ts-morph/docs/details/variables.md +110 -0
  85. package/vendor/ts-morph/docs/emitting.md +151 -0
  86. package/vendor/ts-morph/docs/index.md +25 -0
  87. package/vendor/ts-morph/docs/manipulation/code-writer.md +20 -0
  88. package/vendor/ts-morph/docs/manipulation/formatting.md +76 -0
  89. package/vendor/ts-morph/docs/manipulation/index.md +136 -0
  90. package/vendor/ts-morph/docs/manipulation/order.md +14 -0
  91. package/vendor/ts-morph/docs/manipulation/performance.md +222 -0
  92. package/vendor/ts-morph/docs/manipulation/removing.md +31 -0
  93. package/vendor/ts-morph/docs/manipulation/renaming.md +106 -0
  94. package/vendor/ts-morph/docs/manipulation/settings.md +76 -0
  95. package/vendor/ts-morph/docs/manipulation/structures.md +117 -0
  96. package/vendor/ts-morph/docs/manipulation/transforms.md +84 -0
  97. package/vendor/ts-morph/docs/metrics/performance.json +4 -0
  98. package/vendor/ts-morph/docs/navigation/ambient-modules.md +22 -0
  99. package/vendor/ts-morph/docs/navigation/compiler-nodes.md +82 -0
  100. package/vendor/ts-morph/docs/navigation/directories.md +287 -0
  101. package/vendor/ts-morph/docs/navigation/example.md +50 -0
  102. package/vendor/ts-morph/docs/navigation/finding-references.md +53 -0
  103. package/vendor/ts-morph/docs/navigation/getting-source-files.md +59 -0
  104. package/vendor/ts-morph/docs/navigation/images/getChildrenVsForEachChild.gif +0 -0
  105. package/vendor/ts-morph/docs/navigation/index.md +94 -0
  106. package/vendor/ts-morph/docs/navigation/language-service.md +23 -0
  107. package/vendor/ts-morph/docs/navigation/program.md +25 -0
  108. package/vendor/ts-morph/docs/navigation/type-checker.md +33 -0
  109. package/vendor/ts-morph/docs/setup/adding-source-files.md +145 -0
  110. package/vendor/ts-morph/docs/setup/ast-viewers.md +46 -0
  111. package/vendor/ts-morph/docs/setup/diagnostics.md +109 -0
  112. package/vendor/ts-morph/docs/setup/file-system.md +106 -0
  113. package/vendor/ts-morph/docs/setup/images/atom-ast.png +0 -0
  114. package/vendor/ts-morph/docs/setup/images/atom-ast_small.png +0 -0
  115. package/vendor/ts-morph/docs/setup/images/atom-command-palette.png +0 -0
  116. package/vendor/ts-morph/docs/setup/images/atom-file.png +0 -0
  117. package/vendor/ts-morph/docs/setup/images/ts-ast-viewer.png +0 -0
  118. package/vendor/ts-morph/docs/setup/index.md +94 -0
  119. package/vendor/ts-morph/docs/utilities.md +55 -0
  120. package/vendor/ts-morph/dprint.json +23 -0
  121. package/vendor/ts-morph/package.json +30 -0
  122. package/vendor/ts-morph/packages/bootstrap/LICENSE +21 -0
  123. package/vendor/ts-morph/packages/bootstrap/lib/ts-morph-bootstrap.d.ts +397 -0
  124. package/vendor/ts-morph/packages/bootstrap/package.json +46 -0
  125. package/vendor/ts-morph/packages/bootstrap/readme.md +200 -0
  126. package/vendor/ts-morph/packages/common/LICENSE +21 -0
  127. package/vendor/ts-morph/packages/common/lib/ts-morph-common.d.ts +1082 -0
  128. package/vendor/ts-morph/packages/common/lib/typescript.d.ts +11439 -0
  129. package/vendor/ts-morph/packages/common/package.json +65 -0
  130. package/vendor/ts-morph/packages/common/readme.md +5 -0
  131. package/vendor/ts-morph/packages/scripts/changeTypeScriptVersion.ts +28 -0
  132. package/vendor/ts-morph/packages/scripts/createDeclarationProject.ts +47 -0
  133. package/vendor/ts-morph/packages/scripts/deps.ts +2 -0
  134. package/vendor/ts-morph/packages/scripts/execScript.ts +31 -0
  135. package/vendor/ts-morph/packages/scripts/folders.ts +11 -0
  136. package/vendor/ts-morph/packages/scripts/getDevCompilerVersions.ts +19 -0
  137. package/vendor/ts-morph/packages/scripts/mod.ts +7 -0
  138. package/vendor/ts-morph/packages/scripts/utils/Memoize.ts +36 -0
  139. package/vendor/ts-morph/packages/scripts/utils/forEachTypeText.ts +23 -0
  140. package/vendor/ts-morph/packages/scripts/utils/makeConstructorsPrivate.ts +26 -0
  141. package/vendor/ts-morph/packages/scripts/utils/mod.ts +4 -0
  142. package/vendor/ts-morph/packages/scripts/utils/printDiagnostics.ts +10 -0
  143. package/vendor/ts-morph/packages/ts-morph/LICENSE +21 -0
  144. package/vendor/ts-morph/packages/ts-morph/lib/ts-morph.d.ts +11198 -0
  145. package/vendor/ts-morph/packages/ts-morph/package.json +78 -0
  146. package/vendor/ts-morph/packages/ts-morph/readme.md +111 -0
  147. package/vendor/ts-morph/readme.md +14 -0
  148. package/vendor/ts-morph/rfcs/README.md +13 -0
  149. package/vendor/ts-morph/rfcs/RFC-0001 - Inserting Into Statements Handling Comments.md +181 -0
  150. package/vendor/ts-morph/tsconfig.common.json +17 -0
  151. package/vitest.config.ts +16 -0
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Extractor - Shell layer for loading TypeScript projects via ts-morph
3
+ *
4
+ * This module handles the I/O of loading a TypeScript project from disk.
5
+ * It's a thin shell that delegates to ts-morph for actual parsing.
6
+ */
7
+
8
+ import { Project, SourceFile } from 'ts-morph'
9
+ import * as path from 'node:path'
10
+ import * as fs from 'node:fs'
11
+
12
+ /**
13
+ * Strips JSON comments (both line // and block /* comments)
14
+ * This allows parsing tsconfig.json files that contain comments
15
+ */
16
+ export function stripJsonComments(jsonString: string): string {
17
+ let result = ''
18
+ let inString = false
19
+ let inLineComment = false
20
+ let inBlockComment = false
21
+ let i = 0
22
+
23
+ while (i < jsonString.length) {
24
+ const char = jsonString[i]
25
+ const nextChar = jsonString[i + 1]
26
+
27
+ // Handle string state (don't strip comments inside strings)
28
+ if (!inLineComment && !inBlockComment && char === '"') {
29
+ // Check if this quote is escaped
30
+ let backslashCount = 0
31
+ let j = i - 1
32
+ while (j >= 0 && jsonString[j] === '\\') {
33
+ backslashCount++
34
+ j--
35
+ }
36
+ if (backslashCount % 2 === 0) {
37
+ inString = !inString
38
+ }
39
+ }
40
+
41
+ if (inString) {
42
+ result += char
43
+ i++
44
+ continue
45
+ }
46
+
47
+ // Handle line comment start
48
+ if (!inBlockComment && char === '/' && nextChar === '/') {
49
+ inLineComment = true
50
+ i += 2
51
+ continue
52
+ }
53
+
54
+ // Handle line comment end
55
+ if (inLineComment && (char === '\n' || char === '\r')) {
56
+ inLineComment = false
57
+ result += char
58
+ i++
59
+ continue
60
+ }
61
+
62
+ // Handle block comment start
63
+ if (!inLineComment && char === '/' && nextChar === '*') {
64
+ inBlockComment = true
65
+ i += 2
66
+ continue
67
+ }
68
+
69
+ // Handle block comment end
70
+ if (inBlockComment && char === '*' && nextChar === '/') {
71
+ inBlockComment = false
72
+ i += 2
73
+ continue
74
+ }
75
+
76
+ // Add character if not in a comment
77
+ if (!inLineComment && !inBlockComment) {
78
+ result += char
79
+ }
80
+
81
+ i++
82
+ }
83
+
84
+ return result
85
+ }
86
+
87
+ export type ExtractorOptions = {
88
+ tsconfigPath: string
89
+ filesGlob?: string
90
+ }
91
+
92
+ export type ExtractorResult = {
93
+ project: Project
94
+ sourceFiles: SourceFile[]
95
+ errors: { filePath: string; error: string }[]
96
+ }
97
+
98
+ /**
99
+ * Loads a TypeScript project from a tsconfig.json path
100
+ *
101
+ * @param options - Configuration options for the extractor
102
+ * @returns The loaded project and source files
103
+ * @throws Error if tsconfig.json doesn't exist or is invalid
104
+ */
105
+ export function loadProject(options: ExtractorOptions): ExtractorResult {
106
+ const { tsconfigPath, filesGlob } = options
107
+ const errors: { filePath: string; error: string }[] = []
108
+
109
+ // Validate tsconfig exists
110
+ const absoluteTsconfigPath = path.resolve(tsconfigPath)
111
+ if (!fs.existsSync(absoluteTsconfigPath)) {
112
+ throw new Error(`tsconfig.json not found at: ${absoluteTsconfigPath}`)
113
+ }
114
+
115
+ // Validate tsconfig is valid JSON (strip comments first since TypeScript allows them)
116
+ try {
117
+ const content = fs.readFileSync(absoluteTsconfigPath, 'utf-8')
118
+ const strippedContent = stripJsonComments(content)
119
+ JSON.parse(strippedContent)
120
+ } catch (e) {
121
+ throw new Error(
122
+ `Invalid tsconfig.json at ${absoluteTsconfigPath}: ${e instanceof Error ? e.message : String(e)}`,
123
+ )
124
+ }
125
+
126
+ // Create the ts-morph project
127
+ const project = new Project({
128
+ tsConfigFilePath: absoluteTsconfigPath,
129
+ skipAddingFilesFromTsConfig: false,
130
+ })
131
+
132
+ // Get source files, filtering to .ts only (excluding .tsx for v1)
133
+ let sourceFiles = project.getSourceFiles().filter(sf => {
134
+ const filePath = sf.getFilePath()
135
+ // Include only .ts files, exclude .tsx, .d.ts, test files, and generated files
136
+ return (
137
+ filePath.endsWith('.ts') &&
138
+ !filePath.endsWith('.d.ts') &&
139
+ !filePath.endsWith('.test.ts') &&
140
+ !filePath.endsWith('.spec.ts') &&
141
+ !filePath.includes('/node_modules/') &&
142
+ !filePath.includes('/generated/')
143
+ )
144
+ })
145
+
146
+ // Apply files glob filter if provided
147
+ if (filesGlob) {
148
+ const globPattern = createGlobMatcher(filesGlob)
149
+ sourceFiles = sourceFiles.filter(sf => globPattern(sf.getFilePath()))
150
+ }
151
+
152
+ return {
153
+ project,
154
+ sourceFiles,
155
+ errors,
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Creates a simple glob matcher function
161
+ * Supports basic glob patterns: *, **, ?
162
+ */
163
+ function createGlobMatcher(glob: string): (filePath: string) => boolean {
164
+ // Convert glob to regex
165
+ const regexStr = glob
166
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except * and ?
167
+ .replace(/\*\*/g, '{{DOUBLE_STAR}}') // Placeholder for **
168
+ .replace(/\*/g, '[^/]*') // * matches anything except /
169
+ .replace(/\?/g, '.') // ? matches single char
170
+ .replace(/\{\{DOUBLE_STAR\}\}/g, '.*') // ** matches anything including /
171
+
172
+ const regex = new RegExp(regexStr)
173
+
174
+ return (filePath: string) => regex.test(filePath)
175
+ }
176
+
177
+ /**
178
+ * Gets the commit hash from git if available
179
+ */
180
+ export function getCommitHash(): string | null {
181
+ try {
182
+ const { execSync } = require('node:child_process')
183
+ const hash = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim()
184
+ return hash
185
+ } catch {
186
+ return null
187
+ }
188
+ }
package/src/index.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * FCIS Analyzer - Public API
3
+ *
4
+ * Functional Core, Imperative Shell analyzer for TypeScript codebases.
5
+ *
6
+ * @module @schoolai/fcis
7
+ */
8
+
9
+ // Main analyzer function
10
+ export {
11
+ analyze,
12
+ analyzeFile,
13
+ analyzeFilePath,
14
+ checkThresholds,
15
+ } from './analyzer.js'
16
+
17
+ // Types
18
+ export type {
19
+ // Core types
20
+ CallSite,
21
+ ExtractedFunction,
22
+ FileImports,
23
+ MarkerType,
24
+ ImpurityMarker,
25
+ FunctionClassification,
26
+ Status,
27
+ ClassifiedFunction,
28
+ // Score types
29
+ StatusBreakdown,
30
+ RefactoringCandidate,
31
+ FileScore,
32
+ DirectoryScore,
33
+ ProjectScore,
34
+ // Config types
35
+ AnalyzerConfig,
36
+ QualityThresholds,
37
+ AnalysisError,
38
+ ExtractionResult,
39
+ DetectionContext,
40
+ } from './types.js'
41
+
42
+ export { DEFAULT_QUALITY_THRESHOLDS } from './types.js'
43
+
44
+ // Detection
45
+ export {
46
+ detectMarkers,
47
+ createDetectionContext,
48
+ getPureImportedFunctions,
49
+ } from './detection/detect-markers.js'
50
+ export {
51
+ MARKER_CATALOG,
52
+ getMarkerDefinition,
53
+ getAllMarkerTypes,
54
+ } from './detection/markers.js'
55
+ export type { MarkerDefinition } from './detection/markers.js'
56
+
57
+ // Classification
58
+ export {
59
+ classifyFunction,
60
+ shouldExcludeFunction,
61
+ createClassifiedFunction,
62
+ } from './classification/classifier.js'
63
+ export {
64
+ computeQualityScore,
65
+ analyzeFunction as analyzeFunctionQuality,
66
+ QUALITY_WEIGHTS,
67
+ } from './classification/quality-scorer.js'
68
+ export type { FunctionAnalysis } from './classification/quality-scorer.js'
69
+ export {
70
+ deriveStatus,
71
+ getStatusDescription,
72
+ getStatusEmoji,
73
+ getStatusColor,
74
+ } from './classification/derive-status.js'
75
+
76
+ // Scoring
77
+ export {
78
+ scoreFile,
79
+ scoreDirectory,
80
+ scoreProject,
81
+ groupFilesByDirectory,
82
+ calculateDelta,
83
+ getDiagnosticInsights,
84
+ } from './scoring/scorer.js'
85
+
86
+ // Reporting
87
+ export {
88
+ generateJsonReport,
89
+ writeJsonReport,
90
+ readJsonReport,
91
+ createSummaryJson,
92
+ generateComparisonReport,
93
+ } from './reporting/report-json.js'
94
+ export type { JsonReportOptions } from './reporting/report-json.js'
95
+ export {
96
+ printConsoleReport,
97
+ generateSummaryLine,
98
+ printFileReport,
99
+ } from './reporting/report-console.js'
100
+
101
+ // Extraction (for advanced use cases)
102
+ export { loadProject, getCommitHash } from './extraction/extractor.js'
103
+ export type {
104
+ ExtractorOptions,
105
+ ExtractorResult,
106
+ } from './extraction/extractor.js'
107
+ export {
108
+ extractFunctions,
109
+ extractImports,
110
+ isTypeOnlyFile,
111
+ } from './extraction/extract-functions.js'
@@ -0,0 +1,416 @@
1
+ /**
2
+ * Console Report Generator - Shell Layer
3
+ *
4
+ * Produces human-readable console output with colors, visual bars,
5
+ * and formatted tables. This is part of the SHELL layer as it
6
+ * performs I/O (writing to console).
7
+ */
8
+
9
+ import chalk from 'chalk'
10
+ import * as path from 'node:path'
11
+
12
+ import type {
13
+ DirectoryScore,
14
+ FileScore,
15
+ ProjectScore,
16
+ RefactoringCandidate,
17
+ StatusBreakdown,
18
+ } from '../types.js'
19
+ import { getDiagnosticInsights } from '../scoring/scorer.js'
20
+ import {
21
+ getStatusColor,
22
+ getStatusEmoji,
23
+ } from '../classification/derive-status.js'
24
+
25
+ /**
26
+ * Generate and print the full console report
27
+ */
28
+ export function printConsoleReport(
29
+ score: ProjectScore,
30
+ options: { verbose?: boolean } = {},
31
+ ): void {
32
+ const { verbose = false } = options
33
+
34
+ printHeader()
35
+ printProjectSummary(score)
36
+ printStatusBreakdown(
37
+ score.statusBreakdown,
38
+ score.pureCount + score.impureCount,
39
+ )
40
+ printInsights(score)
41
+
42
+ if (verbose) {
43
+ printDirectoryBreakdown(score.directoryScores)
44
+ } else {
45
+ printWorstDirectories(score.directoryScores)
46
+ }
47
+
48
+ printRefactoringCandidates(score.refactoringCandidates.slice(0, 10))
49
+
50
+ if (score.errors && score.errors.length > 0) {
51
+ printErrors(score.errors)
52
+ }
53
+
54
+ printFooter(score)
55
+ }
56
+
57
+ /**
58
+ * Print the report header
59
+ */
60
+ function printHeader(): void {
61
+ console.log()
62
+ console.log(chalk.bold('FCIS Analysis'))
63
+ console.log(
64
+ chalk.gray('═══════════════════════════════════════════════════════════'),
65
+ )
66
+ console.log()
67
+ }
68
+
69
+ /**
70
+ * Print the project summary with visual bars
71
+ */
72
+ function printProjectSummary(score: ProjectScore): void {
73
+ const healthBar = createProgressBar(score.health, 25)
74
+
75
+ const healthColor =
76
+ score.health >= 70
77
+ ? chalk.green
78
+ : score.health >= 50
79
+ ? chalk.yellow
80
+ : chalk.red
81
+ const purityColor =
82
+ score.purity >= 70
83
+ ? chalk.green
84
+ : score.purity >= 50
85
+ ? chalk.yellow
86
+ : chalk.red
87
+
88
+ console.log(
89
+ `Project Health: ${healthColor(score.health.toFixed(0) + '%')} ${healthBar}`,
90
+ )
91
+ console.log(
92
+ ` Purity: ${purityColor(score.purity.toFixed(0) + '%')} (${score.pureCount} pure / ${score.pureCount + score.impureCount} total)`,
93
+ )
94
+
95
+ if (score.impurityQuality !== null) {
96
+ const qualityColor =
97
+ score.impurityQuality >= 70
98
+ ? chalk.green
99
+ : score.impurityQuality >= 50
100
+ ? chalk.yellow
101
+ : chalk.red
102
+ console.log(
103
+ ` Impurity Quality: ${qualityColor(score.impurityQuality.toFixed(0) + '%')} average`,
104
+ )
105
+ }
106
+
107
+ console.log()
108
+ }
109
+
110
+ /**
111
+ * Print status breakdown
112
+ */
113
+ function printStatusBreakdown(breakdown: StatusBreakdown, total: number): void {
114
+ console.log('Status Breakdown:')
115
+
116
+ const okPercent = total > 0 ? ((breakdown.ok / total) * 100).toFixed(0) : '0'
117
+ const reviewPercent =
118
+ total > 0 ? ((breakdown.review / total) * 100).toFixed(0) : '0'
119
+ const refactorPercent =
120
+ total > 0 ? ((breakdown.refactor / total) * 100).toFixed(0) : '0'
121
+
122
+ console.log(
123
+ ` ${chalk.green('✓ OK:')} ${String(breakdown.ok).padStart(4)} functions (${okPercent}%) — no action needed`,
124
+ )
125
+ console.log(
126
+ ` ${chalk.yellow('◐ Review:')} ${String(breakdown.review).padStart(4)} functions (${reviewPercent}%) — could be improved`,
127
+ )
128
+ console.log(
129
+ ` ${chalk.red('✗ Refactor:')} ${String(breakdown.refactor).padStart(4)} functions (${refactorPercent}%) — tangled, needs work`,
130
+ )
131
+
132
+ console.log()
133
+ }
134
+
135
+ /**
136
+ * Print diagnostic insights
137
+ */
138
+ function printInsights(score: ProjectScore): void {
139
+ const insights = getDiagnosticInsights(score)
140
+
141
+ if (insights.length > 0) {
142
+ console.log(chalk.bold('Insights:'))
143
+ for (const insight of insights) {
144
+ console.log(` ${chalk.cyan('→')} ${insight}`)
145
+ }
146
+ console.log()
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Print directory breakdown (verbose mode)
152
+ */
153
+ function printDirectoryBreakdown(directories: DirectoryScore[]): void {
154
+ // Sort by health ascending (worst first)
155
+ const sorted = [...directories]
156
+ .filter(d => d.pureCount + d.impureCount > 0)
157
+ .sort((a, b) => a.health - b.health)
158
+
159
+ if (sorted.length === 0) return
160
+
161
+ console.log(chalk.bold('Directory Breakdown:'))
162
+ console.log(chalk.gray('─'.repeat(80)))
163
+ console.log(
164
+ chalk.gray(
165
+ padEnd('Directory', 45) +
166
+ padEnd('Health', 10) +
167
+ padEnd('Purity', 10) +
168
+ padEnd('Functions', 15),
169
+ ),
170
+ )
171
+ console.log(chalk.gray('─'.repeat(80)))
172
+
173
+ for (const dir of sorted) {
174
+ const healthColor =
175
+ dir.health >= 70
176
+ ? chalk.green
177
+ : dir.health >= 50
178
+ ? chalk.yellow
179
+ : chalk.red
180
+ const purityColor =
181
+ dir.purity >= 70
182
+ ? chalk.green
183
+ : dir.purity >= 50
184
+ ? chalk.yellow
185
+ : chalk.red
186
+
187
+ const relativePath = toRelativePath(dir.dirPath)
188
+ const total = dir.pureCount + dir.impureCount
189
+
190
+ console.log(
191
+ padEnd(relativePath, 45) +
192
+ healthColor(padEnd(dir.health.toFixed(0) + '%', 10)) +
193
+ purityColor(padEnd(dir.purity.toFixed(0) + '%', 10)) +
194
+ padEnd(`${dir.pureCount}/${total}`, 15),
195
+ )
196
+ }
197
+
198
+ console.log()
199
+ }
200
+
201
+ /**
202
+ * Print worst directories (non-verbose mode)
203
+ */
204
+ function printWorstDirectories(directories: DirectoryScore[]): void {
205
+ const sorted = [...directories]
206
+ .filter(d => d.pureCount + d.impureCount > 0 && d.health < 70)
207
+ .sort((a, b) => a.health - b.health)
208
+ .slice(0, 5)
209
+
210
+ if (sorted.length === 0) return
211
+
212
+ console.log(chalk.bold('Directories Needing Attention:'))
213
+
214
+ for (const dir of sorted) {
215
+ const healthColor = dir.health >= 50 ? chalk.yellow : chalk.red
216
+ const total = dir.pureCount + dir.impureCount
217
+ const relativePath = toRelativePath(dir.dirPath)
218
+
219
+ console.log(
220
+ ` ${healthColor(dir.health.toFixed(0).padStart(3) + '%')} ` +
221
+ chalk.gray(`(${dir.pureCount}/${total} pure)`) +
222
+ ` ${relativePath}`,
223
+ )
224
+ }
225
+
226
+ console.log()
227
+ }
228
+
229
+ /**
230
+ * Print refactoring candidates
231
+ */
232
+ function printRefactoringCandidates(candidates: RefactoringCandidate[]): void {
233
+ if (candidates.length === 0) {
234
+ console.log(chalk.green('No refactoring candidates found. Great job!'))
235
+ console.log()
236
+ return
237
+ }
238
+
239
+ console.log(chalk.bold('Top Refactoring Candidates:'))
240
+ console.log(chalk.gray('(Sorted by impact: size × complexity)'))
241
+ console.log()
242
+
243
+ for (let i = 0; i < candidates.length; i++) {
244
+ const candidate = candidates[i]
245
+ if (!candidate) continue
246
+
247
+ const qualityColor = candidate.qualityScore >= 40 ? chalk.yellow : chalk.red
248
+ const relativePath = toRelativePath(candidate.filePath)
249
+
250
+ console.log(
251
+ chalk.gray(`${(i + 1).toString().padStart(2)}.`) +
252
+ ` ${qualityColor(candidate.qualityScore.toFixed(0).padStart(3))}` +
253
+ chalk.gray('/100') +
254
+ ` ${chalk.cyan(candidate.name ?? '<anonymous>')}` +
255
+ chalk.gray(` (${candidate.bodyLineCount} lines)`),
256
+ )
257
+ console.log(chalk.gray(` ${relativePath}:${candidate.startLine}`))
258
+
259
+ if (candidate.markers.length > 0) {
260
+ const markerStr = candidate.markers.slice(0, 3).join(', ')
261
+ const more =
262
+ candidate.markers.length > 3
263
+ ? ` +${candidate.markers.length - 3} more`
264
+ : ''
265
+ console.log(chalk.gray(` Markers: ${markerStr}${more}`))
266
+ }
267
+
268
+ console.log()
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Print errors encountered during analysis
274
+ */
275
+ function printErrors(errors: { filePath: string; error: string }[]): void {
276
+ console.log(chalk.yellow.bold(`Errors (${errors.length} files skipped):`))
277
+
278
+ for (const err of errors.slice(0, 5)) {
279
+ console.log(` ${chalk.yellow('!')} ${err.filePath}`)
280
+ console.log(chalk.gray(` ${err.error}`))
281
+ }
282
+
283
+ if (errors.length > 5) {
284
+ console.log(chalk.gray(` ... and ${errors.length - 5} more`))
285
+ }
286
+
287
+ console.log()
288
+ }
289
+
290
+ /**
291
+ * Print the report footer
292
+ */
293
+ function printFooter(score: ProjectScore): void {
294
+ console.log(
295
+ chalk.gray('═══════════════════════════════════════════════════════════'),
296
+ )
297
+
298
+ const timestamp = new Date(score.timestamp).toLocaleString()
299
+ console.log(chalk.gray(`Generated: ${timestamp}`))
300
+
301
+ if (score.commitHash) {
302
+ console.log(chalk.gray(`Commit: ${score.commitHash.slice(0, 8)}`))
303
+ }
304
+
305
+ if (score.subset) {
306
+ console.log(chalk.yellow(`Subset analysis: ${score.filesGlob}`))
307
+ }
308
+
309
+ console.log()
310
+ }
311
+
312
+ /**
313
+ * Create a visual progress bar
314
+ */
315
+ function createProgressBar(percent: number, width: number): string {
316
+ const filled = Math.round((percent / 100) * width)
317
+ const empty = width - filled
318
+
319
+ const filledChar = '█'
320
+ const emptyChar = '░'
321
+
322
+ const bar = filledChar.repeat(filled) + emptyChar.repeat(empty)
323
+
324
+ if (percent >= 70) {
325
+ return chalk.green(bar)
326
+ } else if (percent >= 50) {
327
+ return chalk.yellow(bar)
328
+ } else {
329
+ return chalk.red(bar)
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Generate a single-line summary suitable for CI output
335
+ */
336
+ export function generateSummaryLine(score: ProjectScore): string {
337
+ const parts = [
338
+ `FCIS Health: ${score.health.toFixed(0)}%`,
339
+ `Purity: ${score.purity.toFixed(0)}%`,
340
+ ]
341
+
342
+ if (score.impurityQuality !== null) {
343
+ parts.push(`Impurity Quality: ${score.impurityQuality.toFixed(0)}%`)
344
+ }
345
+
346
+ return parts.join(' | ')
347
+ }
348
+
349
+ /**
350
+ * Convert an absolute path to a path relative to the current working directory
351
+ */
352
+ function toRelativePath(absolutePath: string): string {
353
+ const cwd = process.cwd()
354
+ if (absolutePath.startsWith(cwd)) {
355
+ const relative = path.relative(cwd, absolutePath)
356
+ return relative || '.'
357
+ }
358
+ return absolutePath
359
+ }
360
+
361
+ /**
362
+ * Pad end of string to a fixed length
363
+ */
364
+ function padEnd(str: string, length: number): string {
365
+ if (str.length >= length) return str.slice(0, length)
366
+ return str + ' '.repeat(length - str.length)
367
+ }
368
+
369
+ /**
370
+ * Print a file-level report (for verbose output)
371
+ */
372
+ export function printFileReport(fileScore: FileScore): void {
373
+ const healthColor =
374
+ fileScore.health >= 70
375
+ ? chalk.green
376
+ : fileScore.health >= 50
377
+ ? chalk.yellow
378
+ : chalk.red
379
+ const total = fileScore.pureCount + fileScore.impureCount
380
+ const relativePath = toRelativePath(fileScore.filePath)
381
+
382
+ console.log()
383
+ console.log(chalk.bold(relativePath))
384
+ console.log(
385
+ ` Health: ${healthColor(fileScore.health.toFixed(0) + '%')} | ` +
386
+ `Purity: ${fileScore.purity.toFixed(0)}% | ` +
387
+ `Functions: ${fileScore.pureCount}/${total} pure`,
388
+ )
389
+
390
+ if (fileScore.functions.length > 0 && fileScore.impureCount > 0) {
391
+ console.log()
392
+ console.log(' Functions:')
393
+
394
+ for (const fn of fileScore.functions) {
395
+ const statusEmoji = getStatusEmoji(fn.status)
396
+ const statusColor = getStatusColor(fn.status)
397
+ const colorFn =
398
+ statusColor === 'green'
399
+ ? chalk.green
400
+ : statusColor === 'yellow'
401
+ ? chalk.yellow
402
+ : chalk.red
403
+
404
+ const name = fn.name ?? '<anonymous>'
405
+ const classification =
406
+ fn.classification === 'pure'
407
+ ? chalk.green('pure')
408
+ : chalk.yellow('impure')
409
+ const quality = fn.qualityScore !== null ? ` (${fn.qualityScore})` : ''
410
+
411
+ console.log(
412
+ ` ${colorFn(statusEmoji)} ${name} — ${classification}${quality}`,
413
+ )
414
+ }
415
+ }
416
+ }