@archora/core 1.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 (112) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +62 -0
  3. package/package.json +36 -0
  4. package/src/README.md +4 -0
  5. package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
  6. package/src/analyzer/__tests__/_paths.ts +8 -0
  7. package/src/analyzer/__tests__/analyze.test.ts +522 -0
  8. package/src/analyzer/__tests__/archDebt.test.ts +111 -0
  9. package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
  10. package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
  11. package/src/analyzer/__tests__/bundle.test.ts +191 -0
  12. package/src/analyzer/__tests__/classify.test.ts +99 -0
  13. package/src/analyzer/__tests__/contracts.test.ts +372 -0
  14. package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
  15. package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
  16. package/src/analyzer/__tests__/cycles.test.ts +74 -0
  17. package/src/analyzer/__tests__/detect.test.ts +62 -0
  18. package/src/analyzer/__tests__/discover.test.ts +68 -0
  19. package/src/analyzer/__tests__/displayId.test.ts +30 -0
  20. package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
  21. package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
  22. package/src/analyzer/__tests__/incremental.test.ts +154 -0
  23. package/src/analyzer/__tests__/layers.test.ts +87 -0
  24. package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
  25. package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
  26. package/src/analyzer/__tests__/metrics.test.ts +59 -0
  27. package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
  28. package/src/analyzer/__tests__/parsers.test.ts +187 -0
  29. package/src/analyzer/__tests__/reactParser.test.ts +93 -0
  30. package/src/analyzer/__tests__/recommendations.test.ts +171 -0
  31. package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
  32. package/src/analyzer/__tests__/resolve.test.ts +294 -0
  33. package/src/analyzer/__tests__/rsc.test.ts +130 -0
  34. package/src/analyzer/__tests__/signals.test.ts +316 -0
  35. package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
  36. package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
  37. package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
  38. package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
  39. package/src/analyzer/archDebt.ts +68 -0
  40. package/src/analyzer/asyncLifecycleRisk.ts +234 -0
  41. package/src/analyzer/buildGraph.ts +683 -0
  42. package/src/analyzer/bundle/analyzeBundle.ts +147 -0
  43. package/src/analyzer/bundle/index.ts +12 -0
  44. package/src/analyzer/bundle/parseStats.ts +152 -0
  45. package/src/analyzer/bundle/types.ts +85 -0
  46. package/src/analyzer/classify.ts +54 -0
  47. package/src/analyzer/contracts.ts +265 -0
  48. package/src/analyzer/cyclePatterns.ts +138 -0
  49. package/src/analyzer/cycles.ts +98 -0
  50. package/src/analyzer/detect.ts +34 -0
  51. package/src/analyzer/discover.ts +131 -0
  52. package/src/analyzer/displayId.ts +21 -0
  53. package/src/analyzer/entryPoints.ts +136 -0
  54. package/src/analyzer/feedbackArcSet.ts +332 -0
  55. package/src/analyzer/fileSource.ts +8 -0
  56. package/src/analyzer/hotZones.ts +17 -0
  57. package/src/analyzer/incremental.ts +455 -0
  58. package/src/analyzer/index.ts +444 -0
  59. package/src/analyzer/layers.ts +183 -0
  60. package/src/analyzer/loadAliases.ts +288 -0
  61. package/src/analyzer/memoryRisk.ts +345 -0
  62. package/src/analyzer/metrics.ts +156 -0
  63. package/src/analyzer/parsers/index.ts +62 -0
  64. package/src/analyzer/parsers/reactParser.ts +24 -0
  65. package/src/analyzer/parsers/svelteParser.ts +46 -0
  66. package/src/analyzer/parsers/tsParser.ts +364 -0
  67. package/src/analyzer/parsers/vueParser.ts +109 -0
  68. package/src/analyzer/recommendations.ts +432 -0
  69. package/src/analyzer/resolve.ts +315 -0
  70. package/src/analyzer/rsc.ts +120 -0
  71. package/src/analyzer/signals.ts +684 -0
  72. package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
  73. package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
  74. package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
  75. package/src/analyzer/sources/tauriFileSource.ts +68 -0
  76. package/src/analyzer/suggestContracts.ts +214 -0
  77. package/src/analyzer/typeOnlyCandidates.ts +233 -0
  78. package/src/analyzer/types.ts +537 -0
  79. package/src/cache/__tests__/cache.test.ts +316 -0
  80. package/src/cache/index.ts +432 -0
  81. package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
  82. package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
  83. package/src/codegen/__tests__/configSnippets.test.ts +230 -0
  84. package/src/codegen/applyTypeOnlyFix.ts +344 -0
  85. package/src/codegen/configSnippets.ts +172 -0
  86. package/src/codegen/initConfig.ts +223 -0
  87. package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
  88. package/src/config/frontScopeConfig.ts +830 -0
  89. package/src/diff/__tests__/diffScans.test.ts +103 -0
  90. package/src/diff/diffScans.ts +61 -0
  91. package/src/diff/index.ts +2 -0
  92. package/src/diff/types.ts +39 -0
  93. package/src/git/__tests__/computeChurn.test.ts +113 -0
  94. package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
  95. package/src/git/__tests__/parseGitLog.test.ts +120 -0
  96. package/src/git/computeChurn.ts +111 -0
  97. package/src/git/computeTemporalCoupling.ts +114 -0
  98. package/src/git/index.ts +24 -0
  99. package/src/git/parseGitLog.ts +124 -0
  100. package/src/git/readGitHistory.ts +130 -0
  101. package/src/git/types.ts +119 -0
  102. package/src/index.ts +137 -0
  103. package/src/report/__tests__/buildFixPlan.test.ts +357 -0
  104. package/src/report/__tests__/buildJsonReport.test.ts +34 -0
  105. package/src/report/buildFixPlan.ts +481 -0
  106. package/src/report/buildJsonReport.ts +27 -0
  107. package/src/search/__tests__/parseQuery.test.ts +67 -0
  108. package/src/search/__tests__/search.test.ts +172 -0
  109. package/src/search/index.ts +281 -0
  110. package/src/search/parseQuery.ts +75 -0
  111. package/src/views/__tests__/analyzerViews.test.ts +558 -0
  112. package/src/views/analyzerViews.ts +1294 -0
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { analyze } from '..';
3
+ import { createInMemoryFileSource } from '../sources/inMemoryFileSource';
4
+
5
+ describe('async lifecycle risk analysis', () => {
6
+ it('flags a React effect that fetches without abort or stale guard', async () => {
7
+ const result = await analyze(
8
+ createInMemoryFileSource('/p', {
9
+ 'package.json': JSON.stringify({ dependencies: { react: '^18.0.0' } }),
10
+ 'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }),
11
+ 'src/App.tsx': `
12
+ import { useEffect, useState } from 'react';
13
+
14
+ export function App() {
15
+ const [name, setName] = useState('');
16
+ useEffect(() => {
17
+ fetch('/api/user')
18
+ .then((res) => res.text())
19
+ .then(setName);
20
+ }, []);
21
+ return <div>{name}</div>;
22
+ }
23
+ `,
24
+ }),
25
+ );
26
+
27
+ expect(result.asyncLifecycleRisks).toEqual([
28
+ expect.objectContaining({
29
+ kind: 'async-effect-cleanup',
30
+ moduleId: 'src/App.tsx',
31
+ framework: 'react',
32
+ confidence: 'high',
33
+ }),
34
+ ]);
35
+ expect(result.signals?.some((signal) => signal.kind === 'async-lifecycle-risk')).toBe(true);
36
+ });
37
+
38
+ it('keeps a React effect with AbortController clean', async () => {
39
+ const result = await analyze(
40
+ createInMemoryFileSource('/p', {
41
+ 'package.json': JSON.stringify({ dependencies: { react: '^18.0.0' } }),
42
+ 'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }),
43
+ 'src/App.tsx': `
44
+ import { useEffect, useState } from 'react';
45
+
46
+ export function App() {
47
+ const [name, setName] = useState('');
48
+ useEffect(() => {
49
+ const controller = new AbortController();
50
+ fetch('/api/user', { signal: controller.signal })
51
+ .then((res) => res.text())
52
+ .then(setName);
53
+ return () => controller.abort();
54
+ }, []);
55
+ return <div>{name}</div>;
56
+ }
57
+ `,
58
+ }),
59
+ );
60
+
61
+ expect(result.asyncLifecycleRisks ?? []).toEqual([]);
62
+ });
63
+
64
+ it('flags Vue mounted async work without unmounted guard', async () => {
65
+ const result = await analyze(
66
+ createInMemoryFileSource('/p', {
67
+ 'package.json': JSON.stringify({ dependencies: { vue: '^3.0.0' } }),
68
+ 'tsconfig.json': '{}',
69
+ 'src/App.vue': `
70
+ <script setup lang="ts">
71
+ import { onMounted, ref } from 'vue';
72
+
73
+ const name = ref('');
74
+ onMounted(async () => {
75
+ const res = await fetch('/api/user');
76
+ name.value = await res.text();
77
+ });
78
+ </script>
79
+ `,
80
+ }),
81
+ );
82
+
83
+ expect(result.asyncLifecycleRisks).toEqual([
84
+ expect.objectContaining({
85
+ kind: 'async-effect-cleanup',
86
+ moduleId: 'src/App.vue',
87
+ framework: 'vue',
88
+ confidence: 'high',
89
+ }),
90
+ ]);
91
+ });
92
+
93
+ it('flags Svelte onMount async work without returned cleanup', async () => {
94
+ const result = await analyze(
95
+ createInMemoryFileSource('/p', {
96
+ 'package.json': JSON.stringify({ devDependencies: { svelte: '^4.0.0' } }),
97
+ 'tsconfig.json': '{}',
98
+ 'src/App.svelte': `
99
+ <script lang="ts">
100
+ import { onMount } from 'svelte';
101
+
102
+ let name = '';
103
+ onMount(() => {
104
+ fetch('/api/user')
105
+ .then((res) => res.text())
106
+ .then((value) => { name = value; });
107
+ });
108
+ </script>
109
+ `,
110
+ }),
111
+ );
112
+
113
+ expect(result.asyncLifecycleRisks).toEqual([
114
+ expect.objectContaining({
115
+ kind: 'async-effect-cleanup',
116
+ moduleId: 'src/App.svelte',
117
+ framework: 'svelte',
118
+ confidence: 'high',
119
+ }),
120
+ ]);
121
+ });
122
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createBrowserFsAccessFileSource } from '../sources/browserFsAccessFileSource';
3
+
4
+ // minimal FS Access handle mock - only the methods the source touches
5
+ type MockTree = { [name: string]: string | MockTree };
6
+
7
+ function makeFileHandle(content: string): unknown {
8
+ return {
9
+ kind: 'file',
10
+ async getFile() {
11
+ return {
12
+ text: () => Promise.resolve(content),
13
+ };
14
+ },
15
+ };
16
+ }
17
+
18
+ function makeDirHandle(name: string, tree: MockTree): unknown {
19
+ return {
20
+ kind: 'directory',
21
+ name,
22
+ async *entries() {
23
+ for (const [child, value] of Object.entries(tree)) {
24
+ if (typeof value === 'string') {
25
+ yield [child, makeFileHandle(value)];
26
+ } else {
27
+ yield [child, makeDirHandle(child, value)];
28
+ }
29
+ }
30
+ },
31
+ async getDirectoryHandle(child: string): Promise<unknown> {
32
+ const v = tree[child];
33
+ if (!v || typeof v === 'string') throw new Error(`No such dir: ${child}`);
34
+ return makeDirHandle(child, v);
35
+ },
36
+ async getFileHandle(child: string): Promise<unknown> {
37
+ const v = tree[child];
38
+ if (typeof v !== 'string') throw new Error(`No such file: ${child}`);
39
+ return makeFileHandle(v);
40
+ },
41
+ };
42
+ }
43
+
44
+ describe('browserFsAccessFileSource', () => {
45
+ it('list() filters by SUPPORTED_EXT (config files excluded)', async () => {
46
+ const root = makeDirHandle('proj', {
47
+ 'tsconfig.json': '{}',
48
+ 'package.json': '{}',
49
+ 'README.md': 'docs',
50
+ src: { 'main.ts': 'export {}' },
51
+ });
52
+ const source = await createBrowserFsAccessFileSource({
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ rootHandle: root as any,
55
+ });
56
+ const list = await source.list();
57
+ expect(list).toEqual(['src/main.ts']);
58
+ });
59
+
60
+ it('exists() resolves config files outside SUPPORTED_EXT', async () => {
61
+ const root = makeDirHandle('proj', {
62
+ 'tsconfig.json': '{}',
63
+ '.archora.json': '{}',
64
+ src: { 'main.ts': 'export {}' },
65
+ });
66
+ const source = await createBrowserFsAccessFileSource({
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ rootHandle: root as any,
69
+ });
70
+ expect(await source.exists('tsconfig.json')).toBe(true);
71
+ expect(await source.exists('.archora.json')).toBe(true);
72
+ expect(await source.exists('src/main.ts')).toBe(true);
73
+ expect(await source.exists('missing.json')).toBe(false);
74
+ });
75
+
76
+ it('read() returns config files outside SUPPORTED_EXT', async () => {
77
+ const root = makeDirHandle('proj', {
78
+ 'tsconfig.json': '{"compilerOptions":{"paths":{"@/*":["src/*"]}}}',
79
+ src: { 'main.ts': 'import x from "@/x"' },
80
+ });
81
+ const source = await createBrowserFsAccessFileSource({
82
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
83
+ rootHandle: root as any,
84
+ });
85
+ expect(await source.read('tsconfig.json')).toContain('"paths"');
86
+ expect(await source.read('src/main.ts')).toContain('@/x');
87
+ });
88
+
89
+ it('read() throws on non-existent path', async () => {
90
+ const root = makeDirHandle('proj', { src: { 'a.ts': 'x' } });
91
+ const source = await createBrowserFsAccessFileSource({
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
93
+ rootHandle: root as any,
94
+ });
95
+ await expect(source.read('nope.json')).rejects.toThrow();
96
+ });
97
+ });
@@ -0,0 +1,191 @@
1
+ // Bundle-aware analysis.
2
+ // Covers stats parsing for both supported formats, mapping onto the module
3
+ // graph, and bloat detection thresholds.
4
+
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ import { analyzeBundle, parseBundleStats } from '../bundle';
8
+ import type { ModuleNode } from '../types';
9
+
10
+ function mod(id: string, overrides: Partial<ModuleNode> = {}): ModuleNode {
11
+ return {
12
+ id,
13
+ absPath: id,
14
+ kind: 'unknown',
15
+ language: 'ts',
16
+ loc: 50,
17
+ exports: [],
18
+ isInfra: false,
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe('parseBundleStats', () => {
24
+ it('parses webpack stats and normalizes module paths', () => {
25
+ const stats = parseBundleStats(
26
+ {
27
+ chunks: [
28
+ {
29
+ id: 0,
30
+ names: ['main'],
31
+ files: ['main.js'],
32
+ size: 12000,
33
+ modules: [
34
+ { name: './src/app/index.ts', size: 4000 },
35
+ { name: 'src/shared/lib/utils.ts?vue&type=script', size: 800 },
36
+ ],
37
+ },
38
+ ],
39
+ },
40
+ { rootPath: '/repo' },
41
+ );
42
+
43
+ expect(stats.format).toBe('webpack');
44
+ expect(stats.chunks).toHaveLength(1);
45
+ expect(stats.chunks[0]?.modules.map((m) => m.normalizedPath)).toEqual([
46
+ 'src/app/index.ts',
47
+ 'src/shared/lib/utils.ts',
48
+ ]);
49
+ });
50
+
51
+ it('parses rollup-plugin-visualizer trees', () => {
52
+ const stats = parseBundleStats(
53
+ {
54
+ tree: {
55
+ name: 'root',
56
+ children: [
57
+ {
58
+ name: 'main.js',
59
+ children: [
60
+ {
61
+ name: 'src',
62
+ children: [
63
+ { name: 'app/index.ts', size: 2000 },
64
+ { name: 'shared/lib/utils.ts', size: 600 },
65
+ ],
66
+ },
67
+ ],
68
+ },
69
+ ],
70
+ },
71
+ },
72
+ { rootPath: '/repo' },
73
+ );
74
+
75
+ expect(stats.format).toBe('rollup-visualizer');
76
+ expect(stats.chunks).toHaveLength(1);
77
+ expect(stats.chunks[0]?.size).toBe(2600);
78
+ const paths = stats.chunks[0]?.modules.map((m) => m.normalizedPath);
79
+ expect(paths).toContain('main.js/src/app/index.ts');
80
+ });
81
+
82
+ it('returns empty unknown report on garbage input', () => {
83
+ expect(parseBundleStats(null, { rootPath: '/x' })).toEqual({ format: 'unknown', chunks: [] });
84
+ expect(parseBundleStats({ random: 1 }, { rootPath: '/x' })).toEqual({
85
+ format: 'unknown',
86
+ chunks: [],
87
+ });
88
+ });
89
+ });
90
+
91
+ describe('analyzeBundle', () => {
92
+ it('flags duplicates, heavy chunks and solo-hot modules', () => {
93
+ const modules = [
94
+ mod('src/app/index.ts'),
95
+ mod('src/shared/big.ts', { loc: 4000 }),
96
+ mod('src/shared/lib/utils.ts'),
97
+ ];
98
+
99
+ const stats = parseBundleStats(
100
+ {
101
+ chunks: [
102
+ {
103
+ id: 'main',
104
+ files: ['main.js'],
105
+ size: 600_000,
106
+ modules: [
107
+ { name: 'src/app/index.ts', size: 100_000 },
108
+ { name: 'src/shared/big.ts', size: 480_000 },
109
+ { name: 'src/shared/lib/utils.ts', size: 5_000 },
110
+ ],
111
+ },
112
+ {
113
+ id: 'admin',
114
+ files: ['admin.js'],
115
+ size: 220_000,
116
+ modules: [{ name: 'src/shared/lib/utils.ts', size: 5_000 }],
117
+ },
118
+ ],
119
+ },
120
+ { rootPath: '/repo' },
121
+ );
122
+
123
+ const report = analyzeBundle({ modules, stats });
124
+
125
+ expect(report.format).toBe('webpack');
126
+ expect(report.totalSize).toBe(820_000);
127
+
128
+ const kinds = report.bloat.map((b) => b.kind).sort();
129
+ expect(kinds).toContain('duplicate');
130
+ expect(kinds).toContain('heavy-chunk');
131
+ expect(kinds).toContain('solo-hot');
132
+
133
+ const dup = report.bloat.find((b) => b.kind === 'duplicate');
134
+ expect(dup?.modules).toEqual(['src/shared/lib/utils.ts']);
135
+ expect(dup?.chunks.sort()).toEqual(['admin', 'main']);
136
+
137
+ const heavy = report.bloat.find((b) => b.kind === 'heavy-chunk');
138
+ expect(heavy?.chunks).toEqual(['main']);
139
+
140
+ const solo = report.bloat.find((b) => b.kind === 'solo-hot');
141
+ expect(solo?.modules).toEqual(['src/shared/big.ts']);
142
+ expect(solo?.detail?.sharePercent).toBeGreaterThan(70);
143
+ });
144
+
145
+ it('respects custom thresholds', () => {
146
+ const modules = [mod('src/x.ts')];
147
+ const stats = parseBundleStats(
148
+ {
149
+ chunks: [
150
+ {
151
+ id: 'a',
152
+ files: ['a.js'],
153
+ size: 100_000,
154
+ modules: [{ name: 'src/x.ts', size: 100_000 }],
155
+ },
156
+ ],
157
+ },
158
+ { rootPath: '/repo' },
159
+ );
160
+
161
+ const report = analyzeBundle({
162
+ modules,
163
+ stats,
164
+ thresholds: { heavyChunkBytes: 50_000 },
165
+ });
166
+
167
+ expect(report.bloat.some((b) => b.kind === 'heavy-chunk')).toBe(true);
168
+ });
169
+
170
+ it('does not flag unmapped paths', () => {
171
+ const modules = [mod('src/known.ts')];
172
+ const stats = parseBundleStats(
173
+ {
174
+ chunks: [
175
+ {
176
+ id: 'main',
177
+ files: ['main.js'],
178
+ size: 600_000,
179
+ modules: [{ name: 'node_modules/foo/index.js', size: 600_000 }],
180
+ },
181
+ ],
182
+ },
183
+ { rootPath: '/repo' },
184
+ );
185
+
186
+ const report = analyzeBundle({ modules, stats });
187
+ // heavy-chunk still fires (about chunk size) but not solo-hot (no internal modules).
188
+ expect(report.bloat.some((b) => b.kind === 'solo-hot')).toBe(false);
189
+ expect(Object.keys(report.moduleToChunks)).toHaveLength(0);
190
+ });
191
+ });
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isInfra, classifyKind } from '../classify';
3
+ import type { ParsedFile } from '../types';
4
+
5
+ const parsed = (over: Partial<ParsedFile> = {}): ParsedFile => ({
6
+ relPath: '',
7
+ language: 'ts',
8
+ exports: [],
9
+ imports: [],
10
+ hasDefineStore: false,
11
+ loc: 10,
12
+ ...over,
13
+ });
14
+
15
+ describe('isInfra', () => {
16
+ it.each([
17
+ 'src/types.d.ts',
18
+ 'vite.config.ts',
19
+ 'vitest.config.ts',
20
+ 'rollup.config.mjs',
21
+ 'webpack.config.js',
22
+ 'astro.config.mjs',
23
+ 'playwright.config.ts',
24
+ 'eslint.config.js',
25
+ '.eslintrc.cjs',
26
+ 'postcss.config.cjs',
27
+ 'tailwind.config.ts',
28
+ ])('treats %s as infra', (path) => {
29
+ expect(isInfra(path)).toBe(true);
30
+ });
31
+
32
+ it.each([
33
+ 'src/main.ts',
34
+ 'src/components/Button.vue',
35
+ 'src/utils/format.ts',
36
+ 'src/api/users.config.profile.ts',
37
+ ])('keeps %s as regular', (path) => {
38
+ expect(isInfra(path)).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe('classifyKind', () => {
43
+ it('vue file → component', () => {
44
+ expect(classifyKind(parsed({ language: 'vue' }), 'src/X.vue')).toBe('component');
45
+ });
46
+
47
+ it('svelte file → component', () => {
48
+ expect(classifyKind(parsed({ language: 'svelte' }), 'src/X.svelte')).toBe('component');
49
+ });
50
+
51
+ it('defineStore call → store regardless of path', () => {
52
+ expect(classifyKind(parsed({ hasDefineStore: true }), 'src/random.ts')).toBe('store');
53
+ });
54
+
55
+ it('composables/ folder → composable', () => {
56
+ expect(classifyKind(parsed(), 'src/composables/useX.ts')).toBe('composable');
57
+ });
58
+
59
+ it('useFoo.ts at any path → composable', () => {
60
+ expect(classifyKind(parsed(), 'src/lib/useDebounce.ts')).toBe('composable');
61
+ });
62
+
63
+ it('exported `use*` function → composable even with neutral name', () => {
64
+ expect(classifyKind(parsed({ exports: ['useTheme'] }), 'src/lib/theme.ts')).toBe('composable');
65
+ });
66
+
67
+ it('router file → route', () => {
68
+ expect(classifyKind(parsed(), 'src/router/index.ts')).toBe('route');
69
+ });
70
+
71
+ it('utils/lib/helpers folders → util', () => {
72
+ expect(classifyKind(parsed(), 'src/utils/format.ts')).toBe('util');
73
+ expect(classifyKind(parsed(), 'src/lib/cn.ts')).toBe('util');
74
+ expect(classifyKind(parsed(), 'src/helpers/x.ts')).toBe('util');
75
+ });
76
+
77
+ it('main/index/entry → entry', () => {
78
+ expect(classifyKind(parsed(), 'src/main.ts')).toBe('entry');
79
+ expect(classifyKind(parsed(), 'src/index.ts')).toBe('entry');
80
+ });
81
+
82
+ it('XLoader / XPlugin / XRegistry / XProvider → integration', () => {
83
+ expect(classifyKind(parsed(), 'src/services/AuthLoader.ts')).toBe('integration');
84
+ expect(classifyKind(parsed(), 'src/plugins/MyPlugin.ts')).toBe('integration');
85
+ });
86
+
87
+ it('recognizes common project roles outside FSD', () => {
88
+ expect(classifyKind(parsed(), 'src/api/person.ts')).toBe('api');
89
+ expect(classifyKind(parsed(), 'src/services/personClient.ts')).toBe('service');
90
+ expect(classifyKind(parsed(), 'src/model/session.ts')).toBe('model');
91
+ expect(classifyKind(parsed(), 'src/types/person.ts')).toBe('schema');
92
+ expect(classifyKind(parsed(), 'src/config/routes.ts')).toBe('config');
93
+ expect(classifyKind(parsed(), 'src/__tests__/session.test.ts')).toBe('test');
94
+ });
95
+
96
+ it('uses a project module fallback otherwise', () => {
97
+ expect(classifyKind(parsed(), 'src/random/file.ts')).toBe('module');
98
+ });
99
+ });