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,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the full analysis pipeline using ts-morph's in-memory file system.
|
|
5
|
+
* These tests verify that extraction, detection, classification, and scoring
|
|
6
|
+
* work correctly together on real TypeScript ASTs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import { Project } from 'ts-morph'
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
extractFunctions,
|
|
14
|
+
extractImports,
|
|
15
|
+
isTypeOnlyFile,
|
|
16
|
+
} from '../src/extraction/extract-functions.js'
|
|
17
|
+
import {
|
|
18
|
+
detectMarkers,
|
|
19
|
+
createDetectionContext,
|
|
20
|
+
} from '../src/detection/detect-markers.js'
|
|
21
|
+
import {
|
|
22
|
+
classifyFunction,
|
|
23
|
+
shouldExcludeFunction,
|
|
24
|
+
createClassifiedFunction,
|
|
25
|
+
} from '../src/classification/classifier.js'
|
|
26
|
+
import { computeQualityScore } from '../src/classification/quality-scorer.js'
|
|
27
|
+
import { deriveStatus } from '../src/classification/derive-status.js'
|
|
28
|
+
import {
|
|
29
|
+
scoreFile,
|
|
30
|
+
scoreProject,
|
|
31
|
+
scoreDirectory,
|
|
32
|
+
} from '../src/scoring/scorer.js'
|
|
33
|
+
import type { ClassifiedFunction } from '../src/types.js'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Helper to create an in-memory ts-morph project with test files
|
|
37
|
+
*/
|
|
38
|
+
function createTestProject(files: Record<string, string>): Project {
|
|
39
|
+
const project = new Project({
|
|
40
|
+
useInMemoryFileSystem: true,
|
|
41
|
+
compilerOptions: {
|
|
42
|
+
target: 99, // ESNext
|
|
43
|
+
module: 99, // ESNext
|
|
44
|
+
strict: true,
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
for (const [path, content] of Object.entries(files)) {
|
|
49
|
+
project.createSourceFile(path, content)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return project
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Helper to run the full analysis pipeline on a source file
|
|
57
|
+
*/
|
|
58
|
+
function analyzeSourceFile(
|
|
59
|
+
project: Project,
|
|
60
|
+
filePath: string,
|
|
61
|
+
): ClassifiedFunction[] {
|
|
62
|
+
const sourceFile = project.getSourceFile(filePath)
|
|
63
|
+
if (!sourceFile) {
|
|
64
|
+
throw new Error(`File not found: ${filePath}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const functions = extractFunctions(sourceFile)
|
|
68
|
+
const imports = extractImports(sourceFile)
|
|
69
|
+
const context = createDetectionContext(imports)
|
|
70
|
+
|
|
71
|
+
const classifiedFunctions: ClassifiedFunction[] = []
|
|
72
|
+
|
|
73
|
+
for (const fn of functions) {
|
|
74
|
+
if (shouldExcludeFunction(fn)) {
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const markers = detectMarkers(fn, context)
|
|
79
|
+
const classification = classifyFunction(fn, markers)
|
|
80
|
+
|
|
81
|
+
let qualityScore: number | null = null
|
|
82
|
+
if (classification === 'impure') {
|
|
83
|
+
qualityScore = computeQualityScore(fn, markers, context)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const status = deriveStatus(classification, qualityScore)
|
|
87
|
+
|
|
88
|
+
classifiedFunctions.push(
|
|
89
|
+
createClassifiedFunction(fn, markers, qualityScore, status),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return classifiedFunctions
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('Integration: Full Pipeline', () => {
|
|
97
|
+
describe('pure function detection', () => {
|
|
98
|
+
it('should classify a pure function with no I/O', () => {
|
|
99
|
+
const project = createTestProject({
|
|
100
|
+
'/src/math.ts': `
|
|
101
|
+
export function add(a: number, b: number): number {
|
|
102
|
+
const result = a + b
|
|
103
|
+
const doubled = result * 2
|
|
104
|
+
const final = doubled - result
|
|
105
|
+
return final
|
|
106
|
+
}
|
|
107
|
+
`,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const functions = analyzeSourceFile(project, '/src/math.ts')
|
|
111
|
+
|
|
112
|
+
expect(functions).toHaveLength(1)
|
|
113
|
+
expect(functions[0]?.classification).toBe('pure')
|
|
114
|
+
expect(functions[0]?.status).toBe('ok')
|
|
115
|
+
expect(functions[0]?.qualityScore).toBeNull()
|
|
116
|
+
expect(functions[0]?.markers).toHaveLength(0)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('should classify async function without await as pure', () => {
|
|
120
|
+
const project = createTestProject({
|
|
121
|
+
'/src/async-pure.ts': `
|
|
122
|
+
export async function computeAsync(data: number[]): Promise<number> {
|
|
123
|
+
const sum = data.reduce((a, b) => a + b, 0)
|
|
124
|
+
const avg = sum / data.length
|
|
125
|
+
return avg
|
|
126
|
+
}
|
|
127
|
+
`,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const functions = analyzeSourceFile(project, '/src/async-pure.ts')
|
|
131
|
+
|
|
132
|
+
expect(functions).toHaveLength(1)
|
|
133
|
+
expect(functions[0]?.classification).toBe('pure')
|
|
134
|
+
expect(functions[0]?.isAsync).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('should classify predicate functions as pure', () => {
|
|
138
|
+
const project = createTestProject({
|
|
139
|
+
'/src/predicates.ts': `
|
|
140
|
+
export function isValid(value: unknown): boolean {
|
|
141
|
+
if (value === null || value === undefined) {
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
const isString = typeof value === 'string'
|
|
145
|
+
const hasLength = isString && value.length > 0
|
|
146
|
+
return hasLength
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function hasPermission(user: { roles: string[] }, required: string): boolean {
|
|
150
|
+
const roles = user.roles
|
|
151
|
+
const hasRole = roles.includes(required)
|
|
152
|
+
const isAdmin = roles.includes('admin')
|
|
153
|
+
return hasRole || isAdmin
|
|
154
|
+
}
|
|
155
|
+
`,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const functions = analyzeSourceFile(project, '/src/predicates.ts')
|
|
159
|
+
|
|
160
|
+
expect(functions).toHaveLength(2)
|
|
161
|
+
expect(functions.every(f => f.classification === 'pure')).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('impure function detection', () => {
|
|
166
|
+
it('should classify function with database call as impure', () => {
|
|
167
|
+
const project = createTestProject({
|
|
168
|
+
'/src/users.ts': `
|
|
169
|
+
export async function getUser(id: string) {
|
|
170
|
+
const whereClause = { id }
|
|
171
|
+
const user = await db.user.findFirst({ where: whereClause })
|
|
172
|
+
const result = user ? user : null
|
|
173
|
+
return result
|
|
174
|
+
}
|
|
175
|
+
`,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const functions = analyzeSourceFile(project, '/src/users.ts')
|
|
179
|
+
|
|
180
|
+
expect(functions).toHaveLength(1)
|
|
181
|
+
expect(functions[0]?.classification).toBe('impure')
|
|
182
|
+
expect(functions[0]?.markers.some(m => m.type === 'database-call')).toBe(
|
|
183
|
+
true,
|
|
184
|
+
)
|
|
185
|
+
expect(
|
|
186
|
+
functions[0]?.markers.some(m => m.type === 'await-expression'),
|
|
187
|
+
).toBe(true)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('should classify function with fetch call as impure', () => {
|
|
191
|
+
const project = createTestProject({
|
|
192
|
+
'/src/api.ts': `
|
|
193
|
+
export async function fetchData(url: string) {
|
|
194
|
+
const response = await fetch(url)
|
|
195
|
+
const data = await response.json()
|
|
196
|
+
return data
|
|
197
|
+
}
|
|
198
|
+
`,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const functions = analyzeSourceFile(project, '/src/api.ts')
|
|
202
|
+
|
|
203
|
+
expect(functions).toHaveLength(1)
|
|
204
|
+
expect(functions[0]?.classification).toBe('impure')
|
|
205
|
+
expect(functions[0]?.markers.some(m => m.type === 'network-fetch')).toBe(
|
|
206
|
+
true,
|
|
207
|
+
)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('should classify function with console.log as impure', () => {
|
|
211
|
+
const project = createTestProject({
|
|
212
|
+
'/src/logger.ts': `
|
|
213
|
+
export function logMessage(message: string): void {
|
|
214
|
+
const timestamp = new Date().toISOString()
|
|
215
|
+
const formatted = \`[\${timestamp}] \${message}\`
|
|
216
|
+
const output = formatted.trim()
|
|
217
|
+
console.log(output)
|
|
218
|
+
}
|
|
219
|
+
`,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const functions = analyzeSourceFile(project, '/src/logger.ts')
|
|
223
|
+
|
|
224
|
+
expect(functions).toHaveLength(1)
|
|
225
|
+
expect(functions[0]?.classification).toBe('impure')
|
|
226
|
+
expect(functions[0]?.markers.some(m => m.type === 'console-log')).toBe(
|
|
227
|
+
true,
|
|
228
|
+
)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('should classify function with process.env access as impure', () => {
|
|
232
|
+
const project = createTestProject({
|
|
233
|
+
'/src/config.ts': `
|
|
234
|
+
export function getConfig() {
|
|
235
|
+
const apiUrl = process.env.API_URL
|
|
236
|
+
const debug = process.env.DEBUG === 'true'
|
|
237
|
+
return { apiUrl, debug }
|
|
238
|
+
}
|
|
239
|
+
`,
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const functions = analyzeSourceFile(project, '/src/config.ts')
|
|
243
|
+
|
|
244
|
+
expect(functions).toHaveLength(1)
|
|
245
|
+
expect(functions[0]?.classification).toBe('impure')
|
|
246
|
+
expect(functions[0]?.markers.some(m => m.type === 'env-access')).toBe(
|
|
247
|
+
true,
|
|
248
|
+
)
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('quality scoring', () => {
|
|
253
|
+
it('should give high quality score to well-structured impure function', () => {
|
|
254
|
+
const project = createTestProject({
|
|
255
|
+
'/src/service.ts': `
|
|
256
|
+
import { planAcceptInvite } from './domain.pure'
|
|
257
|
+
|
|
258
|
+
export async function handleAcceptInvite(inviteId: string) {
|
|
259
|
+
// GATHER
|
|
260
|
+
const invite = await db.invitation.findFirst({ where: { id: inviteId } })
|
|
261
|
+
const org = await db.organization.findFirst({ where: { id: invite.orgId } })
|
|
262
|
+
|
|
263
|
+
// DECIDE (pure)
|
|
264
|
+
const plan = planAcceptInvite(invite, org)
|
|
265
|
+
|
|
266
|
+
// EXECUTE
|
|
267
|
+
if (plan.shouldAccept) {
|
|
268
|
+
await db.member.create({ data: plan.memberData })
|
|
269
|
+
await db.invitation.update({ where: { id: inviteId }, data: { accepted: true } })
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return plan.result
|
|
273
|
+
}
|
|
274
|
+
`,
|
|
275
|
+
'/src/domain.pure.ts': `
|
|
276
|
+
export function planAcceptInvite(invite: any, org: any) {
|
|
277
|
+
return { shouldAccept: true, memberData: {}, result: 'accepted' }
|
|
278
|
+
}
|
|
279
|
+
`,
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const functions = analyzeSourceFile(project, '/src/service.ts')
|
|
283
|
+
|
|
284
|
+
expect(functions).toHaveLength(1)
|
|
285
|
+
expect(functions[0]?.classification).toBe('impure')
|
|
286
|
+
// Should have decent quality due to calling pure function
|
|
287
|
+
expect(functions[0]?.qualityScore).toBeGreaterThanOrEqual(50)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('should give low quality score to tangled impure function', () => {
|
|
291
|
+
const project = createTestProject({
|
|
292
|
+
'/src/tangled.ts': `
|
|
293
|
+
export async function processOrder(orderId: string) {
|
|
294
|
+
const order = await db.order.findFirst({ where: { id: orderId } })
|
|
295
|
+
console.log('Processing order', order)
|
|
296
|
+
|
|
297
|
+
if (order.status === 'pending') {
|
|
298
|
+
const user = await db.user.findFirst({ where: { id: order.userId } })
|
|
299
|
+
console.log('Found user', user)
|
|
300
|
+
|
|
301
|
+
const items = await db.orderItem.findMany({ where: { orderId } })
|
|
302
|
+
|
|
303
|
+
for (const item of items) {
|
|
304
|
+
const product = await db.product.findFirst({ where: { id: item.productId } })
|
|
305
|
+
console.log('Processing item', item, product)
|
|
306
|
+
|
|
307
|
+
if (product.stock < item.quantity) {
|
|
308
|
+
await fetch('/api/restock', { method: 'POST', body: JSON.stringify({ productId: product.id }) })
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
await db.product.update({ where: { id: product.id }, data: { stock: product.stock - item.quantity } })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
await db.order.update({ where: { id: orderId }, data: { status: 'processed' } })
|
|
315
|
+
console.log('Order processed')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return order
|
|
319
|
+
}
|
|
320
|
+
`,
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
const functions = analyzeSourceFile(project, '/src/tangled.ts')
|
|
324
|
+
|
|
325
|
+
expect(functions).toHaveLength(1)
|
|
326
|
+
expect(functions[0]?.classification).toBe('impure')
|
|
327
|
+
expect(functions[0]?.qualityScore).toBeLessThan(50)
|
|
328
|
+
expect(functions[0]?.status).toBe('refactor')
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
describe('file scoring', () => {
|
|
333
|
+
it('should score a file with mixed functions', () => {
|
|
334
|
+
const project = createTestProject({
|
|
335
|
+
'/src/mixed.ts': `
|
|
336
|
+
// Pure function
|
|
337
|
+
export function calculateTotal(items: { price: number }[]): number {
|
|
338
|
+
const prices = items.map(item => item.price)
|
|
339
|
+
const sum = prices.reduce((acc, price) => acc + price, 0)
|
|
340
|
+
const rounded = Math.round(sum * 100) / 100
|
|
341
|
+
return rounded
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Pure function
|
|
345
|
+
export function formatCurrency(amount: number): string {
|
|
346
|
+
const fixed = amount.toFixed(2)
|
|
347
|
+
const formatted = \`$\${fixed}\`
|
|
348
|
+
const trimmed = formatted.trim()
|
|
349
|
+
return trimmed
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Impure function
|
|
353
|
+
export async function saveOrder(order: any) {
|
|
354
|
+
const total = calculateTotal(order.items)
|
|
355
|
+
const formatted = formatCurrency(total)
|
|
356
|
+
const data = { ...order, total }
|
|
357
|
+
console.log('Saving order with total:', formatted)
|
|
358
|
+
await db.order.create({ data })
|
|
359
|
+
}
|
|
360
|
+
`,
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
const functions = analyzeSourceFile(project, '/src/mixed.ts')
|
|
364
|
+
const fileScore = scoreFile('/src/mixed.ts', functions)
|
|
365
|
+
|
|
366
|
+
expect(fileScore.pureCount).toBe(2)
|
|
367
|
+
expect(fileScore.impureCount).toBe(1)
|
|
368
|
+
expect(fileScore.purity).toBeCloseTo(66.67, 1)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('should handle type-only files', () => {
|
|
372
|
+
const project = createTestProject({
|
|
373
|
+
'/src/types.ts': `
|
|
374
|
+
export interface User {
|
|
375
|
+
id: string
|
|
376
|
+
name: string
|
|
377
|
+
email: string
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export type UserRole = 'admin' | 'user' | 'guest'
|
|
381
|
+
|
|
382
|
+
export enum Status {
|
|
383
|
+
Active = 'active',
|
|
384
|
+
Inactive = 'inactive',
|
|
385
|
+
}
|
|
386
|
+
`,
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
const sourceFile = project.getSourceFile('/src/types.ts')!
|
|
390
|
+
const isTypeOnly = isTypeOnlyFile(sourceFile)
|
|
391
|
+
|
|
392
|
+
expect(isTypeOnly).toBe(true)
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
describe('extraction edge cases', () => {
|
|
397
|
+
it('should extract arrow functions from variable declarations', () => {
|
|
398
|
+
const project = createTestProject({
|
|
399
|
+
'/src/arrows.ts': `
|
|
400
|
+
export const add = (a: number, b: number): number => {
|
|
401
|
+
const sum = a + b
|
|
402
|
+
const doubled = sum * 2
|
|
403
|
+
const result = doubled / 2
|
|
404
|
+
return result
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export const multiply = (a: number, b: number): number => {
|
|
408
|
+
const result = a * b
|
|
409
|
+
const squared = result * result
|
|
410
|
+
const final = Math.sqrt(squared)
|
|
411
|
+
return final
|
|
412
|
+
}
|
|
413
|
+
`,
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
const functions = analyzeSourceFile(project, '/src/arrows.ts')
|
|
417
|
+
|
|
418
|
+
expect(functions).toHaveLength(2)
|
|
419
|
+
expect(functions.every(f => f.kind === 'arrow')).toBe(true)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('should use variable name for arrow functions', () => {
|
|
423
|
+
const project = createTestProject({
|
|
424
|
+
'/src/handlers.ts': `
|
|
425
|
+
export const handleSubmit = async (data: FormData) => {
|
|
426
|
+
const validated = validateData(data)
|
|
427
|
+
const transformed = transformData(validated)
|
|
428
|
+
const result = processData(transformed)
|
|
429
|
+
return result
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export const validateInput = (input: string): boolean => {
|
|
433
|
+
const trimmed = input.trim()
|
|
434
|
+
const normalized = trimmed.toLowerCase()
|
|
435
|
+
const isValid = normalized.length > 0
|
|
436
|
+
return isValid
|
|
437
|
+
}
|
|
438
|
+
`,
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
const functions = analyzeSourceFile(project, '/src/handlers.ts')
|
|
442
|
+
|
|
443
|
+
expect(functions).toHaveLength(2)
|
|
444
|
+
expect(functions.map(f => f.name).sort()).toEqual([
|
|
445
|
+
'handleSubmit',
|
|
446
|
+
'validateInput',
|
|
447
|
+
])
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('should extract inline arrow functions passed as arguments', () => {
|
|
451
|
+
const project = createTestProject({
|
|
452
|
+
'/src/router.ts': `
|
|
453
|
+
import { createRouter } from './trpc'
|
|
454
|
+
|
|
455
|
+
export const userRouter = createRouter({
|
|
456
|
+
getUser: query(async ({ ctx, input }) => {
|
|
457
|
+
const userId = input.id
|
|
458
|
+
const user = await ctx.db.user.findUnique({ where: { id: userId } })
|
|
459
|
+
const formatted = formatUser(user)
|
|
460
|
+
return formatted
|
|
461
|
+
}),
|
|
462
|
+
|
|
463
|
+
updateUser: mutation(async ({ ctx, input }) => {
|
|
464
|
+
const validated = validateInput(input)
|
|
465
|
+
const updated = await ctx.db.user.update({
|
|
466
|
+
where: { id: input.id },
|
|
467
|
+
data: validated,
|
|
468
|
+
})
|
|
469
|
+
return updated
|
|
470
|
+
}),
|
|
471
|
+
})
|
|
472
|
+
`,
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
const functions = analyzeSourceFile(project, '/src/router.ts')
|
|
476
|
+
|
|
477
|
+
// Should find the two inline handlers (query and mutation)
|
|
478
|
+
expect(functions.length).toBeGreaterThanOrEqual(2)
|
|
479
|
+
|
|
480
|
+
// Check that we found handlers named after their method
|
|
481
|
+
const names = functions.map(f => f.name)
|
|
482
|
+
expect(names).toContain('query')
|
|
483
|
+
expect(names).toContain('mutation')
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('should extract class methods', () => {
|
|
487
|
+
const project = createTestProject({
|
|
488
|
+
'/src/class.ts': `
|
|
489
|
+
export class Calculator {
|
|
490
|
+
add(a: number, b: number): number {
|
|
491
|
+
const sum = a + b
|
|
492
|
+
const doubled = sum * 2
|
|
493
|
+
const result = doubled / 2
|
|
494
|
+
return result
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async fetchAndAdd(url: string, b: number): Promise<number> {
|
|
498
|
+
const response = await fetch(url)
|
|
499
|
+
const data = await response.json()
|
|
500
|
+
const a = data.value
|
|
501
|
+
const result = a + b
|
|
502
|
+
return result
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
`,
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
const functions = analyzeSourceFile(project, '/src/class.ts')
|
|
509
|
+
|
|
510
|
+
expect(functions).toHaveLength(2)
|
|
511
|
+
expect(
|
|
512
|
+
functions.some(f => f.kind === 'method' && f.classification === 'pure'),
|
|
513
|
+
).toBe(true)
|
|
514
|
+
expect(
|
|
515
|
+
functions.some(
|
|
516
|
+
f => f.kind === 'method' && f.classification === 'impure',
|
|
517
|
+
),
|
|
518
|
+
).toBe(true)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('should extract getters and setters', () => {
|
|
522
|
+
const project = createTestProject({
|
|
523
|
+
'/src/accessors.ts': `
|
|
524
|
+
export class Config {
|
|
525
|
+
private _value: string = ''
|
|
526
|
+
|
|
527
|
+
get value(): string {
|
|
528
|
+
console.log('Getting value')
|
|
529
|
+
return this._value
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
set value(newValue: string) {
|
|
533
|
+
this._value = newValue
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
`,
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
const sourceFile = project.getSourceFile('/src/accessors.ts')!
|
|
540
|
+
const functions = extractFunctions(sourceFile)
|
|
541
|
+
|
|
542
|
+
expect(functions.some(f => f.kind === 'getter')).toBe(true)
|
|
543
|
+
expect(functions.some(f => f.kind === 'setter')).toBe(true)
|
|
544
|
+
})
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
describe('full project scoring', () => {
|
|
548
|
+
it('should score a multi-file project', () => {
|
|
549
|
+
const project = createTestProject({
|
|
550
|
+
'/src/services/users.ts': `
|
|
551
|
+
export async function getUser(id: string) {
|
|
552
|
+
const whereClause = { id }
|
|
553
|
+
const user = await db.user.findFirst({ where: whereClause })
|
|
554
|
+
const result = user ? user : null
|
|
555
|
+
return result
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export async function createUser(data: any) {
|
|
559
|
+
const userData = { ...data }
|
|
560
|
+
const created = await db.user.create({ data: userData })
|
|
561
|
+
const result = created
|
|
562
|
+
return result
|
|
563
|
+
}
|
|
564
|
+
`,
|
|
565
|
+
'/src/utils/math.ts': `
|
|
566
|
+
export function add(a: number, b: number): number {
|
|
567
|
+
const sum = a + b
|
|
568
|
+
const result = sum
|
|
569
|
+
const final = result
|
|
570
|
+
return final
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function multiply(a: number, b: number): number {
|
|
574
|
+
const product = a * b
|
|
575
|
+
const result = product
|
|
576
|
+
const final = result
|
|
577
|
+
return final
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function divide(a: number, b: number): number {
|
|
581
|
+
if (b === 0) throw new Error('Division by zero')
|
|
582
|
+
const quotient = a / b
|
|
583
|
+
const result = quotient
|
|
584
|
+
return result
|
|
585
|
+
}
|
|
586
|
+
`,
|
|
587
|
+
'/src/utils/format.ts': `
|
|
588
|
+
export function formatDate(date: Date): string {
|
|
589
|
+
const iso = date.toISOString()
|
|
590
|
+
const parts = iso.split('T')
|
|
591
|
+
const datePart = parts[0]
|
|
592
|
+
return datePart
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export function formatCurrency(amount: number): string {
|
|
596
|
+
const fixed = amount.toFixed(2)
|
|
597
|
+
const formatted = \`$\${fixed}\`
|
|
598
|
+
const result = formatted
|
|
599
|
+
return result
|
|
600
|
+
}
|
|
601
|
+
`,
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
// Analyze each file
|
|
605
|
+
const servicesFunctions = analyzeSourceFile(
|
|
606
|
+
project,
|
|
607
|
+
'/src/services/users.ts',
|
|
608
|
+
)
|
|
609
|
+
const mathFunctions = analyzeSourceFile(project, '/src/utils/math.ts')
|
|
610
|
+
const formatFunctions = analyzeSourceFile(project, '/src/utils/format.ts')
|
|
611
|
+
|
|
612
|
+
// Score each file
|
|
613
|
+
const servicesScore = scoreFile(
|
|
614
|
+
'/src/services/users.ts',
|
|
615
|
+
servicesFunctions,
|
|
616
|
+
)
|
|
617
|
+
const mathScore = scoreFile('/src/utils/math.ts', mathFunctions)
|
|
618
|
+
const formatScore = scoreFile('/src/utils/format.ts', formatFunctions)
|
|
619
|
+
|
|
620
|
+
// Score directories
|
|
621
|
+
const servicesDir = scoreDirectory('/src/services', [servicesScore])
|
|
622
|
+
const utilsDir = scoreDirectory('/src/utils', [mathScore, formatScore])
|
|
623
|
+
|
|
624
|
+
// Score project
|
|
625
|
+
const projectScore = scoreProject([servicesDir, utilsDir], {
|
|
626
|
+
timestamp: '2024-01-01T00:00:00.000Z',
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
// Verify metrics
|
|
630
|
+
expect(projectScore.pureCount).toBe(5) // 3 math + 2 format
|
|
631
|
+
expect(projectScore.impureCount).toBe(2) // 2 services
|
|
632
|
+
expect(projectScore.purity).toBeCloseTo(71.43, 1) // 5/7
|
|
633
|
+
|
|
634
|
+
// Utils should have higher purity than services
|
|
635
|
+
expect(utilsDir.purity).toBe(100)
|
|
636
|
+
expect(servicesDir.purity).toBe(0)
|
|
637
|
+
})
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
describe('marker detection accuracy', () => {
|
|
641
|
+
it('should detect multiple marker types in one function', () => {
|
|
642
|
+
const project = createTestProject({
|
|
643
|
+
'/src/complex.ts': `
|
|
644
|
+
export async function complexOperation() {
|
|
645
|
+
console.log('Starting operation')
|
|
646
|
+
|
|
647
|
+
const config = process.env.CONFIG
|
|
648
|
+
const user = await db.user.findFirst({ where: { active: true } })
|
|
649
|
+
const external = await fetch('/api/data')
|
|
650
|
+
|
|
651
|
+
console.log('Operation complete')
|
|
652
|
+
return { config, user, external }
|
|
653
|
+
}
|
|
654
|
+
`,
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
const functions = analyzeSourceFile(project, '/src/complex.ts')
|
|
658
|
+
|
|
659
|
+
expect(functions).toHaveLength(1)
|
|
660
|
+
const markers = functions[0]!.markers
|
|
661
|
+
|
|
662
|
+
const markerTypes = new Set(markers.map(m => m.type))
|
|
663
|
+
expect(markerTypes.has('console-log')).toBe(true)
|
|
664
|
+
expect(markerTypes.has('env-access')).toBe(true)
|
|
665
|
+
expect(markerTypes.has('database-call')).toBe(true)
|
|
666
|
+
expect(markerTypes.has('network-fetch')).toBe(true)
|
|
667
|
+
expect(markerTypes.has('await-expression')).toBe(true)
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
it('should detect queue and event operations', () => {
|
|
671
|
+
const project = createTestProject({
|
|
672
|
+
'/src/events.ts': `
|
|
673
|
+
export async function processJob(job: any) {
|
|
674
|
+
const data = job.data
|
|
675
|
+
const result = computeResult(data)
|
|
676
|
+
const notification = { type: 'notification', data: result }
|
|
677
|
+
|
|
678
|
+
await queue.enqueue(notification)
|
|
679
|
+
emitter.emit('job-complete', { jobId: job.id, result })
|
|
680
|
+
|
|
681
|
+
return result
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function computeResult(data: any) {
|
|
685
|
+
const processed = true
|
|
686
|
+
const result = { processed, data }
|
|
687
|
+
const final = result
|
|
688
|
+
return final
|
|
689
|
+
}
|
|
690
|
+
`,
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
const functions = analyzeSourceFile(project, '/src/events.ts')
|
|
694
|
+
|
|
695
|
+
const processJob = functions.find(f => f.name === 'processJob')
|
|
696
|
+
expect(processJob?.classification).toBe('impure')
|
|
697
|
+
expect(processJob?.markers.some(m => m.type === 'queue-enqueue')).toBe(
|
|
698
|
+
true,
|
|
699
|
+
)
|
|
700
|
+
expect(processJob?.markers.some(m => m.type === 'event-emit')).toBe(true)
|
|
701
|
+
|
|
702
|
+
const computeResult = functions.find(f => f.name === 'computeResult')
|
|
703
|
+
expect(computeResult?.classification).toBe('pure')
|
|
704
|
+
})
|
|
705
|
+
})
|
|
706
|
+
})
|