@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.
- package/LICENSE +201 -0
- package/README.md +62 -0
- package/package.json +36 -0
- package/src/README.md +4 -0
- package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
- package/src/analyzer/__tests__/_paths.ts +8 -0
- package/src/analyzer/__tests__/analyze.test.ts +522 -0
- package/src/analyzer/__tests__/archDebt.test.ts +111 -0
- package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
- package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
- package/src/analyzer/__tests__/bundle.test.ts +191 -0
- package/src/analyzer/__tests__/classify.test.ts +99 -0
- package/src/analyzer/__tests__/contracts.test.ts +372 -0
- package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
- package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
- package/src/analyzer/__tests__/cycles.test.ts +74 -0
- package/src/analyzer/__tests__/detect.test.ts +62 -0
- package/src/analyzer/__tests__/discover.test.ts +68 -0
- package/src/analyzer/__tests__/displayId.test.ts +30 -0
- package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
- package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
- package/src/analyzer/__tests__/incremental.test.ts +154 -0
- package/src/analyzer/__tests__/layers.test.ts +87 -0
- package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
- package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
- package/src/analyzer/__tests__/metrics.test.ts +59 -0
- package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
- package/src/analyzer/__tests__/parsers.test.ts +187 -0
- package/src/analyzer/__tests__/reactParser.test.ts +93 -0
- package/src/analyzer/__tests__/recommendations.test.ts +171 -0
- package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
- package/src/analyzer/__tests__/resolve.test.ts +294 -0
- package/src/analyzer/__tests__/rsc.test.ts +130 -0
- package/src/analyzer/__tests__/signals.test.ts +316 -0
- package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
- package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
- package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
- package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
- package/src/analyzer/archDebt.ts +68 -0
- package/src/analyzer/asyncLifecycleRisk.ts +234 -0
- package/src/analyzer/buildGraph.ts +683 -0
- package/src/analyzer/bundle/analyzeBundle.ts +147 -0
- package/src/analyzer/bundle/index.ts +12 -0
- package/src/analyzer/bundle/parseStats.ts +152 -0
- package/src/analyzer/bundle/types.ts +85 -0
- package/src/analyzer/classify.ts +54 -0
- package/src/analyzer/contracts.ts +265 -0
- package/src/analyzer/cyclePatterns.ts +138 -0
- package/src/analyzer/cycles.ts +98 -0
- package/src/analyzer/detect.ts +34 -0
- package/src/analyzer/discover.ts +131 -0
- package/src/analyzer/displayId.ts +21 -0
- package/src/analyzer/entryPoints.ts +136 -0
- package/src/analyzer/feedbackArcSet.ts +332 -0
- package/src/analyzer/fileSource.ts +8 -0
- package/src/analyzer/hotZones.ts +17 -0
- package/src/analyzer/incremental.ts +455 -0
- package/src/analyzer/index.ts +444 -0
- package/src/analyzer/layers.ts +183 -0
- package/src/analyzer/loadAliases.ts +288 -0
- package/src/analyzer/memoryRisk.ts +345 -0
- package/src/analyzer/metrics.ts +156 -0
- package/src/analyzer/parsers/index.ts +62 -0
- package/src/analyzer/parsers/reactParser.ts +24 -0
- package/src/analyzer/parsers/svelteParser.ts +46 -0
- package/src/analyzer/parsers/tsParser.ts +364 -0
- package/src/analyzer/parsers/vueParser.ts +109 -0
- package/src/analyzer/recommendations.ts +432 -0
- package/src/analyzer/resolve.ts +315 -0
- package/src/analyzer/rsc.ts +120 -0
- package/src/analyzer/signals.ts +684 -0
- package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
- package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
- package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
- package/src/analyzer/sources/tauriFileSource.ts +68 -0
- package/src/analyzer/suggestContracts.ts +214 -0
- package/src/analyzer/typeOnlyCandidates.ts +233 -0
- package/src/analyzer/types.ts +537 -0
- package/src/cache/__tests__/cache.test.ts +316 -0
- package/src/cache/index.ts +432 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
- package/src/codegen/__tests__/configSnippets.test.ts +230 -0
- package/src/codegen/applyTypeOnlyFix.ts +344 -0
- package/src/codegen/configSnippets.ts +172 -0
- package/src/codegen/initConfig.ts +223 -0
- package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
- package/src/config/frontScopeConfig.ts +830 -0
- package/src/diff/__tests__/diffScans.test.ts +103 -0
- package/src/diff/diffScans.ts +61 -0
- package/src/diff/index.ts +2 -0
- package/src/diff/types.ts +39 -0
- package/src/git/__tests__/computeChurn.test.ts +113 -0
- package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
- package/src/git/__tests__/parseGitLog.test.ts +120 -0
- package/src/git/computeChurn.ts +111 -0
- package/src/git/computeTemporalCoupling.ts +114 -0
- package/src/git/index.ts +24 -0
- package/src/git/parseGitLog.ts +124 -0
- package/src/git/readGitHistory.ts +130 -0
- package/src/git/types.ts +119 -0
- package/src/index.ts +137 -0
- package/src/report/__tests__/buildFixPlan.test.ts +357 -0
- package/src/report/__tests__/buildJsonReport.test.ts +34 -0
- package/src/report/buildFixPlan.ts +481 -0
- package/src/report/buildJsonReport.ts +27 -0
- package/src/search/__tests__/parseQuery.test.ts +67 -0
- package/src/search/__tests__/search.test.ts +172 -0
- package/src/search/index.ts +281 -0
- package/src/search/parseQuery.ts +75 -0
- package/src/views/__tests__/analyzerViews.test.ts +558 -0
- package/src/views/analyzerViews.ts +1294 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createTsParser } from '../parsers/tsParser';
|
|
3
|
+
import { createVueParser } from '../parsers/vueParser';
|
|
4
|
+
import { createParserRegistry, isParseFailure } from '../parsers';
|
|
5
|
+
|
|
6
|
+
describe('tsParser', () => {
|
|
7
|
+
it('captures static, dynamic, type-only and side-effect imports', () => {
|
|
8
|
+
const p = createTsParser();
|
|
9
|
+
const result = p.parse({
|
|
10
|
+
relPath: 'sample.ts',
|
|
11
|
+
language: 'ts',
|
|
12
|
+
content: `
|
|
13
|
+
import a from './a';
|
|
14
|
+
import type { B } from './b';
|
|
15
|
+
import './style.css';
|
|
16
|
+
import { type C, d } from './cd';
|
|
17
|
+
const lazy = () => import('./lazy');
|
|
18
|
+
export { e } from './e';
|
|
19
|
+
`,
|
|
20
|
+
});
|
|
21
|
+
const byKind = (k: string) =>
|
|
22
|
+
result.imports.filter((i) => i.kind === k).map((i) => i.specifier);
|
|
23
|
+
expect(byKind('static').sort()).toEqual(['./a', './cd', './e'].sort());
|
|
24
|
+
expect(byKind('type-only')).toContain('./b');
|
|
25
|
+
expect(byKind('side-effect')).toContain('./style.css');
|
|
26
|
+
expect(byKind('dynamic')).toContain('./lazy');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('detects defineStore', () => {
|
|
30
|
+
const p = createTsParser();
|
|
31
|
+
const result = p.parse({
|
|
32
|
+
relPath: 'store.ts',
|
|
33
|
+
language: 'ts',
|
|
34
|
+
content: `import { defineStore } from 'pinia'; export const s = defineStore('x', {});`,
|
|
35
|
+
});
|
|
36
|
+
expect(result.hasDefineStore).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('lists exports', () => {
|
|
40
|
+
const p = createTsParser();
|
|
41
|
+
const result = p.parse({
|
|
42
|
+
relPath: 'x.ts',
|
|
43
|
+
language: 'ts',
|
|
44
|
+
content: `export const a = 1; export function b() {} export default {};`,
|
|
45
|
+
});
|
|
46
|
+
expect(result.exports).toContain('a');
|
|
47
|
+
expect(result.exports).toContain('b');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('extracts static prefix from template-literal dynamic imports', () => {
|
|
51
|
+
const p = createTsParser();
|
|
52
|
+
const result = p.parse({
|
|
53
|
+
relPath: 'loader.ts',
|
|
54
|
+
language: 'ts',
|
|
55
|
+
content: `
|
|
56
|
+
async function load(name: string) {
|
|
57
|
+
return import(\`./mfes/\${name}/index\`);
|
|
58
|
+
}
|
|
59
|
+
`,
|
|
60
|
+
});
|
|
61
|
+
const dyn = result.imports.filter((i) => i.kind === 'dynamic');
|
|
62
|
+
expect(dyn).toHaveLength(1);
|
|
63
|
+
expect(dyn[0]?.specifier).toBe('./mfes/');
|
|
64
|
+
expect(dyn[0]?.pattern).toBe('prefix');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('extracts static prefix from string-concat dynamic imports', () => {
|
|
68
|
+
const p = createTsParser();
|
|
69
|
+
const result = p.parse({
|
|
70
|
+
relPath: 'loader.ts',
|
|
71
|
+
language: 'ts',
|
|
72
|
+
content: `
|
|
73
|
+
function load(name: string) {
|
|
74
|
+
return import('./mfes/' + name + '/index');
|
|
75
|
+
}
|
|
76
|
+
`,
|
|
77
|
+
});
|
|
78
|
+
const dyn = result.imports.filter((i) => i.kind === 'dynamic');
|
|
79
|
+
expect(dyn).toHaveLength(1);
|
|
80
|
+
expect(dyn[0]?.specifier).toBe('./mfes/');
|
|
81
|
+
expect(dyn[0]?.pattern).toBe('prefix');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('keeps literal pattern for plain dynamic imports', () => {
|
|
85
|
+
const p = createTsParser();
|
|
86
|
+
const result = p.parse({
|
|
87
|
+
relPath: 'a.ts',
|
|
88
|
+
language: 'ts',
|
|
89
|
+
content: `const m = import('./b');`,
|
|
90
|
+
});
|
|
91
|
+
const dyn = result.imports.filter((i) => i.kind === 'dynamic');
|
|
92
|
+
expect(dyn[0]?.pattern).toBeUndefined();
|
|
93
|
+
expect(dyn[0]?.specifier).toBe('./b');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('captures CJS require() as a static import', () => {
|
|
97
|
+
const p = createTsParser();
|
|
98
|
+
const result = p.parse({
|
|
99
|
+
relPath: 'a.cjs',
|
|
100
|
+
language: 'js',
|
|
101
|
+
content: `const x = require('./b'); const y = require('lodash');`,
|
|
102
|
+
});
|
|
103
|
+
const statics = result.imports.filter((i) => i.kind === 'static');
|
|
104
|
+
expect(statics.map((i) => i.specifier).sort()).toEqual(['./b', 'lodash']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('captures import.meta.glob string-literal patterns', () => {
|
|
108
|
+
const p = createTsParser();
|
|
109
|
+
const result = p.parse({
|
|
110
|
+
relPath: 'router.ts',
|
|
111
|
+
language: 'ts',
|
|
112
|
+
content: `const pages = import.meta.glob('./pages/**/*.vue', { eager: false });`,
|
|
113
|
+
});
|
|
114
|
+
const globs = result.imports.filter((i) => i.pattern === 'glob');
|
|
115
|
+
expect(globs).toHaveLength(1);
|
|
116
|
+
expect(globs[0]?.specifier).toBe('./pages/**/*.vue');
|
|
117
|
+
expect(globs[0]?.kind).toBe('dynamic');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('captures import.meta.glob with array-of-patterns', () => {
|
|
121
|
+
const p = createTsParser();
|
|
122
|
+
const result = p.parse({
|
|
123
|
+
relPath: 'router.ts',
|
|
124
|
+
language: 'ts',
|
|
125
|
+
content: `const m = import.meta.glob(['./a/**/*.ts', './b/**/*.ts']);`,
|
|
126
|
+
});
|
|
127
|
+
const globs = result.imports.filter((i) => i.pattern === 'glob');
|
|
128
|
+
expect(globs.map((g) => g.specifier).sort()).toEqual(['./a/**/*.ts', './b/**/*.ts']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('captures import.meta.globEager patterns as eager glob imports', () => {
|
|
132
|
+
const p = createTsParser();
|
|
133
|
+
const result = p.parse({
|
|
134
|
+
relPath: 'modules.ts',
|
|
135
|
+
language: 'ts',
|
|
136
|
+
content: `const modules = import.meta.globEager('./modules/**/*.ts');`,
|
|
137
|
+
});
|
|
138
|
+
const globs = result.imports.filter((i) => i.pattern === 'glob');
|
|
139
|
+
expect(globs).toHaveLength(1);
|
|
140
|
+
expect(globs[0]).toMatchObject({
|
|
141
|
+
specifier: './modules/**/*.ts',
|
|
142
|
+
kind: 'dynamic',
|
|
143
|
+
confidence: 'medium',
|
|
144
|
+
globEager: true,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('captures namespace re-export names', () => {
|
|
149
|
+
const parser = createParserRegistry();
|
|
150
|
+
const result = parser.parse({
|
|
151
|
+
relPath: 'src/index.ts',
|
|
152
|
+
content: "export * as FeatureApi from './feature';",
|
|
153
|
+
});
|
|
154
|
+
if (isParseFailure(result)) throw new Error('parse failed');
|
|
155
|
+
expect(result.exports).toContain('FeatureApi');
|
|
156
|
+
expect(result.imports).toEqual([{ specifier: './feature', kind: 'static' }]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('skips dynamic imports with no static prefix', () => {
|
|
160
|
+
const p = createTsParser();
|
|
161
|
+
const result = p.parse({
|
|
162
|
+
relPath: 'a.ts',
|
|
163
|
+
language: 'ts',
|
|
164
|
+
content: `function load(p: string) { return import(p); }`,
|
|
165
|
+
});
|
|
166
|
+
expect(result.imports.filter((i) => i.kind === 'dynamic')).toHaveLength(0);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('vueParser', () => {
|
|
171
|
+
it('extracts imports from <script setup>', () => {
|
|
172
|
+
const tp = createTsParser();
|
|
173
|
+
const vp = createVueParser(tp);
|
|
174
|
+
const result = vp.parse({
|
|
175
|
+
relPath: 'Foo.vue',
|
|
176
|
+
content: `<template><div /></template>
|
|
177
|
+
<script setup lang="ts">
|
|
178
|
+
import Bar from '@/Bar.vue';
|
|
179
|
+
import { useFoo } from '@/composables/useFoo';
|
|
180
|
+
</script>`,
|
|
181
|
+
});
|
|
182
|
+
expect(result.language).toBe('vue');
|
|
183
|
+
const specs = result.imports.map((i) => i.specifier);
|
|
184
|
+
expect(specs).toContain('@/Bar.vue');
|
|
185
|
+
expect(specs).toContain('@/composables/useFoo');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createParserRegistry, isParseFailure } from '../parsers';
|
|
3
|
+
import type { ParsedFile } from '../types';
|
|
4
|
+
|
|
5
|
+
const parseReact = (relPath: string, content: string): ParsedFile => {
|
|
6
|
+
const registry = createParserRegistry({ framework: 'react' });
|
|
7
|
+
const r = registry.parse({ relPath, content });
|
|
8
|
+
if (isParseFailure(r)) throw new Error(`unexpected parse failure: ${r.reason}`);
|
|
9
|
+
return r;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe('reactParser (via registry, framework=react)', () => {
|
|
13
|
+
it('captures lazy(() => import(...)) as a dynamic edge', () => {
|
|
14
|
+
const file = parseReact(
|
|
15
|
+
'src/App.tsx',
|
|
16
|
+
`import { lazy } from 'react';
|
|
17
|
+
const Settings = lazy(() => import('./routes/Settings'));
|
|
18
|
+
export const App = () => null;`,
|
|
19
|
+
);
|
|
20
|
+
const dynamic = file.imports.filter((i) => i.kind === 'dynamic');
|
|
21
|
+
expect(dynamic.map((i) => i.specifier)).toContain('./routes/Settings');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('captures multiple lazy imports independently', () => {
|
|
25
|
+
const file = parseReact(
|
|
26
|
+
'src/App.tsx',
|
|
27
|
+
`import { lazy } from 'react';
|
|
28
|
+
const A = lazy(() => import('./A'));
|
|
29
|
+
const B = lazy(() => import('./B'));
|
|
30
|
+
export const App = () => null;`,
|
|
31
|
+
);
|
|
32
|
+
const specs = file.imports.filter((i) => i.kind === 'dynamic').map((i) => i.specifier);
|
|
33
|
+
expect(specs).toEqual(expect.arrayContaining(['./A', './B']));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('captures static imports of JSX components', () => {
|
|
37
|
+
const file = parseReact(
|
|
38
|
+
'src/App.tsx',
|
|
39
|
+
`import { Header } from './components/Header';
|
|
40
|
+
import { SearchIcon } from './components/icons/SearchIcon';
|
|
41
|
+
export const App = () => (<div><Header /><SearchIcon /></div>);`,
|
|
42
|
+
);
|
|
43
|
+
const statics = file.imports.filter((i) => i.kind === 'static').map((i) => i.specifier);
|
|
44
|
+
expect(statics).toEqual(
|
|
45
|
+
expect.arrayContaining(['./components/Header', './components/icons/SearchIcon']),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('routes .jsx extension as js language', () => {
|
|
50
|
+
const file = parseReact(
|
|
51
|
+
'src/App.jsx',
|
|
52
|
+
`import { Foo } from './foo'; export const App = () => <Foo />;`,
|
|
53
|
+
);
|
|
54
|
+
expect(file.language).toBe('js');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('routes .tsx extension as ts language', () => {
|
|
58
|
+
const file = parseReact(
|
|
59
|
+
'src/App.tsx',
|
|
60
|
+
`import { Foo } from './foo'; export const App = (): JSX.Element => <Foo />;`,
|
|
61
|
+
);
|
|
62
|
+
expect(file.language).toBe('ts');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does not treat external react packages as project edges (filtered downstream)', () => {
|
|
66
|
+
const file = parseReact(
|
|
67
|
+
'src/App.tsx',
|
|
68
|
+
`import React, { useState } from 'react';
|
|
69
|
+
import { useNavigate } from 'react-router-dom';
|
|
70
|
+
import './side-effect-only';
|
|
71
|
+
export const App = () => null;`,
|
|
72
|
+
);
|
|
73
|
+
// bare specifiers are recorded; buildGraph filters externals later
|
|
74
|
+
const specs = file.imports.map((i) => i.specifier);
|
|
75
|
+
expect(specs).toEqual(
|
|
76
|
+
expect.arrayContaining(['react', 'react-router-dom', './side-effect-only']),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('parser routing by framework', () => {
|
|
82
|
+
it('uses tsParser for unknown framework (no React-specific routing today)', () => {
|
|
83
|
+
const registry = createParserRegistry({ framework: 'unknown' });
|
|
84
|
+
const r = registry.parse({
|
|
85
|
+
relPath: 'src/App.tsx',
|
|
86
|
+
content: `const Settings = lazy(() => import('./Settings')); export const App = () => null;`,
|
|
87
|
+
});
|
|
88
|
+
expect(isParseFailure(r)).toBe(false);
|
|
89
|
+
// reactParser is currently a delegate; just check lazy() emits a dynamic edge
|
|
90
|
+
const dynamic = (r as ParsedFile).imports.filter((i) => i.kind === 'dynamic');
|
|
91
|
+
expect(dynamic.map((i) => i.specifier)).toContain('./Settings');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computeRecommendations } from '../recommendations';
|
|
3
|
+
import type { Cycle, DependencyEdge, ModuleMetrics, ModuleNode } from '../types';
|
|
4
|
+
|
|
5
|
+
function module(id: string, overrides: Partial<ModuleNode> = {}): ModuleNode {
|
|
6
|
+
return {
|
|
7
|
+
id,
|
|
8
|
+
absPath: `/${id}`,
|
|
9
|
+
kind: 'unknown',
|
|
10
|
+
language: 'ts',
|
|
11
|
+
loc: 1,
|
|
12
|
+
exports: [],
|
|
13
|
+
isInfra: false,
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function metrics(overrides: Partial<ModuleMetrics> = {}): ModuleMetrics {
|
|
19
|
+
return {
|
|
20
|
+
fanIn: 0,
|
|
21
|
+
fanOut: 0,
|
|
22
|
+
instability: 0,
|
|
23
|
+
depth: 0,
|
|
24
|
+
inCycle: false,
|
|
25
|
+
couplingScore: 0,
|
|
26
|
+
hotnessScore: 0,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function edge(from: string, to: string, kind: DependencyEdge['kind'] = 'static'): DependencyEdge {
|
|
32
|
+
return { from, to, kind, specifier: to, resolved: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('computeRecommendations', () => {
|
|
36
|
+
it('flags an unused utility', () => {
|
|
37
|
+
const modules = [module('src/shared/lib/dead.ts', { kind: 'util' })];
|
|
38
|
+
const recs = computeRecommendations({
|
|
39
|
+
modules,
|
|
40
|
+
edges: [],
|
|
41
|
+
metrics: { 'src/shared/lib/dead.ts': metrics({ fanIn: 0 }) },
|
|
42
|
+
cycles: [],
|
|
43
|
+
layerViolations: [],
|
|
44
|
+
hotZones: [],
|
|
45
|
+
});
|
|
46
|
+
expect(recs.some((r) => r.kind === 'unused-utility')).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('proposes a cycle break candidate', () => {
|
|
50
|
+
const modules = [module('a.ts'), module('b.ts')];
|
|
51
|
+
const cycle: Cycle = { id: 'c1', modules: ['a.ts', 'b.ts'], length: 2, severity: 'direct' };
|
|
52
|
+
const recs = computeRecommendations({
|
|
53
|
+
modules,
|
|
54
|
+
edges: [edge('a.ts', 'b.ts'), edge('b.ts', 'a.ts')],
|
|
55
|
+
metrics: {
|
|
56
|
+
'a.ts': metrics({ fanOut: 5, couplingScore: 0.8 }),
|
|
57
|
+
'b.ts': metrics({ fanOut: 1, couplingScore: 0.2 }),
|
|
58
|
+
},
|
|
59
|
+
cycles: [cycle],
|
|
60
|
+
layerViolations: [],
|
|
61
|
+
hotZones: [],
|
|
62
|
+
});
|
|
63
|
+
expect(recs.some((r) => r.kind === 'cycle-break-cluster')).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('cycle-break-cluster carries pattern and feedback edges', () => {
|
|
67
|
+
// 3-cycle a→b→c→a, all in the same folder, no barrel - should be 'mixed'
|
|
68
|
+
const modules = [module('foo/a.ts'), module('foo/b.ts'), module('foo/c.ts')];
|
|
69
|
+
const cycle: Cycle = {
|
|
70
|
+
id: 'c1',
|
|
71
|
+
modules: ['foo/a.ts', 'foo/b.ts', 'foo/c.ts'],
|
|
72
|
+
length: 3,
|
|
73
|
+
severity: 'indirect',
|
|
74
|
+
};
|
|
75
|
+
const recs = computeRecommendations({
|
|
76
|
+
modules,
|
|
77
|
+
edges: [
|
|
78
|
+
edge('foo/a.ts', 'foo/b.ts'),
|
|
79
|
+
edge('foo/b.ts', 'foo/c.ts'),
|
|
80
|
+
edge('foo/c.ts', 'foo/a.ts'),
|
|
81
|
+
],
|
|
82
|
+
metrics: Object.fromEntries(modules.map((m) => [m.id, metrics()])),
|
|
83
|
+
cycles: [cycle],
|
|
84
|
+
layerViolations: [],
|
|
85
|
+
hotZones: [],
|
|
86
|
+
});
|
|
87
|
+
const cluster = recs.find((r) => r.kind === 'cycle-break-cluster');
|
|
88
|
+
expect(cluster).toBeDefined();
|
|
89
|
+
expect(cluster!.params.pattern).toBe('mixed');
|
|
90
|
+
expect(cluster!.params.sccLength).toBe(3);
|
|
91
|
+
const fbs = cluster!.params.feedbackEdges;
|
|
92
|
+
expect(Array.isArray(fbs)).toBe(true);
|
|
93
|
+
expect((fbs as unknown[]).length).toBe(1); // ring breaks with one edge
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('detects barrel-cycle and surfaces barrel/sibling params', () => {
|
|
97
|
+
const modules = [module('src/foo/index.ts'), module('src/foo/a.ts'), module('src/foo/b.ts')];
|
|
98
|
+
const cycle: Cycle = {
|
|
99
|
+
id: 'cb',
|
|
100
|
+
modules: ['src/foo/index.ts', 'src/foo/a.ts', 'src/foo/b.ts'],
|
|
101
|
+
length: 3,
|
|
102
|
+
severity: 'indirect',
|
|
103
|
+
};
|
|
104
|
+
const recs = computeRecommendations({
|
|
105
|
+
modules,
|
|
106
|
+
edges: [
|
|
107
|
+
edge('src/foo/index.ts', 'src/foo/a.ts'),
|
|
108
|
+
edge('src/foo/a.ts', 'src/foo/b.ts'),
|
|
109
|
+
edge('src/foo/b.ts', 'src/foo/index.ts'),
|
|
110
|
+
],
|
|
111
|
+
metrics: Object.fromEntries(modules.map((m) => [m.id, metrics()])),
|
|
112
|
+
cycles: [cycle],
|
|
113
|
+
layerViolations: [],
|
|
114
|
+
hotZones: [],
|
|
115
|
+
});
|
|
116
|
+
const cluster = recs.find((r) => r.kind === 'cycle-break-cluster');
|
|
117
|
+
expect(cluster?.params.pattern).toBe('barrel-cycle');
|
|
118
|
+
expect(cluster?.params.barrel).toBe('foo/index.ts');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('flags a god-module by combining fan-in and LOC', () => {
|
|
122
|
+
const big = module('src/entities/store.ts', { loc: 500 });
|
|
123
|
+
const callers = Array.from({ length: 12 }, (_, i) => module(`src/c${i}.ts`));
|
|
124
|
+
const allModules = [big, ...callers];
|
|
125
|
+
const allMetrics: Record<string, ModuleMetrics> = {
|
|
126
|
+
'src/entities/store.ts': metrics({ fanIn: 12 }),
|
|
127
|
+
};
|
|
128
|
+
for (const c of callers) allMetrics[c.id] = metrics({ fanOut: 1 });
|
|
129
|
+
const recs = computeRecommendations({
|
|
130
|
+
modules: allModules,
|
|
131
|
+
edges: callers.map((c) => edge(c.id, big.id)),
|
|
132
|
+
metrics: allMetrics,
|
|
133
|
+
cycles: [],
|
|
134
|
+
layerViolations: [],
|
|
135
|
+
hotZones: [],
|
|
136
|
+
});
|
|
137
|
+
expect(recs.some((r) => r.kind === 'split-god-module')).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('returns at most 20 items, sorted by weight', () => {
|
|
141
|
+
const modules = Array.from({ length: 50 }, (_, i) => module(`src/x${i}.ts`, { kind: 'util' }));
|
|
142
|
+
const m: Record<string, ModuleMetrics> = Object.fromEntries(
|
|
143
|
+
modules.map((x) => [x.id, metrics()]),
|
|
144
|
+
);
|
|
145
|
+
const recs = computeRecommendations({
|
|
146
|
+
modules,
|
|
147
|
+
edges: [],
|
|
148
|
+
metrics: m,
|
|
149
|
+
cycles: [],
|
|
150
|
+
layerViolations: [],
|
|
151
|
+
hotZones: [],
|
|
152
|
+
});
|
|
153
|
+
expect(recs.length).toBeLessThanOrEqual(20);
|
|
154
|
+
for (let i = 0; i < recs.length - 1; i++) {
|
|
155
|
+
expect(recs[i]!.weight).toBeGreaterThanOrEqual(recs[i + 1]!.weight);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('emits structured params, not pre-formatted strings', () => {
|
|
160
|
+
const recs = computeRecommendations({
|
|
161
|
+
modules: [module('src/shared/lib/dead.ts', { kind: 'util' })],
|
|
162
|
+
edges: [],
|
|
163
|
+
metrics: { 'src/shared/lib/dead.ts': metrics() },
|
|
164
|
+
cycles: [],
|
|
165
|
+
layerViolations: [],
|
|
166
|
+
hotZones: [],
|
|
167
|
+
});
|
|
168
|
+
const r = recs.find((x) => x.kind === 'unused-utility')!;
|
|
169
|
+
expect(r.params.name).toBe('dead.ts');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { analyze } from '../index';
|
|
5
|
+
import { createNodeFsFileSource } from '../sources/nodeFsFileSource';
|
|
6
|
+
import { fixturePath } from './_paths';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Snapshot tests on the benchmark reference corpus.
|
|
10
|
+
*
|
|
11
|
+
* We pin two things per reference project: the count of recommendations
|
|
12
|
+
* per kind, and the total number of modules / edges. This separates
|
|
13
|
+
* regressions in the heuristics (changes to recommendation counts) from
|
|
14
|
+
* changes to ground-truth annotations (which only affect bench TP/FP/FN).
|
|
15
|
+
*
|
|
16
|
+
* If a heuristic intentionally changes, `npm test -- -u` regenerates the
|
|
17
|
+
* snapshot. The change must be co-justified with bench numbers in the PR.
|
|
18
|
+
*/
|
|
19
|
+
const REFERENCE_ROOT = fixturePath('../fixtures/reference');
|
|
20
|
+
|
|
21
|
+
function listReferenceProjects(): string[] {
|
|
22
|
+
try {
|
|
23
|
+
return readdirSync(REFERENCE_ROOT)
|
|
24
|
+
.filter((name) => {
|
|
25
|
+
try {
|
|
26
|
+
return statSync(join(REFERENCE_ROOT, name)).isDirectory();
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
.sort();
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const projects = listReferenceProjects();
|
|
38
|
+
|
|
39
|
+
describe.skipIf(projects.length === 0)('reference corpus snapshots', () => {
|
|
40
|
+
for (const name of projects) {
|
|
41
|
+
it(`${name}: stable summary`, async () => {
|
|
42
|
+
const source = await createNodeFsFileSource({ rootPath: join(REFERENCE_ROOT, name) });
|
|
43
|
+
const result = await analyze(source);
|
|
44
|
+
const byKind: Record<string, number> = {};
|
|
45
|
+
for (const r of result.recommendations) {
|
|
46
|
+
byKind[r.kind] = (byKind[r.kind] ?? 0) + 1;
|
|
47
|
+
}
|
|
48
|
+
// stable order
|
|
49
|
+
const sortedByKind = Object.fromEntries(
|
|
50
|
+
Object.entries(byKind).sort(([a], [b]) => a.localeCompare(b)),
|
|
51
|
+
);
|
|
52
|
+
expect({
|
|
53
|
+
project: name,
|
|
54
|
+
modules: result.modules.length,
|
|
55
|
+
edges: result.edges.length,
|
|
56
|
+
cycles: result.cycles.length,
|
|
57
|
+
recommendations: result.recommendations.length,
|
|
58
|
+
recommendationsByKind: sortedByKind,
|
|
59
|
+
archDebtGrade: result.archDebt.grade,
|
|
60
|
+
}).toMatchSnapshot();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|