fcis 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -703,4 +703,348 @@ describe('Integration: Full Pipeline', () => {
703
703
  expect(computeResult?.classification).toBe('pure')
704
704
  })
705
705
  })
706
+
707
+ describe('checkForConditionals scoping', () => {
708
+ it('should NOT detect conditionals inside nested callbacks', () => {
709
+ const project = createTestProject({
710
+ '/src/no-leak.ts': `
711
+ function outer() {
712
+ const x = items.map(item => item.active ? item.name : 'unknown')
713
+ const y = x.length
714
+ const z = y + 1
715
+ return z
716
+ }
717
+ `,
718
+ })
719
+
720
+ const sourceFile = project.getSourceFile('/src/no-leak.ts')
721
+ if (!sourceFile) throw new Error('File not found')
722
+
723
+ const functions = extractFunctions(sourceFile)
724
+ const outer = functions.find(f => f.name === 'outer')
725
+
726
+ // The ternary is inside the callback, not in outer itself
727
+ expect(outer?.hasConditionals).toBe(false)
728
+ })
729
+
730
+ it('should still detect conditionals in the function itself', () => {
731
+ const project = createTestProject({
732
+ '/src/has-conditional.ts': `
733
+ function outer() {
734
+ if (items.length === 0) return []
735
+ const result = items
736
+ const final = result
737
+ return final
738
+ }
739
+ `,
740
+ })
741
+
742
+ const sourceFile = project.getSourceFile('/src/has-conditional.ts')
743
+ if (!sourceFile) throw new Error('File not found')
744
+
745
+ const functions = extractFunctions(sourceFile)
746
+ const outer = functions.find(f => f.name === 'outer')
747
+
748
+ expect(outer?.hasConditionals).toBe(true)
749
+ })
750
+
751
+ it('should detect conditionals in callback but not leak to parent', () => {
752
+ const project = createTestProject({
753
+ '/src/callback-conditional.ts': `
754
+ function process() {
755
+ const results = items.forEach(item => {
756
+ if (item.active) {
757
+ doSomething(item)
758
+ }
759
+ logItem(item)
760
+ saveItem(item)
761
+ })
762
+ const x = 1
763
+ const y = 2
764
+ return results
765
+ }
766
+ `,
767
+ })
768
+
769
+ const sourceFile = project.getSourceFile('/src/callback-conditional.ts')
770
+ if (!sourceFile) throw new Error('File not found')
771
+
772
+ const functions = extractFunctions(sourceFile)
773
+ const process = functions.find(f => f.name === 'process')
774
+ const callback = functions.find(f => f.parentContext === 'forEach')
775
+
776
+ // Parent should NOT have conditionals (the if is in the callback)
777
+ expect(process?.hasConditionals).toBe(false)
778
+ // Callback SHOULD have conditionals
779
+ expect(callback?.hasConditionals).toBe(true)
780
+ })
781
+
782
+ it('should handle switch statements in callbacks without leaking', () => {
783
+ const project = createTestProject({
784
+ '/src/switch-callback.ts': `
785
+ function handleItems() {
786
+ items.map(item => {
787
+ switch (item.type) {
788
+ case 'a': return 1
789
+ case 'b': return 2
790
+ default: return 0
791
+ }
792
+ })
793
+ const a = 1
794
+ const b = 2
795
+ return a + b
796
+ }
797
+ `,
798
+ })
799
+
800
+ const sourceFile = project.getSourceFile('/src/switch-callback.ts')
801
+ if (!sourceFile) throw new Error('File not found')
802
+
803
+ const functions = extractFunctions(sourceFile)
804
+ const handleItems = functions.find(f => f.name === 'handleItems')
805
+ const callback = functions.find(f => f.parentContext === 'map')
806
+
807
+ expect(handleItems?.hasConditionals).toBe(false)
808
+ expect(callback?.hasConditionals).toBe(true)
809
+ })
810
+ })
811
+
812
+ describe('inline callback detection', () => {
813
+ it('should mark arrow functions passed to map as inline callbacks', () => {
814
+ const project = createTestProject({
815
+ '/src/map-callback.ts': `
816
+ const result = items.map(item => item.value)
817
+ `,
818
+ })
819
+
820
+ const sourceFile = project.getSourceFile('/src/map-callback.ts')
821
+ if (!sourceFile) throw new Error('File not found')
822
+
823
+ const functions = extractFunctions(sourceFile)
824
+ const callback = functions.find(f => f.parentContext === 'map')
825
+
826
+ expect(callback?.isInlineCallback).toBe(true)
827
+ expect(callback?.enclosingFunctionStartLine).toBeNull() // module-scope
828
+ })
829
+
830
+ it('should link nested callbacks to their enclosing function', () => {
831
+ const project = createTestProject({
832
+ '/src/nested-callback.ts': `
833
+ function outer() {
834
+ items.forEach(item => {
835
+ console.log(item)
836
+ doSomething(item)
837
+ saveItem(item)
838
+ })
839
+ const x = 1
840
+ const y = 2
841
+ return x + y
842
+ }
843
+ `,
844
+ })
845
+
846
+ const sourceFile = project.getSourceFile('/src/nested-callback.ts')
847
+ if (!sourceFile) throw new Error('File not found')
848
+
849
+ const functions = extractFunctions(sourceFile)
850
+ const outer = functions.find(f => f.name === 'outer')
851
+ const callback = functions.find(f => f.parentContext === 'forEach')
852
+
853
+ expect(callback?.isInlineCallback).toBe(true)
854
+ expect(callback?.enclosingFunctionStartLine).toBe(outer?.startLine)
855
+ })
856
+
857
+ it('should NOT mark callbacks to unknown methods as inline', () => {
858
+ const project = createTestProject({
859
+ '/src/custom-method.ts': `
860
+ const result = customMethod(item => item.value)
861
+ `,
862
+ })
863
+
864
+ const sourceFile = project.getSourceFile('/src/custom-method.ts')
865
+ if (!sourceFile) throw new Error('File not found')
866
+
867
+ const functions = extractFunctions(sourceFile)
868
+ const callback = functions.find(f => f.parentContext === 'customMethod')
869
+
870
+ expect(callback?.isInlineCallback).toBe(false)
871
+ expect(callback?.enclosingFunctionStartLine).toBeNull()
872
+ })
873
+
874
+ it('should NOT mark tRPC handlers as inline callbacks', () => {
875
+ const project = createTestProject({
876
+ '/src/trpc-router.ts': `
877
+ export const router = createRouter({
878
+ getUser: query(async ({ ctx }) => {
879
+ const user = await ctx.db.user.findFirst()
880
+ const formatted = formatUser(user)
881
+ return formatted
882
+ }),
883
+ })
884
+ `,
885
+ })
886
+
887
+ const sourceFile = project.getSourceFile('/src/trpc-router.ts')
888
+ if (!sourceFile) throw new Error('File not found')
889
+
890
+ const functions = extractFunctions(sourceFile)
891
+ const handler = functions.find(f => f.parentContext === 'query')
892
+
893
+ expect(handler?.isInlineCallback).toBe(false)
894
+ })
895
+
896
+ it('should handle deeply nested callbacks with correct enclosing function', () => {
897
+ const project = createTestProject({
898
+ '/src/deeply-nested.ts': `
899
+ function process() {
900
+ items.forEach(item => {
901
+ item.tags.map(tag => tag.name)
902
+ doSomething(item)
903
+ logItem(item)
904
+ })
905
+ const a = 1
906
+ const b = 2
907
+ return a + b
908
+ }
909
+ `,
910
+ })
911
+
912
+ const sourceFile = project.getSourceFile('/src/deeply-nested.ts')
913
+ if (!sourceFile) throw new Error('File not found')
914
+
915
+ const functions = extractFunctions(sourceFile)
916
+ const process = functions.find(f => f.name === 'process')
917
+ const forEachCb = functions.find(f => f.parentContext === 'forEach')
918
+ const mapCb = functions.find(f => f.parentContext === 'map')
919
+
920
+ // forEach callback links to process
921
+ expect(forEachCb?.isInlineCallback).toBe(true)
922
+ expect(forEachCb?.enclosingFunctionStartLine).toBe(process?.startLine)
923
+
924
+ // map callback's enclosing function is the forEach callback
925
+ expect(mapCb?.isInlineCallback).toBe(true)
926
+ expect(mapCb?.enclosingFunctionStartLine).toBe(forEachCb?.startLine)
927
+ })
928
+
929
+ it('should mark filter, reduce, find, some, every as inline callbacks', () => {
930
+ const project = createTestProject({
931
+ '/src/array-methods.ts': `
932
+ function processItems() {
933
+ const filtered = items.filter(x => x.active)
934
+ const reduced = items.reduce((acc, x) => acc + x.value, 0)
935
+ const found = items.find(x => x.id === 1)
936
+ const hasAny = items.some(x => x.valid)
937
+ const allValid = items.every(x => x.ok)
938
+ return { filtered, reduced, found, hasAny, allValid }
939
+ }
940
+ `,
941
+ })
942
+
943
+ const sourceFile = project.getSourceFile('/src/array-methods.ts')
944
+ if (!sourceFile) throw new Error('File not found')
945
+
946
+ const functions = extractFunctions(sourceFile)
947
+
948
+ const filterCb = functions.find(f => f.parentContext === 'filter')
949
+ const reduceCb = functions.find(f => f.parentContext === 'reduce')
950
+ const findCb = functions.find(f => f.parentContext === 'find')
951
+ const someCb = functions.find(f => f.parentContext === 'some')
952
+ const everyCb = functions.find(f => f.parentContext === 'every')
953
+
954
+ expect(filterCb?.isInlineCallback).toBe(true)
955
+ expect(reduceCb?.isInlineCallback).toBe(true)
956
+ expect(findCb?.isInlineCallback).toBe(true)
957
+ expect(someCb?.isInlineCallback).toBe(true)
958
+ expect(everyCb?.isInlineCallback).toBe(true)
959
+ })
960
+
961
+ it('should mark Promise methods as inline callbacks', () => {
962
+ const project = createTestProject({
963
+ '/src/promise-methods.ts': `
964
+ function fetchData() {
965
+ return fetch('/api')
966
+ .then(res => res.json())
967
+ .catch(err => handleError(err))
968
+ .finally(() => cleanup())
969
+ }
970
+ `,
971
+ })
972
+
973
+ const sourceFile = project.getSourceFile('/src/promise-methods.ts')
974
+ if (!sourceFile) throw new Error('File not found')
975
+
976
+ const functions = extractFunctions(sourceFile)
977
+
978
+ const thenCb = functions.find(f => f.parentContext === 'then')
979
+ const catchCb = functions.find(f => f.parentContext === 'catch')
980
+ const finallyCb = functions.find(f => f.parentContext === 'finally')
981
+
982
+ expect(thenCb?.isInlineCallback).toBe(true)
983
+ expect(catchCb?.isInlineCallback).toBe(true)
984
+ expect(finallyCb?.isInlineCallback).toBe(true)
985
+ })
986
+
987
+ it('should NOT mark mutation/subscription as inline callbacks', () => {
988
+ const project = createTestProject({
989
+ '/src/trpc-handlers.ts': `
990
+ export const router = {
991
+ create: mutation(async ({ input }) => {
992
+ const result = await db.create(input)
993
+ const formatted = format(result)
994
+ return formatted
995
+ }),
996
+ onUpdate: subscription(({ ctx }) => {
997
+ return observable(emit => {
998
+ ctx.ee.on('update', emit)
999
+ })
1000
+ }),
1001
+ }
1002
+ `,
1003
+ })
1004
+
1005
+ const sourceFile = project.getSourceFile('/src/trpc-handlers.ts')
1006
+ if (!sourceFile) throw new Error('File not found')
1007
+
1008
+ const functions = extractFunctions(sourceFile)
1009
+
1010
+ const mutationCb = functions.find(f => f.parentContext === 'mutation')
1011
+ const subscriptionCb = functions.find(
1012
+ f => f.parentContext === 'subscription',
1013
+ )
1014
+
1015
+ expect(mutationCb?.isInlineCallback).toBe(false)
1016
+ expect(subscriptionCb?.isInlineCallback).toBe(false)
1017
+ })
1018
+
1019
+ it('should set isInlineCallback false for regular named functions', () => {
1020
+ const project = createTestProject({
1021
+ '/src/regular-functions.ts': `
1022
+ function regularFunction() {
1023
+ const x = 1
1024
+ const y = 2
1025
+ return x + y
1026
+ }
1027
+
1028
+ const arrowFunction = () => {
1029
+ const a = 1
1030
+ const b = 2
1031
+ return a + b
1032
+ }
1033
+ `,
1034
+ })
1035
+
1036
+ const sourceFile = project.getSourceFile('/src/regular-functions.ts')
1037
+ if (!sourceFile) throw new Error('File not found')
1038
+
1039
+ const functions = extractFunctions(sourceFile)
1040
+
1041
+ const regular = functions.find(f => f.name === 'regularFunction')
1042
+ const arrow = functions.find(f => f.name === 'arrowFunction')
1043
+
1044
+ expect(regular?.isInlineCallback).toBe(false)
1045
+ expect(regular?.enclosingFunctionStartLine).toBeNull()
1046
+ expect(arrow?.isInlineCallback).toBe(false)
1047
+ expect(arrow?.enclosingFunctionStartLine).toBeNull()
1048
+ })
1049
+ })
706
1050
  })
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Report Console Tests
3
+ *
4
+ * Tests for the console reporting shell layer.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest'
8
+
9
+ import { relativizeDirectoryPaths } from '../src/reporting/report-console.js'
10
+ import type { DirectoryScore } from '../src/types.js'
11
+
12
+ /**
13
+ * Helper to create a directory score for testing
14
+ */
15
+ function createDirScore(
16
+ dirPath: string,
17
+ overrides: Partial<DirectoryScore> = {},
18
+ ): DirectoryScore {
19
+ return {
20
+ dirPath,
21
+ purity: 50,
22
+ impurityQuality: 60,
23
+ health: 75,
24
+ pureCount: 5,
25
+ impureCount: 5,
26
+ excludedCount: 0,
27
+ statusBreakdown: { ok: 7, review: 2, refactor: 1 },
28
+ pureLineCount: 50,
29
+ impureLineCount: 100,
30
+ fileScores: [],
31
+ ...overrides,
32
+ }
33
+ }
34
+
35
+ describe('relativizeDirectoryPaths', () => {
36
+ it('should convert absolute paths to relative paths', () => {
37
+ const dirs = [
38
+ createDirScore('/project/src/services'),
39
+ createDirScore('/project/src/utils'),
40
+ ]
41
+
42
+ const result = relativizeDirectoryPaths(dirs, '/project')
43
+
44
+ expect(result[0]?.dirPath).toBe('src/services')
45
+ expect(result[1]?.dirPath).toBe('src/utils')
46
+ })
47
+
48
+ it('should normalize backslashes to forward slashes', () => {
49
+ const dirs = [createDirScore('/project/src\\services\\auth')]
50
+
51
+ const result = relativizeDirectoryPaths(dirs, '/project')
52
+
53
+ expect(result[0]?.dirPath).toBe('src/services/auth')
54
+ })
55
+
56
+ it('should preserve other directory properties', () => {
57
+ const dirs = [
58
+ createDirScore('/project/src', {
59
+ pureCount: 10,
60
+ impureCount: 5,
61
+ health: 80,
62
+ purity: 66.67,
63
+ impurityQuality: 75,
64
+ }),
65
+ ]
66
+
67
+ const result = relativizeDirectoryPaths(dirs, '/project')
68
+
69
+ expect(result[0]?.dirPath).toBe('src')
70
+ expect(result[0]?.pureCount).toBe(10)
71
+ expect(result[0]?.impureCount).toBe(5)
72
+ expect(result[0]?.health).toBe(80)
73
+ expect(result[0]?.purity).toBe(66.67)
74
+ expect(result[0]?.impurityQuality).toBe(75)
75
+ })
76
+
77
+ it('should handle empty array', () => {
78
+ const result = relativizeDirectoryPaths([], '/project')
79
+
80
+ expect(result).toEqual([])
81
+ })
82
+
83
+ it('should handle paths that are already relative', () => {
84
+ const dirs = [createDirScore('src/services')]
85
+
86
+ const result = relativizeDirectoryPaths(dirs, '/project')
87
+
88
+ // path.relative will return the path as-is or with ../ prefix
89
+ // depending on the relationship
90
+ expect(result[0]?.dirPath).toBeDefined()
91
+ })
92
+ })