@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,154 @@
|
|
|
1
|
+
// Контракт: incrementalAnalyze должен либо вернуть результат, эквивалентный
|
|
2
|
+
// полному `analyze()` после применения тех же изменений к источнику, либо
|
|
3
|
+
// прозрачно делегировать в analyze(). В обоих случаях downstream-данные
|
|
4
|
+
// (modules/edges/cycles/metrics) должны соответствовать пост-стейту источника.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { analyze } from '../index';
|
|
8
|
+
import { incrementalAnalyze } from '../incremental';
|
|
9
|
+
import { createInMemoryFileSource } from '../sources/inMemoryFileSource';
|
|
10
|
+
|
|
11
|
+
function project(files: Record<string, string>) {
|
|
12
|
+
return createInMemoryFileSource('/proj', {
|
|
13
|
+
'tsconfig.json': JSON.stringify({ compilerOptions: { paths: { '@/*': ['src/*'] } } }),
|
|
14
|
+
...files,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('incrementalAnalyze', () => {
|
|
19
|
+
it('пере-парс одного файла даёт тот же результат, что и полный analyze пост-стейта', async () => {
|
|
20
|
+
const before = {
|
|
21
|
+
'src/a.ts': "import { b } from '@/b';\nexport const a = b;\n",
|
|
22
|
+
'src/b.ts': 'export const b = 1;\n',
|
|
23
|
+
'src/c.ts': 'export const c = 2;\n',
|
|
24
|
+
};
|
|
25
|
+
const prev = await analyze(project(before));
|
|
26
|
+
|
|
27
|
+
const after = { ...before, 'src/a.ts': "import { c } from '@/c';\nexport const a = c;\n" };
|
|
28
|
+
const expected = await analyze(project(after));
|
|
29
|
+
const got = await incrementalAnalyze({
|
|
30
|
+
prev,
|
|
31
|
+
source: project(after),
|
|
32
|
+
changedFiles: ['src/a.ts'],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// структурное равенство по ключевым полям (durationMs/scannedAt отбрасываем)
|
|
36
|
+
expect(sortEdges(got.edges)).toEqual(sortEdges(expected.edges));
|
|
37
|
+
expect(got.modules.find((m) => m.id === 'src/a.ts')?.exports).toEqual(['a']);
|
|
38
|
+
expect(got.cycles).toEqual(expected.cycles);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('изменение типа модуля (loc, exports) попадает в результат', async () => {
|
|
42
|
+
const before = {
|
|
43
|
+
'src/util.ts': 'export const x = 1;\n',
|
|
44
|
+
'src/main.ts': "import { x } from '@/util';\nexport default x;\n",
|
|
45
|
+
};
|
|
46
|
+
const prev = await analyze(project(before));
|
|
47
|
+
|
|
48
|
+
const after = {
|
|
49
|
+
...before,
|
|
50
|
+
'src/util.ts': 'export const x = 1;\nexport const y = 2;\nexport const z = 3;\n',
|
|
51
|
+
};
|
|
52
|
+
const got = await incrementalAnalyze({
|
|
53
|
+
prev,
|
|
54
|
+
source: project(after),
|
|
55
|
+
changedFiles: ['src/util.ts'],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const util = got.modules.find((m) => m.id === 'src/util.ts');
|
|
59
|
+
expect(util?.exports.sort()).toEqual(['x', 'y', 'z']);
|
|
60
|
+
expect(util?.loc).toBeGreaterThanOrEqual(3);
|
|
61
|
+
expect(util?.loc).toBe(prev.modules.find((m) => m.id === 'src/util.ts')!.loc + 2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('появление цикла после правки детектируется', async () => {
|
|
65
|
+
const before = {
|
|
66
|
+
'src/a.ts': "import { b } from '@/b';\nexport const a = b;\n",
|
|
67
|
+
'src/b.ts': 'export const b = 1;\n',
|
|
68
|
+
};
|
|
69
|
+
const prev = await analyze(project(before));
|
|
70
|
+
expect(prev.cycles).toHaveLength(0);
|
|
71
|
+
|
|
72
|
+
const after = {
|
|
73
|
+
...before,
|
|
74
|
+
'src/b.ts': "import { a } from '@/a';\nexport const b = a;\n",
|
|
75
|
+
};
|
|
76
|
+
const got = await incrementalAnalyze({
|
|
77
|
+
prev,
|
|
78
|
+
source: project(after),
|
|
79
|
+
changedFiles: ['src/b.ts'],
|
|
80
|
+
});
|
|
81
|
+
expect(got.cycles).toHaveLength(1);
|
|
82
|
+
expect(got.cycles[0]?.modules.sort()).toEqual(['src/a.ts', 'src/b.ts']);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('изменение конфигурационного файла → fallback на полный analyze', async () => {
|
|
86
|
+
const before = {
|
|
87
|
+
'src/a.ts': 'export const a = 1;\n',
|
|
88
|
+
};
|
|
89
|
+
const prev = await analyze(project(before));
|
|
90
|
+
|
|
91
|
+
const newSource = createInMemoryFileSource('/proj', {
|
|
92
|
+
'tsconfig.json': JSON.stringify({ compilerOptions: { paths: { '~/*': ['src/*'] } } }),
|
|
93
|
+
'src/a.ts': 'export const a = 1;\n',
|
|
94
|
+
'src/b.ts': "import { a } from '~/a';\nexport const b = a;\n",
|
|
95
|
+
});
|
|
96
|
+
const got = await incrementalAnalyze({
|
|
97
|
+
prev,
|
|
98
|
+
source: newSource,
|
|
99
|
+
changedFiles: ['tsconfig.json', 'src/b.ts'],
|
|
100
|
+
});
|
|
101
|
+
// если прошло через analyze, новый алиас и новый файл подхвачены
|
|
102
|
+
expect(got.modules.map((m) => m.id).sort()).toEqual(['src/a.ts', 'src/b.ts']);
|
|
103
|
+
expect(got.edges.find((e) => e.from === 'src/b.ts')?.to).toBe('src/a.ts');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('новый файл (не в prev) → fallback на полный analyze', async () => {
|
|
107
|
+
const before = {
|
|
108
|
+
'src/a.ts': 'export const a = 1;\n',
|
|
109
|
+
};
|
|
110
|
+
const prev = await analyze(project(before));
|
|
111
|
+
|
|
112
|
+
const after = {
|
|
113
|
+
...before,
|
|
114
|
+
'src/new.ts': "import { a } from '@/a';\nexport const n = a;\n",
|
|
115
|
+
};
|
|
116
|
+
const got = await incrementalAnalyze({
|
|
117
|
+
prev,
|
|
118
|
+
source: project(after),
|
|
119
|
+
changedFiles: ['src/new.ts'],
|
|
120
|
+
});
|
|
121
|
+
expect(got.modules.map((m) => m.id).sort()).toEqual(['src/a.ts', 'src/new.ts']);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('удалённый файл → fallback на полный analyze', async () => {
|
|
125
|
+
const before = {
|
|
126
|
+
'src/a.ts': "import { b } from '@/b';\nexport const a = b;\n",
|
|
127
|
+
'src/b.ts': 'export const b = 1;\n',
|
|
128
|
+
};
|
|
129
|
+
const prev = await analyze(project(before));
|
|
130
|
+
|
|
131
|
+
const after = { 'src/a.ts': 'export const a = 1;\n' };
|
|
132
|
+
const got = await incrementalAnalyze({
|
|
133
|
+
prev,
|
|
134
|
+
source: project(after),
|
|
135
|
+
changedFiles: ['src/b.ts'],
|
|
136
|
+
});
|
|
137
|
+
expect(got.modules.map((m) => m.id)).toEqual(['src/a.ts']);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('пустой changedFiles → возвращает prev без работы', async () => {
|
|
141
|
+
const before = { 'src/a.ts': 'export const a = 1;\n' };
|
|
142
|
+
const prev = await analyze(project(before));
|
|
143
|
+
const got = await incrementalAnalyze({
|
|
144
|
+
prev,
|
|
145
|
+
source: project(before),
|
|
146
|
+
changedFiles: [],
|
|
147
|
+
});
|
|
148
|
+
expect(got).toBe(prev);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
function sortEdges<T extends { from: string; to: string }>(edges: readonly T[]): T[] {
|
|
153
|
+
return [...edges].sort((a, b) => (a.from + a.to).localeCompare(b.from + b.to));
|
|
154
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { detectLayer, detectLayerViolations } from '../layers';
|
|
3
|
+
import type { DependencyEdge, 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 edge(from: string, to: string, kind: DependencyEdge['kind'] = 'static'): DependencyEdge {
|
|
19
|
+
return { from, to, kind, specifier: to, resolved: true };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('detectLayer', () => {
|
|
23
|
+
it('extracts the FSD layer from src/<layer>/ paths', () => {
|
|
24
|
+
expect(detectLayer('src/widgets/Foo.vue')).toBe('widgets');
|
|
25
|
+
expect(detectLayer('src/features/auth/login.ts')).toBe('features');
|
|
26
|
+
expect(detectLayer('src/shared/ui/Button.vue')).toBe('shared');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('works without src/ prefix', () => {
|
|
30
|
+
expect(detectLayer('entities/order/model.ts')).toBe('entities');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns unknown for non-FSD paths', () => {
|
|
34
|
+
expect(detectLayer('vendor/lib.ts')).toBe('unknown');
|
|
35
|
+
expect(detectLayer('main.ts')).toBe('unknown');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('does not match layer names nested deeper than the first segment', () => {
|
|
39
|
+
expect(detectLayer('src/lib/app/something.ts')).toBe('unknown');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('detectLayerViolations', () => {
|
|
44
|
+
it('flags entities → widgets as a violation (warning severity)', () => {
|
|
45
|
+
const modules = [
|
|
46
|
+
module('src/entities/order/model.ts'),
|
|
47
|
+
module('src/widgets/order-card/OrderCard.vue'),
|
|
48
|
+
];
|
|
49
|
+
const edges = [edge('src/entities/order/model.ts', 'src/widgets/order-card/OrderCard.vue')];
|
|
50
|
+
const v = detectLayerViolations(modules, edges);
|
|
51
|
+
expect(v).toHaveLength(1);
|
|
52
|
+
expect(v[0]?.fromLayer).toBe('entities');
|
|
53
|
+
expect(v[0]?.toLayer).toBe('widgets');
|
|
54
|
+
expect(v[0]?.severity).toBe('warning');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('escalates deep → top-level imports to error severity', () => {
|
|
58
|
+
const modules = [module('src/shared/lib/x.ts'), module('src/pages/Home.vue')];
|
|
59
|
+
const edges = [edge('src/shared/lib/x.ts', 'src/pages/Home.vue')];
|
|
60
|
+
const v = detectLayerViolations(modules, edges);
|
|
61
|
+
expect(v[0]?.severity).toBe('error');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does not flag allowed top-down imports (widgets → entities)', () => {
|
|
65
|
+
const modules = [
|
|
66
|
+
module('src/widgets/order-card/OrderCard.vue'),
|
|
67
|
+
module('src/entities/order/model.ts'),
|
|
68
|
+
];
|
|
69
|
+
const edges = [edge('src/widgets/order-card/OrderCard.vue', 'src/entities/order/model.ts')];
|
|
70
|
+
expect(detectLayerViolations(modules, edges)).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('ignores type-only edges and unresolved edges', () => {
|
|
74
|
+
const modules = [module('src/entities/order/model.ts'), module('src/widgets/x/X.vue')];
|
|
75
|
+
const edges = [
|
|
76
|
+
edge('src/entities/order/model.ts', 'src/widgets/x/X.vue', 'type-only'),
|
|
77
|
+
{ ...edge('src/entities/order/model.ts', 'src/widgets/x/X.vue'), resolved: false },
|
|
78
|
+
];
|
|
79
|
+
expect(detectLayerViolations(modules, edges)).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('does not flag same-layer imports', () => {
|
|
83
|
+
const modules = [module('src/features/a/index.ts'), module('src/features/b/index.ts')];
|
|
84
|
+
const edges = [edge('src/features/a/index.ts', 'src/features/b/index.ts')];
|
|
85
|
+
expect(detectLayerViolations(modules, edges)).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
detectLayer,
|
|
4
|
+
detectLayerViolations,
|
|
5
|
+
recomputeLayers,
|
|
6
|
+
validateLayerOverride,
|
|
7
|
+
} from '../layers';
|
|
8
|
+
import type { DependencyEdge, ModuleNode } from '../types';
|
|
9
|
+
|
|
10
|
+
function module(id: string): ModuleNode {
|
|
11
|
+
return {
|
|
12
|
+
id,
|
|
13
|
+
absPath: `/${id}`,
|
|
14
|
+
kind: 'unknown',
|
|
15
|
+
language: 'ts',
|
|
16
|
+
loc: 1,
|
|
17
|
+
exports: [],
|
|
18
|
+
isInfra: false,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function edge(from: string, to: string): DependencyEdge {
|
|
23
|
+
return { from, to, kind: 'static', specifier: to, resolved: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('detectLayer with overrides', () => {
|
|
27
|
+
it('promotes a path under src/lib/* into shared via glob', () => {
|
|
28
|
+
expect(detectLayer('src/lib/utils.ts', { 'src/lib/**': 'shared' })).toBe('shared');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('falls through to FSD detection when no override matches', () => {
|
|
32
|
+
expect(detectLayer('src/widgets/X.vue', { 'src/lib/**': 'shared' })).toBe('widgets');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('first matching pattern wins (insertion order)', () => {
|
|
36
|
+
const overrides = {
|
|
37
|
+
'src/lib/auth/**': 'features',
|
|
38
|
+
'src/lib/**': 'shared',
|
|
39
|
+
};
|
|
40
|
+
expect(detectLayer('src/lib/auth/index.ts', overrides)).toBe('features');
|
|
41
|
+
expect(detectLayer('src/lib/db.ts', overrides)).toBe('shared');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('skips overrides with unknown layer values', () => {
|
|
45
|
+
expect(detectLayer('src/lib/x.ts', { 'src/lib/**': 'bogus' })).toBe('unknown');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('detectLayerViolations with overrides', () => {
|
|
50
|
+
it('flags new violations introduced by promoting a path to a higher layer', () => {
|
|
51
|
+
// baseline: src/lib/utils.ts is unknown, so widgets→lib doesn't violate.
|
|
52
|
+
const modules = [module('src/widgets/X.vue'), module('src/lib/utils.ts')];
|
|
53
|
+
const edges = [edge('src/lib/utils.ts', 'src/widgets/X.vue')];
|
|
54
|
+
expect(detectLayerViolations(modules, edges)).toHaveLength(0);
|
|
55
|
+
// promote lib→shared: now lib (= shared, rank 5) → widgets (rank 2) becomes a violation.
|
|
56
|
+
const v = detectLayerViolations(modules, edges, { 'src/lib/**': 'shared' });
|
|
57
|
+
expect(v).toHaveLength(1);
|
|
58
|
+
expect(v[0]?.fromLayer).toBe('shared');
|
|
59
|
+
expect(v[0]?.toLayer).toBe('widgets');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('clears violations when overrides reclassify both endpoints', () => {
|
|
63
|
+
// baseline: entities → widgets is a warning.
|
|
64
|
+
const modules = [module('src/entities/order/model.ts'), module('src/widgets/order-card/X.vue')];
|
|
65
|
+
const edges = [edge('src/entities/order/model.ts', 'src/widgets/order-card/X.vue')];
|
|
66
|
+
expect(detectLayerViolations(modules, edges)).toHaveLength(1);
|
|
67
|
+
// declare both files as `unknown`-equivalent via reclassification to widgets:
|
|
68
|
+
// entities→widgets becomes widgets→widgets (same layer, allowed).
|
|
69
|
+
const v = detectLayerViolations(modules, edges, {
|
|
70
|
+
'src/entities/**': 'widgets',
|
|
71
|
+
});
|
|
72
|
+
expect(v).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('recomputeLayers', () => {
|
|
77
|
+
it('returns deterministic byModule and violations', () => {
|
|
78
|
+
const modules = [
|
|
79
|
+
module('src/widgets/X.vue'),
|
|
80
|
+
module('src/entities/order/model.ts'),
|
|
81
|
+
module('src/lib/util.ts'),
|
|
82
|
+
];
|
|
83
|
+
const edges = [
|
|
84
|
+
edge('src/widgets/X.vue', 'src/entities/order/model.ts'),
|
|
85
|
+
edge('src/entities/order/model.ts', 'src/lib/util.ts'),
|
|
86
|
+
];
|
|
87
|
+
const a = recomputeLayers({ modules, edges, overrides: { 'src/lib/**': 'shared' } });
|
|
88
|
+
const b = recomputeLayers({ modules, edges, overrides: { 'src/lib/**': 'shared' } });
|
|
89
|
+
expect(a).toEqual(b);
|
|
90
|
+
expect(a.byModule['src/lib/util.ts']).toBe('shared');
|
|
91
|
+
expect(a.byModule['src/widgets/X.vue']).toBe('widgets');
|
|
92
|
+
expect(a.violations).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('is idempotent: same input → same output', () => {
|
|
96
|
+
const modules = [module('src/shared/x.ts'), module('src/pages/Home.vue')];
|
|
97
|
+
const edges = [edge('src/shared/x.ts', 'src/pages/Home.vue')];
|
|
98
|
+
const r1 = recomputeLayers({ modules, edges });
|
|
99
|
+
const r2 = recomputeLayers({ modules, edges });
|
|
100
|
+
expect(r1).toEqual(r2);
|
|
101
|
+
expect(r1.violations[0]?.severity).toBe('error');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('validateLayerOverride', () => {
|
|
106
|
+
it('rejects empty patterns', () => {
|
|
107
|
+
expect(validateLayerOverride('', 'shared')).toBe('empty');
|
|
108
|
+
expect(validateLayerOverride(' ', 'shared')).toBe('empty');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects unknown layers', () => {
|
|
112
|
+
expect(validateLayerOverride('src/**', 'bogus')).toBe('unknown-layer');
|
|
113
|
+
expect(validateLayerOverride('src/**', 'unknown')).toBe('unknown-layer');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('accepts a well-formed pattern + known layer', () => {
|
|
117
|
+
expect(validateLayerOverride('src/lib/**', 'shared')).toBeNull();
|
|
118
|
+
expect(validateLayerOverride('apps/*/src/**', 'features')).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { analyze } from '..';
|
|
3
|
+
import { createInMemoryFileSource } from '../sources/inMemoryFileSource';
|
|
4
|
+
|
|
5
|
+
describe('memory risk analysis', () => {
|
|
6
|
+
it('flags a React effect that registers an event listener without cleanup', 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 } from 'react';
|
|
13
|
+
|
|
14
|
+
export function App() {
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
window.addEventListener('resize', () => {});
|
|
17
|
+
}, []);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
`,
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
expect(result.memoryRisks).toEqual([
|
|
25
|
+
expect.objectContaining({
|
|
26
|
+
kind: 'event-listener-cleanup',
|
|
27
|
+
moduleId: 'src/App.tsx',
|
|
28
|
+
framework: 'react',
|
|
29
|
+
confidence: 'high',
|
|
30
|
+
}),
|
|
31
|
+
]);
|
|
32
|
+
expect(result.signals?.some((signal) => signal.kind === 'memory-risk')).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('keeps a React effect with matching cleanup clean', async () => {
|
|
36
|
+
const result = await analyze(
|
|
37
|
+
createInMemoryFileSource('/p', {
|
|
38
|
+
'package.json': JSON.stringify({ dependencies: { react: '^18.0.0' } }),
|
|
39
|
+
'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }),
|
|
40
|
+
'src/App.tsx': `
|
|
41
|
+
import { useEffect } from 'react';
|
|
42
|
+
|
|
43
|
+
export function App() {
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const onResize = () => {};
|
|
46
|
+
window.addEventListener('resize', onResize);
|
|
47
|
+
return () => window.removeEventListener('resize', onResize);
|
|
48
|
+
}, []);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
`,
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(result.memoryRisks ?? []).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('flags Vue mounted listeners without an unmounted cleanup', async () => {
|
|
59
|
+
const result = await analyze(
|
|
60
|
+
createInMemoryFileSource('/p', {
|
|
61
|
+
'package.json': JSON.stringify({ dependencies: { vue: '^3.0.0' } }),
|
|
62
|
+
'tsconfig.json': '{}',
|
|
63
|
+
'src/App.vue': `
|
|
64
|
+
<script setup lang="ts">
|
|
65
|
+
import { onMounted } from 'vue';
|
|
66
|
+
|
|
67
|
+
onMounted(() => {
|
|
68
|
+
window.addEventListener('scroll', () => {});
|
|
69
|
+
});
|
|
70
|
+
</script>
|
|
71
|
+
`,
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(result.memoryRisks).toEqual([
|
|
76
|
+
expect.objectContaining({
|
|
77
|
+
kind: 'event-listener-cleanup',
|
|
78
|
+
moduleId: 'src/App.vue',
|
|
79
|
+
framework: 'vue',
|
|
80
|
+
confidence: 'high',
|
|
81
|
+
}),
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('flags Svelte subscriptions without returned cleanup', async () => {
|
|
86
|
+
const result = await analyze(
|
|
87
|
+
createInMemoryFileSource('/p', {
|
|
88
|
+
'package.json': JSON.stringify({ devDependencies: { svelte: '^4.0.0' } }),
|
|
89
|
+
'tsconfig.json': '{}',
|
|
90
|
+
'src/App.svelte': `
|
|
91
|
+
<script lang="ts">
|
|
92
|
+
import { onMount } from 'svelte';
|
|
93
|
+
import { writable } from 'svelte/store';
|
|
94
|
+
|
|
95
|
+
const count = writable(0);
|
|
96
|
+
onMount(() => {
|
|
97
|
+
count.subscribe(() => {});
|
|
98
|
+
});
|
|
99
|
+
</script>
|
|
100
|
+
`,
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(result.memoryRisks).toEqual([
|
|
105
|
+
expect.objectContaining({
|
|
106
|
+
kind: 'subscription-cleanup',
|
|
107
|
+
moduleId: 'src/App.svelte',
|
|
108
|
+
framework: 'svelte',
|
|
109
|
+
confidence: 'high',
|
|
110
|
+
}),
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('does not flag Next server modules as browser memory risks', async () => {
|
|
115
|
+
const result = await analyze(
|
|
116
|
+
createInMemoryFileSource('/p', {
|
|
117
|
+
'package.json': JSON.stringify({ dependencies: { next: '^14.0.0', react: '^18.0.0' } }),
|
|
118
|
+
'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'preserve' } }),
|
|
119
|
+
'app/page.tsx': `
|
|
120
|
+
'use server';
|
|
121
|
+
|
|
122
|
+
export default function Page() {
|
|
123
|
+
setInterval(() => {}, 1000);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
`,
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(result.memoryRisks ?? []).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { computeMetrics } from '../metrics';
|
|
3
|
+
import { detectCycles } from '../cycles';
|
|
4
|
+
import type { DependencyEdge, ModuleNode } from '../types';
|
|
5
|
+
|
|
6
|
+
const mod = (id: string, loc = 10): ModuleNode => ({
|
|
7
|
+
id,
|
|
8
|
+
absPath: id,
|
|
9
|
+
kind: 'unknown',
|
|
10
|
+
language: 'ts',
|
|
11
|
+
loc,
|
|
12
|
+
exports: [],
|
|
13
|
+
isInfra: false,
|
|
14
|
+
});
|
|
15
|
+
const edge = (from: string, to: string): DependencyEdge => ({
|
|
16
|
+
from,
|
|
17
|
+
to,
|
|
18
|
+
kind: 'static',
|
|
19
|
+
specifier: to,
|
|
20
|
+
resolved: true,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('computeMetrics', () => {
|
|
24
|
+
it('counts fan-in/out and instability', () => {
|
|
25
|
+
const modules = [mod('a'), mod('b'), mod('c')];
|
|
26
|
+
const edges = [edge('a', 'b'), edge('a', 'c'), edge('b', 'c')];
|
|
27
|
+
const m = computeMetrics({ modules, edges, cycles: [], entries: ['a'] });
|
|
28
|
+
expect(m['a']!.fanIn).toBe(0);
|
|
29
|
+
expect(m['a']!.fanOut).toBe(2);
|
|
30
|
+
expect(m['a']!.instability).toBe(1);
|
|
31
|
+
expect(m['b']!.fanIn).toBe(1);
|
|
32
|
+
expect(m['b']!.fanOut).toBe(1);
|
|
33
|
+
expect(m['b']!.instability).toBe(0.5);
|
|
34
|
+
expect(m['c']!.fanIn).toBe(2);
|
|
35
|
+
expect(m['c']!.fanOut).toBe(0);
|
|
36
|
+
expect(m['c']!.instability).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('marks inCycle and ranks coupling', () => {
|
|
40
|
+
const modules = [mod('a'), mod('b'), mod('c')];
|
|
41
|
+
const edges = [edge('a', 'b'), edge('b', 'a'), edge('a', 'c')];
|
|
42
|
+
const cycles = detectCycles(modules, edges);
|
|
43
|
+
const m = computeMetrics({ modules, edges, cycles, entries: [] });
|
|
44
|
+
expect(m['a']!.inCycle).toBe(true);
|
|
45
|
+
expect(m['b']!.inCycle).toBe(true);
|
|
46
|
+
expect(m['c']!.inCycle).toBe(false);
|
|
47
|
+
expect(m['a']!.couplingScore).toBeGreaterThan(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('computes depth from entry through DAG (after SCC condensation)', () => {
|
|
51
|
+
const modules = [mod('a'), mod('b'), mod('c'), mod('d')];
|
|
52
|
+
const edges = [edge('a', 'b'), edge('b', 'c'), edge('c', 'd')];
|
|
53
|
+
const m = computeMetrics({ modules, edges, cycles: [], entries: ['a'] });
|
|
54
|
+
expect(m['a']!.depth).toBe(0);
|
|
55
|
+
expect(m['b']!.depth).toBe(1);
|
|
56
|
+
expect(m['c']!.depth).toBe(2);
|
|
57
|
+
expect(m['d']!.depth).toBe(3);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createParserRegistry, isParseFailure } from '../parsers';
|
|
3
|
+
import type { ParsedFile } from '../types';
|
|
4
|
+
|
|
5
|
+
describe('parserRegistry', () => {
|
|
6
|
+
const registry = createParserRegistry();
|
|
7
|
+
|
|
8
|
+
it('routes .ts files to TS parser', () => {
|
|
9
|
+
const r = registry.parse({
|
|
10
|
+
relPath: 'a.ts',
|
|
11
|
+
content: "import { x } from './b';\nexport const a = x;",
|
|
12
|
+
});
|
|
13
|
+
expect(isParseFailure(r)).toBe(false);
|
|
14
|
+
const parsed = r as ParsedFile;
|
|
15
|
+
expect(parsed.language).toBe('ts');
|
|
16
|
+
expect(parsed.imports.map((i) => i.specifier)).toEqual(['./b']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('routes .tsx files to TS parser as ts language', () => {
|
|
20
|
+
const r = registry.parse({
|
|
21
|
+
relPath: 'a.tsx',
|
|
22
|
+
content: 'export const A = () => null;',
|
|
23
|
+
});
|
|
24
|
+
expect((r as ParsedFile).language).toBe('ts');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('routes .jsx/.mjs/.cjs files to TS parser as js language', () => {
|
|
28
|
+
for (const ext of ['jsx', 'mjs', 'cjs']) {
|
|
29
|
+
const r = registry.parse({ relPath: `a.${ext}`, content: 'export const a = 1;' });
|
|
30
|
+
expect((r as ParsedFile).language).toBe('js');
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('routes .vue files to Vue parser', () => {
|
|
35
|
+
const sfc = `<script setup lang="ts">
|
|
36
|
+
import { ref } from 'vue';
|
|
37
|
+
const x = ref(1);
|
|
38
|
+
</script>`;
|
|
39
|
+
const r = registry.parse({ relPath: 'A.vue', content: sfc });
|
|
40
|
+
expect(isParseFailure(r)).toBe(false);
|
|
41
|
+
expect((r as ParsedFile).language).toBe('vue');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns failure on unsupported extension', () => {
|
|
45
|
+
const r = registry.parse({ relPath: 'a.css', content: 'body { color: red; }' });
|
|
46
|
+
expect(isParseFailure(r)).toBe(true);
|
|
47
|
+
if (isParseFailure(r)) expect(r.reason).toBe('unsupported-extension');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns failure on extensionless files', () => {
|
|
51
|
+
const r = registry.parse({ relPath: 'README', content: '' });
|
|
52
|
+
expect(isParseFailure(r)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|