@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,132 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { classifyCyclePattern } from '../cyclePatterns';
|
|
3
|
+
import { feedbackArcSet, edgeKey } from '../feedbackArcSet';
|
|
4
|
+
import type { DependencyEdge } from '../types';
|
|
5
|
+
|
|
6
|
+
const e = (from: string, to: string, kind: DependencyEdge['kind'] = 'static'): DependencyEdge => ({
|
|
7
|
+
from,
|
|
8
|
+
to,
|
|
9
|
+
kind,
|
|
10
|
+
specifier: to,
|
|
11
|
+
resolved: true,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('classifyCyclePattern', () => {
|
|
15
|
+
it('detects mutual-pair', () => {
|
|
16
|
+
const internal = [e('a.ts', 'b.ts'), e('b.ts', 'a.ts')];
|
|
17
|
+
const fas = feedbackArcSet(['a.ts', 'b.ts'], internal);
|
|
18
|
+
const p = classifyCyclePattern({
|
|
19
|
+
scc: ['a.ts', 'b.ts'],
|
|
20
|
+
internalEdges: internal,
|
|
21
|
+
feedback: fas.feedback,
|
|
22
|
+
});
|
|
23
|
+
expect(p.kind).toBe('mutual-pair');
|
|
24
|
+
if (p.kind === 'mutual-pair') {
|
|
25
|
+
expect(p.a).toBe('a.ts');
|
|
26
|
+
expect(p.b).toBe('b.ts');
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('detects barrel-cycle (sibling imports through same-folder index)', () => {
|
|
31
|
+
// src/foo/index.ts imports src/foo/bar.ts; src/foo/bar.ts imports
|
|
32
|
+
// back through '.' (i.e. resolves to src/foo/index.ts)
|
|
33
|
+
const internal = [
|
|
34
|
+
e('src/foo/index.ts', 'src/foo/bar.ts'),
|
|
35
|
+
e('src/foo/bar.ts', 'src/foo/index.ts'),
|
|
36
|
+
];
|
|
37
|
+
const fas = feedbackArcSet(['src/foo/index.ts', 'src/foo/bar.ts'], internal);
|
|
38
|
+
const p = classifyCyclePattern({
|
|
39
|
+
scc: ['src/foo/index.ts', 'src/foo/bar.ts'],
|
|
40
|
+
internalEdges: internal,
|
|
41
|
+
feedback: fas.feedback,
|
|
42
|
+
});
|
|
43
|
+
// mutual-pair takes precedence for SCC of 2; verify the more interesting
|
|
44
|
+
// case below where SCC > 2 still routes to barrel
|
|
45
|
+
expect(['mutual-pair', 'barrel-cycle']).toContain(p.kind);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('detects barrel-cycle in 3-SCC with single feedback edge into sibling index', () => {
|
|
49
|
+
// a→b→c→index→a, where a is sibling of index in src/foo
|
|
50
|
+
const nodes = ['src/foo/index.ts', 'src/foo/a.ts', 'src/foo/b.ts'];
|
|
51
|
+
const internal = [
|
|
52
|
+
e('src/foo/index.ts', 'src/foo/a.ts'),
|
|
53
|
+
e('src/foo/a.ts', 'src/foo/b.ts'),
|
|
54
|
+
e('src/foo/b.ts', 'src/foo/index.ts'),
|
|
55
|
+
];
|
|
56
|
+
const fas = feedbackArcSet(nodes, internal);
|
|
57
|
+
expect(fas.feedback.size).toBe(1);
|
|
58
|
+
const p = classifyCyclePattern({ scc: nodes, internalEdges: internal, feedback: fas.feedback });
|
|
59
|
+
expect(p.kind).toBe('barrel-cycle');
|
|
60
|
+
if (p.kind === 'barrel-cycle') {
|
|
61
|
+
expect(p.barrel).toBe('src/foo/index.ts');
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('detects hub-feedback: ≥70% of feedback edges point into same hub', () => {
|
|
66
|
+
// 5 producers all back-edge into hub
|
|
67
|
+
const nodes = ['hub.ts', 'p1.ts', 'p2.ts', 'p3.ts', 'p4.ts', 'p5.ts'];
|
|
68
|
+
const internal: DependencyEdge[] = [];
|
|
69
|
+
for (const p of nodes.slice(1)) {
|
|
70
|
+
internal.push(e('hub.ts', p));
|
|
71
|
+
internal.push(e(p, 'hub.ts'));
|
|
72
|
+
}
|
|
73
|
+
const fas = feedbackArcSet(nodes, internal);
|
|
74
|
+
const p = classifyCyclePattern({
|
|
75
|
+
scc: nodes,
|
|
76
|
+
internalEdges: internal,
|
|
77
|
+
feedback: fas.feedback,
|
|
78
|
+
});
|
|
79
|
+
// FAS will pick one orientation; the hub is whichever side becomes target
|
|
80
|
+
expect(p.kind).toBe('hub-feedback');
|
|
81
|
+
if (p.kind === 'hub-feedback') {
|
|
82
|
+
expect(p.incomingCount).toBeGreaterThanOrEqual(4);
|
|
83
|
+
expect(p.valueImports).toBe(p.incomingCount); // all static
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('hub-feedback excludes type-only edges from valueImports count', () => {
|
|
88
|
+
const nodes = ['hub.ts', 'p1.ts', 'p2.ts', 'p3.ts', 'p4.ts'];
|
|
89
|
+
const internal: DependencyEdge[] = [];
|
|
90
|
+
for (const p of nodes.slice(1)) internal.push(e('hub.ts', p));
|
|
91
|
+
// back-edges: 3 static, 1 type-only
|
|
92
|
+
internal.push(e('p1.ts', 'hub.ts'));
|
|
93
|
+
internal.push(e('p2.ts', 'hub.ts'));
|
|
94
|
+
internal.push(e('p3.ts', 'hub.ts'));
|
|
95
|
+
internal.push(e('p4.ts', 'hub.ts', 'type-only'));
|
|
96
|
+
const fas = feedbackArcSet(nodes, internal);
|
|
97
|
+
const p = classifyCyclePattern({ scc: nodes, internalEdges: internal, feedback: fas.feedback });
|
|
98
|
+
expect(p.kind).toBe('hub-feedback');
|
|
99
|
+
if (p.kind === 'hub-feedback') {
|
|
100
|
+
// type-only edge isn't even in FAS - all 3 feedback edges are value
|
|
101
|
+
expect(p.valueImports).toBe(3);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('detects long-chain: large SCC with single feedback edge', () => {
|
|
106
|
+
// 10-node ring: chain of 10 + one back-edge from last to first
|
|
107
|
+
const nodes = Array.from({ length: 10 }, (_, i) => `n${i}.ts`);
|
|
108
|
+
const internal: DependencyEdge[] = [];
|
|
109
|
+
for (let i = 0; i < nodes.length - 1; i++) internal.push(e(nodes[i]!, nodes[i + 1]!));
|
|
110
|
+
internal.push(e(nodes[nodes.length - 1]!, nodes[0]!));
|
|
111
|
+
const fas = feedbackArcSet(nodes, internal);
|
|
112
|
+
expect(fas.feedback.size).toBe(1);
|
|
113
|
+
const p = classifyCyclePattern({ scc: nodes, internalEdges: internal, feedback: fas.feedback });
|
|
114
|
+
expect(p.kind).toBe('long-chain');
|
|
115
|
+
if (p.kind === 'long-chain') {
|
|
116
|
+
expect(p.length).toBe(10);
|
|
117
|
+
expect(p.bridge).toBe(edgeKey('n9.ts', 'n0.ts'));
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('falls back to mixed when no pattern fits', () => {
|
|
122
|
+
// 3-cycle a→b→c→a, no special structure (not mutual, not barrel, no hub)
|
|
123
|
+
const internal = [e('a.ts', 'b.ts'), e('b.ts', 'c.ts'), e('c.ts', 'a.ts')];
|
|
124
|
+
const fas = feedbackArcSet(['a.ts', 'b.ts', 'c.ts'], internal);
|
|
125
|
+
const p = classifyCyclePattern({
|
|
126
|
+
scc: ['a.ts', 'b.ts', 'c.ts'],
|
|
127
|
+
internalEdges: internal,
|
|
128
|
+
feedback: fas.feedback,
|
|
129
|
+
});
|
|
130
|
+
expect(p.kind).toBe('mixed');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { detectCycles } from '../cycles';
|
|
3
|
+
import type { DependencyEdge, ModuleNode } from '../types';
|
|
4
|
+
|
|
5
|
+
const mod = (id: string): ModuleNode => ({
|
|
6
|
+
id,
|
|
7
|
+
absPath: id,
|
|
8
|
+
kind: 'unknown',
|
|
9
|
+
language: 'ts',
|
|
10
|
+
loc: 1,
|
|
11
|
+
exports: [],
|
|
12
|
+
isInfra: false,
|
|
13
|
+
});
|
|
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('detectCycles', () => {
|
|
24
|
+
it('finds 2-cycle and marks it direct', () => {
|
|
25
|
+
const cycles = detectCycles([mod('a'), mod('b'), mod('c')], [edge('a', 'b'), edge('b', 'a')]);
|
|
26
|
+
expect(cycles).toHaveLength(1);
|
|
27
|
+
expect(cycles[0]?.severity).toBe('direct');
|
|
28
|
+
expect(cycles[0]?.modules.sort()).toEqual(['a', 'b']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('finds 3-cycle and marks it indirect', () => {
|
|
32
|
+
const cycles = detectCycles(
|
|
33
|
+
[mod('a'), mod('b'), mod('c')],
|
|
34
|
+
[edge('a', 'b'), edge('b', 'c'), edge('c', 'a')],
|
|
35
|
+
);
|
|
36
|
+
expect(cycles).toHaveLength(1);
|
|
37
|
+
expect(cycles[0]?.severity).toBe('indirect');
|
|
38
|
+
expect(cycles[0]?.length).toBe(3);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('ignores type-only edges', () => {
|
|
42
|
+
const cycles = detectCycles(
|
|
43
|
+
[mod('a'), mod('b')],
|
|
44
|
+
[{ from: 'a', to: 'b', kind: 'type-only', specifier: 'b', resolved: true }, edge('b', 'a')],
|
|
45
|
+
);
|
|
46
|
+
expect(cycles).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('detects self-import as length-1 direct cycle', () => {
|
|
50
|
+
const cycles = detectCycles([mod('a')], [edge('a', 'a')]);
|
|
51
|
+
expect(cycles).toHaveLength(1);
|
|
52
|
+
expect(cycles[0]?.length).toBe(1);
|
|
53
|
+
expect(cycles[0]?.severity).toBe('direct');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('produces a short stable content-addressed cycle id', () => {
|
|
57
|
+
// Regression: the old join('|') form produced 2kB+ ids on a real
|
|
58
|
+
// 53-module SCC, breaking UI keys, fix-plan ids and baselines.
|
|
59
|
+
const many = Array.from({ length: 40 }, (_, i) => `src/m${i}.ts`);
|
|
60
|
+
const mods = many.map(mod);
|
|
61
|
+
const edges = many.map((id, i) => edge(id, many[(i + 1) % many.length]!));
|
|
62
|
+
const cycles = detectCycles(mods, edges);
|
|
63
|
+
expect(cycles).toHaveLength(1);
|
|
64
|
+
const id = cycles[0]?.id ?? '';
|
|
65
|
+
expect(id).toMatch(/^cycle:[0-9a-f]{8}$/u);
|
|
66
|
+
expect(id.length).toBeLessThan(32);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('cycle id is stable across module insertion order', () => {
|
|
70
|
+
const a = detectCycles([mod('a'), mod('b'), mod('c')], [edge('a', 'b'), edge('b', 'a')]);
|
|
71
|
+
const b = detectCycles([mod('b'), mod('a'), mod('c')], [edge('b', 'a'), edge('a', 'b')]);
|
|
72
|
+
expect(a[0]?.id).toBe(b[0]?.id);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createInMemoryFileSource } from '../sources/inMemoryFileSource';
|
|
3
|
+
import { detectFramework } from '../detect';
|
|
4
|
+
|
|
5
|
+
const pkg = (deps: Record<string, string>, dev: Record<string, string> = {}): string =>
|
|
6
|
+
JSON.stringify({ dependencies: deps, devDependencies: dev });
|
|
7
|
+
|
|
8
|
+
describe('detectFramework', () => {
|
|
9
|
+
it('returns generic when package.json is missing', async () => {
|
|
10
|
+
const src = createInMemoryFileSource('/p', {});
|
|
11
|
+
expect(await detectFramework(src)).toEqual({ framework: 'generic', signals: [] });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('returns generic on malformed package.json', async () => {
|
|
15
|
+
const src = createInMemoryFileSource('/p', { 'package.json': '{ not json' });
|
|
16
|
+
expect(await detectFramework(src)).toEqual({ framework: 'generic', signals: [] });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('detects vue', async () => {
|
|
20
|
+
const src = createInMemoryFileSource('/p', { 'package.json': pkg({ vue: '^3.0.0' }) });
|
|
21
|
+
expect(await detectFramework(src)).toEqual({ framework: 'vue', signals: ['vue'] });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('detects react', async () => {
|
|
25
|
+
const src = createInMemoryFileSource('/p', {
|
|
26
|
+
'package.json': pkg({ react: '^18.0.0', 'react-dom': '^18.0.0' }),
|
|
27
|
+
});
|
|
28
|
+
expect(await detectFramework(src)).toEqual({ framework: 'react', signals: ['react'] });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('detects svelte', async () => {
|
|
32
|
+
const src = createInMemoryFileSource('/p', {
|
|
33
|
+
'package.json': pkg({}, { svelte: '^4.0.0' }),
|
|
34
|
+
});
|
|
35
|
+
expect(await detectFramework(src)).toEqual({ framework: 'svelte', signals: ['svelte'] });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('prefers nuxt over vue when both present', async () => {
|
|
39
|
+
const src = createInMemoryFileSource('/p', {
|
|
40
|
+
'package.json': pkg({ vue: '^3.0.0', nuxt: '^3.0.0' }),
|
|
41
|
+
});
|
|
42
|
+
const result = await detectFramework(src);
|
|
43
|
+
expect(result.framework).toBe('nuxt');
|
|
44
|
+
expect(result.signals).toEqual(expect.arrayContaining(['nuxt', 'vue']));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('prefers next over react when both present', async () => {
|
|
48
|
+
const src = createInMemoryFileSource('/p', {
|
|
49
|
+
'package.json': pkg({ react: '^18.0.0', next: '^14.0.0' }),
|
|
50
|
+
});
|
|
51
|
+
const result = await detectFramework(src);
|
|
52
|
+
expect(result.framework).toBe('next');
|
|
53
|
+
expect(result.signals).toEqual(expect.arrayContaining(['next', 'react']));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('reads devDependencies as well as dependencies', async () => {
|
|
57
|
+
const src = createInMemoryFileSource('/p', {
|
|
58
|
+
'package.json': JSON.stringify({ devDependencies: { vue: '^3.0.0' } }),
|
|
59
|
+
});
|
|
60
|
+
expect((await detectFramework(src)).framework).toBe('vue');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { FileSource } from '../fileSource';
|
|
3
|
+
import { createNodeFsFileSource } from '../sources/nodeFsFileSource';
|
|
4
|
+
import { discoverFiles } from '../discover';
|
|
5
|
+
import { fixturePath } from './_paths';
|
|
6
|
+
|
|
7
|
+
function inMemorySource(files: string[]): FileSource {
|
|
8
|
+
return {
|
|
9
|
+
rootPath: '/virtual',
|
|
10
|
+
list: () => Promise.resolve(files),
|
|
11
|
+
read: () => Promise.reject(new Error('read not used')),
|
|
12
|
+
exists: (p) => Promise.resolve(files.includes(p)),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('discover', () => {
|
|
17
|
+
it('lists supported files and skips node_modules-like dirs', async () => {
|
|
18
|
+
const src = await createNodeFsFileSource({ rootPath: fixturePath('sample-vue-app') });
|
|
19
|
+
const { files, byExt } = await discoverFiles(src);
|
|
20
|
+
expect(files.length).toBeGreaterThan(20);
|
|
21
|
+
expect(files.every((f) => !f.startsWith('node_modules'))).toBe(true);
|
|
22
|
+
expect(byExt['.vue']).toBeGreaterThan(0);
|
|
23
|
+
expect(byExt['.ts']).toBeGreaterThan(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('excludes tests, stories, declarations and configs by default', async () => {
|
|
27
|
+
const src = inMemorySource([
|
|
28
|
+
'src/foo.ts',
|
|
29
|
+
'src/foo.test.ts',
|
|
30
|
+
'src/foo.spec.tsx',
|
|
31
|
+
'src/__tests__/bar.ts',
|
|
32
|
+
'src/__mocks__/baz.ts',
|
|
33
|
+
'src/__snapshots__/snap.ts',
|
|
34
|
+
'src/Button.stories.ts',
|
|
35
|
+
'src/Button.stories.vue',
|
|
36
|
+
'types/global.d.ts',
|
|
37
|
+
'types/global.d.mts',
|
|
38
|
+
'vite.config.ts',
|
|
39
|
+
'vitest.config.mts',
|
|
40
|
+
'eslint.config.js',
|
|
41
|
+
'tailwind.config.cjs',
|
|
42
|
+
'cypress/e2e/login.cy.ts',
|
|
43
|
+
'playwright/test.spec.ts',
|
|
44
|
+
'e2e/login.ts',
|
|
45
|
+
'.storybook/main.ts',
|
|
46
|
+
'src/keep.vue',
|
|
47
|
+
]);
|
|
48
|
+
const { files, skipped } = await discoverFiles(src);
|
|
49
|
+
expect(files.sort()).toEqual(['src/foo.ts', 'src/keep.vue']);
|
|
50
|
+
expect(skipped).toBe(17);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('respects opt-out flags', async () => {
|
|
54
|
+
const src = inMemorySource([
|
|
55
|
+
'src/foo.test.ts',
|
|
56
|
+
'src/Button.stories.ts',
|
|
57
|
+
'a.d.ts',
|
|
58
|
+
'vite.config.ts',
|
|
59
|
+
]);
|
|
60
|
+
const { files } = await discoverFiles(src, {
|
|
61
|
+
excludeTests: false,
|
|
62
|
+
excludeStories: false,
|
|
63
|
+
excludeDeclarations: false,
|
|
64
|
+
excludeConfigs: false,
|
|
65
|
+
});
|
|
66
|
+
expect(files).toHaveLength(4);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { displayShortId } from '../displayId';
|
|
3
|
+
|
|
4
|
+
describe('displayShortId', () => {
|
|
5
|
+
it('returns bare basename for unique filenames', () => {
|
|
6
|
+
expect(displayShortId('src/utils/cn.ts')).toBe('cn.ts');
|
|
7
|
+
expect(displayShortId('src/components/UserCard.vue')).toBe('UserCard.vue');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('prepends parent for ambiguous index.* files', () => {
|
|
11
|
+
expect(displayShortId('src/router/index.ts')).toBe('router/index.ts');
|
|
12
|
+
expect(displayShortId('packages/core/src/index.ts')).toBe('src/index.ts');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('prepends parent for SvelteKit special files', () => {
|
|
16
|
+
expect(displayShortId('src/routes/users/+page.svelte')).toBe('users/+page.svelte');
|
|
17
|
+
expect(displayShortId('src/routes/api/+server.ts')).toBe('api/+server.ts');
|
|
18
|
+
expect(displayShortId('src/routes/+layout.ts')).toBe('routes/+layout.ts');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('prepends parent for main/app/root', () => {
|
|
22
|
+
expect(displayShortId('src/main.ts')).toBe('src/main.ts');
|
|
23
|
+
expect(displayShortId('src/app.vue')).toBe('src/app.vue');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns id as-is when there is no slash', () => {
|
|
27
|
+
expect(displayShortId('main.ts')).toBe('main.ts');
|
|
28
|
+
expect(displayShortId('userService')).toBe('userService');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { feedbackArcSet, countBrokenCycles, edgeKey, parseEdgeKey } from '../feedbackArcSet';
|
|
3
|
+
import type { DependencyEdge } from '../types';
|
|
4
|
+
|
|
5
|
+
const e = (from: string, to: string, kind: DependencyEdge['kind'] = 'static'): DependencyEdge => ({
|
|
6
|
+
from,
|
|
7
|
+
to,
|
|
8
|
+
kind,
|
|
9
|
+
specifier: to,
|
|
10
|
+
resolved: true,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('feedbackArcSet', () => {
|
|
14
|
+
it('breaks a 2-cycle with one edge', () => {
|
|
15
|
+
const r = feedbackArcSet(['a', 'b'], [e('a', 'b'), e('b', 'a')]);
|
|
16
|
+
expect(r.feedback.size).toBe(1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('breaks a 3-cycle with one edge', () => {
|
|
20
|
+
const r = feedbackArcSet(['a', 'b', 'c'], [e('a', 'b'), e('b', 'c'), e('c', 'a')]);
|
|
21
|
+
expect(r.feedback.size).toBe(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('breaks two independent loops sharing a hub', () => {
|
|
25
|
+
// x→hub, hub→a→x, hub→b→x : two cycles (hub-a-x-hub) and (hub-b-x-hub)
|
|
26
|
+
// but x and hub form an SCC of {x, hub, a, b}; both loops close via x→hub.
|
|
27
|
+
// Greedy FAS should pick a single feedback edge (most likely x→hub) that
|
|
28
|
+
// breaks both loops.
|
|
29
|
+
const edges = [e('x', 'hub'), e('hub', 'a'), e('a', 'x'), e('hub', 'b'), e('b', 'x')];
|
|
30
|
+
const r = feedbackArcSet(['x', 'hub', 'a', 'b'], edges);
|
|
31
|
+
expect(r.feedback.size).toBeGreaterThanOrEqual(1);
|
|
32
|
+
expect(r.feedback.size).toBeLessThanOrEqual(2);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('hub-pointing pattern: many feedback edges target the same hub', () => {
|
|
36
|
+
// 4 producers all feed back into a single hub h that they were reached from
|
|
37
|
+
// h → p1, p2, p3, p4 (producers); each producer → h (back edge)
|
|
38
|
+
const edges = [
|
|
39
|
+
e('h', 'p1'),
|
|
40
|
+
e('h', 'p2'),
|
|
41
|
+
e('h', 'p3'),
|
|
42
|
+
e('h', 'p4'),
|
|
43
|
+
e('p1', 'h'),
|
|
44
|
+
e('p2', 'h'),
|
|
45
|
+
e('p3', 'h'),
|
|
46
|
+
e('p4', 'h'),
|
|
47
|
+
];
|
|
48
|
+
const r = feedbackArcSet(['h', 'p1', 'p2', 'p3', 'p4'], edges);
|
|
49
|
+
// either direction (forward or backward) makes all edges feedback - we
|
|
50
|
+
// accept whichever orientation FAS picks as long as it's consistent
|
|
51
|
+
const targets = [...r.feedback].map((k) => parseEdgeKey(k).to);
|
|
52
|
+
const uniqueTargets = new Set(targets);
|
|
53
|
+
expect(uniqueTargets.size).toBe(1);
|
|
54
|
+
expect(r.feedback.size).toBe(4);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('handles self-loop on singleton SCC', () => {
|
|
58
|
+
const r = feedbackArcSet(['a'], [e('a', 'a')]);
|
|
59
|
+
expect([...r.feedback]).toEqual([edgeKey('a', 'a')]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('ignores type-only edges', () => {
|
|
63
|
+
const r = feedbackArcSet(['a', 'b'], [e('a', 'b'), e('b', 'a', 'type-only')]);
|
|
64
|
+
// SCC isn't actually strongly connected without the type-only edge, but
|
|
65
|
+
// FAS doesn't re-validate - with only a→b internal there are no feedback
|
|
66
|
+
// edges (it's a DAG).
|
|
67
|
+
expect(r.feedback.size).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('dedupes parallel edges with the same direction', () => {
|
|
71
|
+
const edges = [e('a', 'b'), e('a', 'b', 'dynamic'), e('b', 'a')];
|
|
72
|
+
const r = feedbackArcSet(['a', 'b'], edges);
|
|
73
|
+
expect(r.internal.length).toBe(2); // one a→b, one b→a
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('produces feedback set whose removal makes the SCC acyclic', () => {
|
|
77
|
+
// dense SCC: complete digraph K4 (all 12 directed edges)
|
|
78
|
+
const nodes = ['a', 'b', 'c', 'd'];
|
|
79
|
+
const edges: DependencyEdge[] = [];
|
|
80
|
+
for (const a of nodes) for (const b of nodes) if (a !== b) edges.push(e(a, b));
|
|
81
|
+
const r = feedbackArcSet(nodes, edges);
|
|
82
|
+
|
|
83
|
+
// verify: in r.order, no edge points backwards (other than feedback)
|
|
84
|
+
const pos = new Map(r.order.map((id, i) => [id, i]));
|
|
85
|
+
const backward = r.internal.filter(
|
|
86
|
+
(ed) => ed.from !== ed.to && (pos.get(ed.from) ?? 0) > (pos.get(ed.to) ?? 0),
|
|
87
|
+
);
|
|
88
|
+
const backwardKeys = new Set(backward.map((ed) => edgeKey(ed.from, ed.to)));
|
|
89
|
+
expect(backwardKeys).toEqual(r.feedback);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('countBrokenCycles', () => {
|
|
94
|
+
it('exact count for small SCC: 2-cycle = 1 cycle broken', () => {
|
|
95
|
+
const internal = [e('a', 'b'), e('b', 'a')];
|
|
96
|
+
const fas = feedbackArcSet(['a', 'b'], internal);
|
|
97
|
+
const r = countBrokenCycles(['a', 'b'], internal, fas.feedback);
|
|
98
|
+
expect(r.totalCycles).toBe(1);
|
|
99
|
+
expect(r.totalPartial).toBe(false);
|
|
100
|
+
const only = [...fas.feedback][0]!;
|
|
101
|
+
expect(r.byEdge.get(only)?.broken).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('exact count for two independent 3-cycles sharing one node', () => {
|
|
105
|
+
// a→b→c→a and a→d→e→a : two 3-cycles, removing edge into a (the shared
|
|
106
|
+
// node) breaks both
|
|
107
|
+
const internal = [e('a', 'b'), e('b', 'c'), e('c', 'a'), e('a', 'd'), e('d', 'e'), e('e', 'a')];
|
|
108
|
+
const fas = feedbackArcSet(['a', 'b', 'c', 'd', 'e'], internal);
|
|
109
|
+
const r = countBrokenCycles(['a', 'b', 'c', 'd', 'e'], internal, fas.feedback);
|
|
110
|
+
expect(r.totalCycles).toBe(2);
|
|
111
|
+
expect(r.totalPartial).toBe(false);
|
|
112
|
+
// sum over feedback edges should equal at least the number of cycles
|
|
113
|
+
// (each cycle has at least one feedback edge along it)
|
|
114
|
+
const totalBroken = [...r.byEdge.values()].reduce((s, v) => s + v.broken, 0);
|
|
115
|
+
expect(totalBroken).toBeGreaterThanOrEqual(2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('terminates on a dense large SCC instead of enumerating exponentially', () => {
|
|
119
|
+
// Near-complete digraph on 30 nodes: ~870 edges, and the number of simple
|
|
120
|
+
// paths between any two nodes is ~e·28! (≈1e29). Without a DFS step budget,
|
|
121
|
+
// countSimplePaths explores that space and never returns. The budget must
|
|
122
|
+
// bound the work so a real-world dense cyclic cluster can't hang the scan.
|
|
123
|
+
const N = 30;
|
|
124
|
+
const nodes = Array.from({ length: N }, (_, i) => `n${i}`);
|
|
125
|
+
const internal: DependencyEdge[] = [];
|
|
126
|
+
for (const a of nodes) for (const b of nodes) if (a !== b) internal.push(e(a, b));
|
|
127
|
+
const fas = feedbackArcSet(nodes, internal);
|
|
128
|
+
const started = Date.now();
|
|
129
|
+
const r = countBrokenCycles(nodes, internal, fas.feedback, { stepCap: 5000 });
|
|
130
|
+
// The assertion that matters is that we got here at all (no hang). Keep a
|
|
131
|
+
// generous wall-clock guard so a regression that reintroduces the blowup
|
|
132
|
+
// fails loudly rather than wedging the suite.
|
|
133
|
+
expect(Date.now() - started).toBeLessThan(3000);
|
|
134
|
+
expect(r.totalPartial).toBe(true);
|
|
135
|
+
expect(r.byEdge.size).toBe(fas.feedback.size);
|
|
136
|
+
}, 8000);
|
|
137
|
+
|
|
138
|
+
it('caps elementary-cycle enumeration on a dense sub-limit SCC', () => {
|
|
139
|
+
// Complete digraph on 12 nodes (≤ exactLimit) has ~e·11! (≈1e8) elementary
|
|
140
|
+
// cycles. Johnson enumeration must stop at the cycle cap and report partial
|
|
141
|
+
// rather than enumerating all of them.
|
|
142
|
+
const N = 12;
|
|
143
|
+
const nodes = Array.from({ length: N }, (_, i) => `n${i}`);
|
|
144
|
+
const internal: DependencyEdge[] = [];
|
|
145
|
+
for (const a of nodes) for (const b of nodes) if (a !== b) internal.push(e(a, b));
|
|
146
|
+
const fas = feedbackArcSet(nodes, internal);
|
|
147
|
+
const started = Date.now();
|
|
148
|
+
const r = countBrokenCycles(nodes, internal, fas.feedback, { exactLimit: 20, cycleCap: 5000 });
|
|
149
|
+
expect(Date.now() - started).toBeLessThan(3000);
|
|
150
|
+
expect(r.totalPartial).toBe(true);
|
|
151
|
+
expect(r.totalCycles).toBe(-1);
|
|
152
|
+
}, 8000);
|
|
153
|
+
|
|
154
|
+
it('partial count for large SCC', () => {
|
|
155
|
+
// construct a 25-node SCC: linear chain plus back-edge from last to first
|
|
156
|
+
const nodes = Array.from({ length: 25 }, (_, i) => `n${i}`);
|
|
157
|
+
const internal: DependencyEdge[] = [];
|
|
158
|
+
for (let i = 0; i < nodes.length - 1; i++) internal.push(e(nodes[i]!, nodes[i + 1]!));
|
|
159
|
+
internal.push(e(nodes[nodes.length - 1]!, nodes[0]!));
|
|
160
|
+
const fas = feedbackArcSet(nodes, internal);
|
|
161
|
+
const r = countBrokenCycles(nodes, internal, fas.feedback);
|
|
162
|
+
expect(r.totalCycles).toBe(-1);
|
|
163
|
+
expect(r.totalPartial).toBe(true);
|
|
164
|
+
// the single back-edge breaks exactly one elementary cycle
|
|
165
|
+
const only = [...fas.feedback][0]!;
|
|
166
|
+
expect(r.byEdge.get(only)?.broken).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createInMemoryFileSource } from '../sources/inMemoryFileSource';
|
|
3
|
+
import { analyze } from '../index';
|
|
4
|
+
|
|
5
|
+
describe('inMemoryFileSource', () => {
|
|
6
|
+
it('lists, reads, and reports existence', async () => {
|
|
7
|
+
const src = createInMemoryFileSource('/proj', {
|
|
8
|
+
'a.ts': 'export const a = 1;',
|
|
9
|
+
'b.ts': 'export const b = 2;',
|
|
10
|
+
});
|
|
11
|
+
expect(await src.list()).toEqual(['a.ts', 'b.ts']);
|
|
12
|
+
expect(await src.read('a.ts')).toBe('export const a = 1;');
|
|
13
|
+
expect(await src.exists('a.ts')).toBe(true);
|
|
14
|
+
expect(await src.exists('missing.ts')).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('throws on read of missing file', async () => {
|
|
18
|
+
const src = createInMemoryFileSource('/proj', {});
|
|
19
|
+
await expect(src.read('x.ts')).rejects.toThrow();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('feeds the full analyzer pipeline end-to-end', async () => {
|
|
23
|
+
const src = createInMemoryFileSource('/proj', {
|
|
24
|
+
'package.json': JSON.stringify({ dependencies: { vue: '^3.0.0' } }),
|
|
25
|
+
'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.' } }),
|
|
26
|
+
'src/a.ts': "import { b } from './b';\nexport const a = b + 1;",
|
|
27
|
+
'src/b.ts': 'export const b = 2;',
|
|
28
|
+
});
|
|
29
|
+
const result = await analyze(src);
|
|
30
|
+
expect(result.project.detectedFramework).toBe('vue');
|
|
31
|
+
expect(result.modules.map((m) => m.id).sort()).toEqual(['src/a.ts', 'src/b.ts']);
|
|
32
|
+
expect(result.edges.some((e) => e.from === 'src/a.ts' && e.to === 'src/b.ts')).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|