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.
- package/.plans/001-fcis-analyzer.md +832 -0
- package/.plans/002-fcis-analyzer-improvements.md +205 -0
- package/README.md +272 -0
- package/TECHNICAL.md +386 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1836 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +709 -0
- package/dist/index.js +1845 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/pnpm-workspace.yaml +0 -0
- package/src/analyzer.ts +266 -0
- package/src/classification/classifier.ts +156 -0
- package/src/classification/derive-status.ts +171 -0
- package/src/classification/quality-scorer.ts +481 -0
- package/src/cli.ts +286 -0
- package/src/detection/detect-markers.ts +480 -0
- package/src/detection/markers.ts +332 -0
- package/src/extraction/extract-functions.ts +570 -0
- package/src/extraction/extractor.ts +188 -0
- package/src/index.ts +111 -0
- package/src/reporting/report-console.ts +416 -0
- package/src/reporting/report-json.ts +232 -0
- package/src/scoring/scorer.ts +504 -0
- package/src/types.ts +248 -0
- package/tests/classifier.test.ts +480 -0
- package/tests/derive-status.test.ts +464 -0
- package/tests/detect-markers.test.ts +639 -0
- package/tests/extractor.test.ts +155 -0
- package/tests/integration.test.ts +706 -0
- package/tests/quality-scorer.test.ts +650 -0
- package/tests/scorer.test.ts +768 -0
- package/tsconfig.json +34 -0
- package/tsup.config.ts +17 -0
- package/vendor/ts-morph/.editorconfig +10 -0
- package/vendor/ts-morph/.gitattributes +11 -0
- package/vendor/ts-morph/.github/CODE_OF_CONDUCT.md +77 -0
- package/vendor/ts-morph/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
- package/vendor/ts-morph/.github/ISSUE_TEMPLATE/custom.md +4 -0
- package/vendor/ts-morph/.github/ISSUE_TEMPLATE/feature_request.md +18 -0
- package/vendor/ts-morph/.github/workflows/ci.yml +50 -0
- package/vendor/ts-morph/.github/workflows/publish.yml +53 -0
- package/vendor/ts-morph/.vscode/settings.json +10 -0
- package/vendor/ts-morph/CONTRIBUTING.md +23 -0
- package/vendor/ts-morph/DEVELOPMENT.md +32 -0
- package/vendor/ts-morph/LICENSE +21 -0
- package/vendor/ts-morph/deno.json +8 -0
- package/vendor/ts-morph/deno.lock +1233 -0
- package/vendor/ts-morph/docs/CNAME +1 -0
- package/vendor/ts-morph/docs/Gemfile +2 -0
- package/vendor/ts-morph/docs/_config.yml +5 -0
- package/vendor/ts-morph/docs/_layouts/default.html +159 -0
- package/vendor/ts-morph/docs/_script-templates/main.ts +116 -0
- package/vendor/ts-morph/docs/assets/css/style.scss +212 -0
- package/vendor/ts-morph/docs/details/ambient.md +38 -0
- package/vendor/ts-morph/docs/details/async.md +31 -0
- package/vendor/ts-morph/docs/details/classes.md +314 -0
- package/vendor/ts-morph/docs/details/comment-ranges.md +7 -0
- package/vendor/ts-morph/docs/details/comments.md +122 -0
- package/vendor/ts-morph/docs/details/decorators.md +119 -0
- package/vendor/ts-morph/docs/details/documentation.md +73 -0
- package/vendor/ts-morph/docs/details/enums.md +117 -0
- package/vendor/ts-morph/docs/details/exports.md +308 -0
- package/vendor/ts-morph/docs/details/expressions.md +46 -0
- package/vendor/ts-morph/docs/details/functions.md +150 -0
- package/vendor/ts-morph/docs/details/generators.md +27 -0
- package/vendor/ts-morph/docs/details/identifiers.md +79 -0
- package/vendor/ts-morph/docs/details/imports.md +191 -0
- package/vendor/ts-morph/docs/details/index.md +52 -0
- package/vendor/ts-morph/docs/details/initializers.md +40 -0
- package/vendor/ts-morph/docs/details/interfaces.md +218 -0
- package/vendor/ts-morph/docs/details/literals.md +20 -0
- package/vendor/ts-morph/docs/details/modifiers.md +38 -0
- package/vendor/ts-morph/docs/details/modules.md +113 -0
- package/vendor/ts-morph/docs/details/namespaces.md +7 -0
- package/vendor/ts-morph/docs/details/object-literal-expressions.md +106 -0
- package/vendor/ts-morph/docs/details/parameters.md +64 -0
- package/vendor/ts-morph/docs/details/signatures.md +41 -0
- package/vendor/ts-morph/docs/details/source-files.md +292 -0
- package/vendor/ts-morph/docs/details/type-aliases.md +34 -0
- package/vendor/ts-morph/docs/details/type-parameters.md +72 -0
- package/vendor/ts-morph/docs/details/types.md +254 -0
- package/vendor/ts-morph/docs/details/variables.md +110 -0
- package/vendor/ts-morph/docs/emitting.md +151 -0
- package/vendor/ts-morph/docs/index.md +25 -0
- package/vendor/ts-morph/docs/manipulation/code-writer.md +20 -0
- package/vendor/ts-morph/docs/manipulation/formatting.md +76 -0
- package/vendor/ts-morph/docs/manipulation/index.md +136 -0
- package/vendor/ts-morph/docs/manipulation/order.md +14 -0
- package/vendor/ts-morph/docs/manipulation/performance.md +222 -0
- package/vendor/ts-morph/docs/manipulation/removing.md +31 -0
- package/vendor/ts-morph/docs/manipulation/renaming.md +106 -0
- package/vendor/ts-morph/docs/manipulation/settings.md +76 -0
- package/vendor/ts-morph/docs/manipulation/structures.md +117 -0
- package/vendor/ts-morph/docs/manipulation/transforms.md +84 -0
- package/vendor/ts-morph/docs/metrics/performance.json +4 -0
- package/vendor/ts-morph/docs/navigation/ambient-modules.md +22 -0
- package/vendor/ts-morph/docs/navigation/compiler-nodes.md +82 -0
- package/vendor/ts-morph/docs/navigation/directories.md +287 -0
- package/vendor/ts-morph/docs/navigation/example.md +50 -0
- package/vendor/ts-morph/docs/navigation/finding-references.md +53 -0
- package/vendor/ts-morph/docs/navigation/getting-source-files.md +59 -0
- package/vendor/ts-morph/docs/navigation/images/getChildrenVsForEachChild.gif +0 -0
- package/vendor/ts-morph/docs/navigation/index.md +94 -0
- package/vendor/ts-morph/docs/navigation/language-service.md +23 -0
- package/vendor/ts-morph/docs/navigation/program.md +25 -0
- package/vendor/ts-morph/docs/navigation/type-checker.md +33 -0
- package/vendor/ts-morph/docs/setup/adding-source-files.md +145 -0
- package/vendor/ts-morph/docs/setup/ast-viewers.md +46 -0
- package/vendor/ts-morph/docs/setup/diagnostics.md +109 -0
- package/vendor/ts-morph/docs/setup/file-system.md +106 -0
- package/vendor/ts-morph/docs/setup/images/atom-ast.png +0 -0
- package/vendor/ts-morph/docs/setup/images/atom-ast_small.png +0 -0
- package/vendor/ts-morph/docs/setup/images/atom-command-palette.png +0 -0
- package/vendor/ts-morph/docs/setup/images/atom-file.png +0 -0
- package/vendor/ts-morph/docs/setup/images/ts-ast-viewer.png +0 -0
- package/vendor/ts-morph/docs/setup/index.md +94 -0
- package/vendor/ts-morph/docs/utilities.md +55 -0
- package/vendor/ts-morph/dprint.json +23 -0
- package/vendor/ts-morph/package.json +30 -0
- package/vendor/ts-morph/packages/bootstrap/LICENSE +21 -0
- package/vendor/ts-morph/packages/bootstrap/lib/ts-morph-bootstrap.d.ts +397 -0
- package/vendor/ts-morph/packages/bootstrap/package.json +46 -0
- package/vendor/ts-morph/packages/bootstrap/readme.md +200 -0
- package/vendor/ts-morph/packages/common/LICENSE +21 -0
- package/vendor/ts-morph/packages/common/lib/ts-morph-common.d.ts +1082 -0
- package/vendor/ts-morph/packages/common/lib/typescript.d.ts +11439 -0
- package/vendor/ts-morph/packages/common/package.json +65 -0
- package/vendor/ts-morph/packages/common/readme.md +5 -0
- package/vendor/ts-morph/packages/scripts/changeTypeScriptVersion.ts +28 -0
- package/vendor/ts-morph/packages/scripts/createDeclarationProject.ts +47 -0
- package/vendor/ts-morph/packages/scripts/deps.ts +2 -0
- package/vendor/ts-morph/packages/scripts/execScript.ts +31 -0
- package/vendor/ts-morph/packages/scripts/folders.ts +11 -0
- package/vendor/ts-morph/packages/scripts/getDevCompilerVersions.ts +19 -0
- package/vendor/ts-morph/packages/scripts/mod.ts +7 -0
- package/vendor/ts-morph/packages/scripts/utils/Memoize.ts +36 -0
- package/vendor/ts-morph/packages/scripts/utils/forEachTypeText.ts +23 -0
- package/vendor/ts-morph/packages/scripts/utils/makeConstructorsPrivate.ts +26 -0
- package/vendor/ts-morph/packages/scripts/utils/mod.ts +4 -0
- package/vendor/ts-morph/packages/scripts/utils/printDiagnostics.ts +10 -0
- package/vendor/ts-morph/packages/ts-morph/LICENSE +21 -0
- package/vendor/ts-morph/packages/ts-morph/lib/ts-morph.d.ts +11198 -0
- package/vendor/ts-morph/packages/ts-morph/package.json +78 -0
- package/vendor/ts-morph/packages/ts-morph/readme.md +111 -0
- package/vendor/ts-morph/readme.md +14 -0
- package/vendor/ts-morph/rfcs/README.md +13 -0
- package/vendor/ts-morph/rfcs/RFC-0001 - Inserting Into Statements Handling Comments.md +181 -0
- package/vendor/ts-morph/tsconfig.common.json +17 -0
- 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
|
+
}
|