@archora/core 1.1.0 → 1.3.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/README.md +5 -3
- package/package.json +1 -1
- package/src/analyzer/__tests__/analyze.test.ts +41 -0
- package/src/analyzer/__tests__/bundle.test.ts +99 -0
- package/src/analyzer/__tests__/incremental.test.ts +61 -13
- package/src/analyzer/__tests__/layerViolationAccuracy.test.ts +77 -0
- package/src/analyzer/__tests__/metrics.test.ts +39 -0
- package/src/analyzer/__tests__/nuxtComposableAutoImport.test.ts +109 -0
- package/src/analyzer/__tests__/reactParser.test.ts +22 -0
- package/src/analyzer/__tests__/resolve.test.ts +27 -0
- package/src/analyzer/__tests__/rsc.test.ts +71 -0
- package/src/analyzer/archDebt.ts +32 -9
- package/src/analyzer/buildGraph.ts +73 -2
- package/src/analyzer/bundle/analyzeBundle.ts +84 -1
- package/src/analyzer/bundle/types.ts +9 -1
- package/src/analyzer/incremental.ts +26 -9
- package/src/analyzer/index.ts +1 -0
- package/src/analyzer/loadAliases.ts +4 -4
- package/src/analyzer/metrics.ts +10 -1
- package/src/analyzer/parsers/svelteParser.ts +5 -0
- package/src/analyzer/parsers/tsParser.ts +11 -1
- package/src/analyzer/recommendations.ts +13 -3
- package/src/analyzer/resolve.ts +22 -14
- package/src/analyzer/rsc.ts +73 -9
- package/src/analyzer/sources/browserFsAccessFileSource.ts +1 -1
- package/src/analyzer/sources/nodeFsFileSource.ts +1 -1
- package/src/analyzer/sources/tauriFileSource.ts +2 -2
- package/src/analyzer/types.ts +5 -0
- package/src/git/__tests__/computeTemporalCoupling.test.ts +24 -0
- package/src/git/computeTemporalCoupling.ts +30 -3
- package/src/git/types.ts +14 -1
- package/src/search/__tests__/parseQuery.test.ts +13 -13
- package/src/search/__tests__/search.test.ts +19 -19
- package/src/search/index.ts +39 -39
- package/src/search/parseQuery.ts +13 -13
package/README.md
CHANGED
|
@@ -31,13 +31,15 @@ parsing, output formatting, and CI exit codes.
|
|
|
31
31
|
|
|
32
32
|
## What it computes
|
|
33
33
|
|
|
34
|
-
- **Dependency graph** — parses `.ts`, `.tsx`, `.vue`
|
|
34
|
+
- **Dependency graph** — parses `.ts`, `.tsx`, `.js`, `.jsx`, `.vue` (and `.svelte`, beta), resolves `tsconfig` path aliases including recursive alias→alias chains
|
|
35
|
+
- **Dynamic & framework-auto edges** — `import()`, `React.lazy`, `next/dynamic`, Vue/Nuxt component and Nuxt `composables/` auto-imports
|
|
35
36
|
- **Cycles** — Tarjan's SCC; classifies as direct (length ≤ 2) or indirect
|
|
36
37
|
- **Per-module metrics** — fan-in, fan-out, instability, depth, coupling, hotness score
|
|
37
38
|
- **Layer violations** — FSD-style rules (`shared → entities → features → widgets → pages → app`)
|
|
38
39
|
- **Contract checks** — boundary rules, package budgets, API stability, bundle thresholds from `.archora.json`
|
|
39
|
-
- **
|
|
40
|
-
- **
|
|
40
|
+
- **RSC boundary leaks** — server/client runtime from directives, `server-only`/`client-only` packages and framework conventions; flags `client → server` imports, direct and transitive
|
|
41
|
+
- **Bundle signals** — duplicated modules, heavy chunks, solo-hot modules, barrel tree-shaking leaks (from webpack/rollup stats)
|
|
42
|
+
- **Temporal coupling** — modules that change together without a static edge (from `git log`), ranked by risk
|
|
41
43
|
|
|
42
44
|
## FileSource
|
|
43
45
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@archora/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Framework-independent analyzer core for Archora. Builds the dependency model, detects cycles, computes metrics and recommendations from any FileSource. No Vue, Pinia or Tauri dependencies.",
|
|
6
6
|
"author": "Archora <archora@archora.dev>",
|
|
@@ -520,3 +520,44 @@ describe('analyze: phantom type-only cycles', () => {
|
|
|
520
520
|
expect(result.cycles.some((c) => c.modules.length === 2)).toBe(true);
|
|
521
521
|
});
|
|
522
522
|
});
|
|
523
|
+
|
|
524
|
+
describe('analyze: robustness to broken inputs', () => {
|
|
525
|
+
it('does not crash on malformed files and still analyzes the valid ones', async () => {
|
|
526
|
+
const source = createInMemoryFileSource('/proj', {
|
|
527
|
+
'package.json': JSON.stringify({ name: 'p', version: '0.0.0', dependencies: { vue: '^3' } }),
|
|
528
|
+
'tsconfig.json': '{}',
|
|
529
|
+
'src/good.ts': `import { dep } from './dep';\nexport const good = (): number => dep();\n`,
|
|
530
|
+
'src/dep.ts': `export const dep = (): number => 1;\n`,
|
|
531
|
+
// unterminated string / garbage TS
|
|
532
|
+
'src/brokenTs.ts': `export const x = "unterminated\nconst y = {{{ <<< @@@ ;`,
|
|
533
|
+
// malformed Vue SFC (unclosed script, broken template)
|
|
534
|
+
'src/Broken.vue': `<script setup lang="ts">\nimport { z from './nowhere'\n<template><div></span></template>`,
|
|
535
|
+
// empty file
|
|
536
|
+
'src/empty.ts': ``,
|
|
537
|
+
// NUL / control bytes
|
|
538
|
+
'src/binary.ts': ` not really code `,
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// Must resolve, not throw.
|
|
542
|
+
const result = await analyze(source);
|
|
543
|
+
|
|
544
|
+
// The valid pair survived and its edge was resolved despite broken neighbors.
|
|
545
|
+
expect(result.modules.some((m) => m.id === 'src/good.ts')).toBe(true);
|
|
546
|
+
expect(result.edges.some((e) => e.from === 'src/good.ts' && e.to === 'src/dep.ts')).toBe(true);
|
|
547
|
+
// Warnings are accumulated, never thrown.
|
|
548
|
+
expect(Array.isArray(result.warnings)).toBe(true);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('survives a file that exceeds the size cap without aborting the scan', async () => {
|
|
552
|
+
const huge = `export const big = "${'x'.repeat(1_100_000)}";\n`;
|
|
553
|
+
const source = createInMemoryFileSource('/proj', {
|
|
554
|
+
'package.json': JSON.stringify({ name: 'p', version: '0.0.0' }),
|
|
555
|
+
'tsconfig.json': '{}',
|
|
556
|
+
'src/good.ts': `export const good = 1;\n`,
|
|
557
|
+
'src/huge.ts': huge,
|
|
558
|
+
});
|
|
559
|
+
const result = await analyze(source);
|
|
560
|
+
expect(result.modules.some((m) => m.id === 'src/good.ts')).toBe(true);
|
|
561
|
+
expect(result.warnings.some((w) => w.file === 'src/huge.ts')).toBe(true);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
@@ -189,3 +189,102 @@ describe('analyzeBundle', () => {
|
|
|
189
189
|
expect(Object.keys(report.moduleToChunks)).toHaveLength(0);
|
|
190
190
|
});
|
|
191
191
|
});
|
|
192
|
+
|
|
193
|
+
describe('analyzeBundle: barrel-leak (graph × bundle)', () => {
|
|
194
|
+
const siblings = Array.from({ length: 8 }, (_, i) => `src/ui/C${i + 1}.ts`);
|
|
195
|
+
const barrelModules = [
|
|
196
|
+
mod('src/ui/index.ts'),
|
|
197
|
+
...siblings.map((s) => mod(s)),
|
|
198
|
+
mod('src/app/Page.tsx'),
|
|
199
|
+
];
|
|
200
|
+
const barrelEdges = [
|
|
201
|
+
...siblings.map((s) => ({
|
|
202
|
+
from: 'src/ui/index.ts',
|
|
203
|
+
to: s,
|
|
204
|
+
kind: 'static' as const,
|
|
205
|
+
specifier: `./${s.slice('src/ui/'.length, -'.ts'.length)}`,
|
|
206
|
+
resolved: true,
|
|
207
|
+
})),
|
|
208
|
+
{
|
|
209
|
+
from: 'src/app/Page.tsx',
|
|
210
|
+
to: 'src/ui/index.ts',
|
|
211
|
+
kind: 'static' as const,
|
|
212
|
+
specifier: '@/ui',
|
|
213
|
+
resolved: true,
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
it('flags a barrel that pulls its whole directory into one chunk', () => {
|
|
218
|
+
const stats = parseBundleStats(
|
|
219
|
+
{
|
|
220
|
+
chunks: [
|
|
221
|
+
{
|
|
222
|
+
id: 'main',
|
|
223
|
+
files: ['main.js'],
|
|
224
|
+
size: 100_000,
|
|
225
|
+
modules: [
|
|
226
|
+
{ name: 'src/ui/index.ts', size: 1_000 },
|
|
227
|
+
...siblings.map((s) => ({ name: s, size: 5_000 })),
|
|
228
|
+
{ name: 'src/app/Page.tsx', size: 2_000 },
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
{ rootPath: '/repo' },
|
|
234
|
+
);
|
|
235
|
+
const report = analyzeBundle({ modules: barrelModules, edges: barrelEdges, stats });
|
|
236
|
+
const leak = report.bloat.find((b) => b.kind === 'barrel-leak');
|
|
237
|
+
expect(leak).toBeDefined();
|
|
238
|
+
expect(leak?.modules).toEqual(['src/ui/index.ts']);
|
|
239
|
+
expect(leak?.detail?.moduleCount).toBe(8);
|
|
240
|
+
expect(leak?.detail?.sizeBytes).toBe(40_000);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('does not flag when only a few siblings co-locate with the barrel', () => {
|
|
244
|
+
const stats = parseBundleStats(
|
|
245
|
+
{
|
|
246
|
+
chunks: [
|
|
247
|
+
{
|
|
248
|
+
id: 'main',
|
|
249
|
+
files: ['main.js'],
|
|
250
|
+
size: 30_000,
|
|
251
|
+
modules: [
|
|
252
|
+
{ name: 'src/ui/index.ts', size: 1_000 },
|
|
253
|
+
...siblings.slice(0, 3).map((s) => ({ name: s, size: 5_000 })),
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: 'other',
|
|
258
|
+
files: ['other.js'],
|
|
259
|
+
size: 25_000,
|
|
260
|
+
modules: siblings.slice(3).map((s) => ({ name: s, size: 5_000 })),
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
{ rootPath: '/repo' },
|
|
265
|
+
);
|
|
266
|
+
const report = analyzeBundle({ modules: barrelModules, edges: barrelEdges, stats });
|
|
267
|
+
expect(report.bloat.some((b) => b.kind === 'barrel-leak')).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('skips barrel-leak detection when no edges are supplied', () => {
|
|
271
|
+
const stats = parseBundleStats(
|
|
272
|
+
{
|
|
273
|
+
chunks: [
|
|
274
|
+
{
|
|
275
|
+
id: 'main',
|
|
276
|
+
files: ['main.js'],
|
|
277
|
+
size: 100_000,
|
|
278
|
+
modules: [
|
|
279
|
+
{ name: 'src/ui/index.ts', size: 1_000 },
|
|
280
|
+
...siblings.map((s) => ({ name: s, size: 5_000 })),
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
{ rootPath: '/repo' },
|
|
286
|
+
);
|
|
287
|
+
const report = analyzeBundle({ modules: barrelModules, stats });
|
|
288
|
+
expect(report.bloat.some((b) => b.kind === 'barrel-leak')).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// (modules/edges/cycles/metrics)
|
|
1
|
+
// Contract: incrementalAnalyze must either return a result equivalent to a
|
|
2
|
+
// full `analyze()` after applying the same changes to the source, or
|
|
3
|
+
// transparently delegate to analyze(). In both cases the downstream data
|
|
4
|
+
// (modules/edges/cycles/metrics) must match the post-state of the source.
|
|
5
5
|
|
|
6
6
|
import { describe, expect, it } from 'vitest';
|
|
7
7
|
import { analyze } from '../index';
|
|
@@ -16,7 +16,7 @@ function project(files: Record<string, string>) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
describe('incrementalAnalyze', () => {
|
|
19
|
-
it('
|
|
19
|
+
it('re-parsing a single file yields the same result as a full analyze of the post-state', async () => {
|
|
20
20
|
const before = {
|
|
21
21
|
'src/a.ts': "import { b } from '@/b';\nexport const a = b;\n",
|
|
22
22
|
'src/b.ts': 'export const b = 1;\n',
|
|
@@ -32,13 +32,13 @@ describe('incrementalAnalyze', () => {
|
|
|
32
32
|
changedFiles: ['src/a.ts'],
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
//
|
|
35
|
+
// structural equality on the key fields (durationMs/scannedAt dropped)
|
|
36
36
|
expect(sortEdges(got.edges)).toEqual(sortEdges(expected.edges));
|
|
37
37
|
expect(got.modules.find((m) => m.id === 'src/a.ts')?.exports).toEqual(['a']);
|
|
38
38
|
expect(got.cycles).toEqual(expected.cycles);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
it('
|
|
41
|
+
it('a change to module shape (loc, exports) lands in the result', async () => {
|
|
42
42
|
const before = {
|
|
43
43
|
'src/util.ts': 'export const x = 1;\n',
|
|
44
44
|
'src/main.ts': "import { x } from '@/util';\nexport default x;\n",
|
|
@@ -61,7 +61,7 @@ describe('incrementalAnalyze', () => {
|
|
|
61
61
|
expect(util?.loc).toBe(prev.modules.find((m) => m.id === 'src/util.ts')!.loc + 2);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
it('
|
|
64
|
+
it('a cycle appearing after an edit is detected', async () => {
|
|
65
65
|
const before = {
|
|
66
66
|
'src/a.ts': "import { b } from '@/b';\nexport const a = b;\n",
|
|
67
67
|
'src/b.ts': 'export const b = 1;\n',
|
|
@@ -82,7 +82,7 @@ describe('incrementalAnalyze', () => {
|
|
|
82
82
|
expect(got.cycles[0]?.modules.sort()).toEqual(['src/a.ts', 'src/b.ts']);
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
it('
|
|
85
|
+
it('a config file change → fallback to a full analyze', async () => {
|
|
86
86
|
const before = {
|
|
87
87
|
'src/a.ts': 'export const a = 1;\n',
|
|
88
88
|
};
|
|
@@ -98,12 +98,12 @@ describe('incrementalAnalyze', () => {
|
|
|
98
98
|
source: newSource,
|
|
99
99
|
changedFiles: ['tsconfig.json', 'src/b.ts'],
|
|
100
100
|
});
|
|
101
|
-
//
|
|
101
|
+
// if it went through analyze, the new alias and new file are picked up
|
|
102
102
|
expect(got.modules.map((m) => m.id).sort()).toEqual(['src/a.ts', 'src/b.ts']);
|
|
103
103
|
expect(got.edges.find((e) => e.from === 'src/b.ts')?.to).toBe('src/a.ts');
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
it('
|
|
106
|
+
it('a new file (not in prev) → fallback to a full analyze', async () => {
|
|
107
107
|
const before = {
|
|
108
108
|
'src/a.ts': 'export const a = 1;\n',
|
|
109
109
|
};
|
|
@@ -121,7 +121,7 @@ describe('incrementalAnalyze', () => {
|
|
|
121
121
|
expect(got.modules.map((m) => m.id).sort()).toEqual(['src/a.ts', 'src/new.ts']);
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
-
it('
|
|
124
|
+
it('a deleted file → fallback to a full analyze', async () => {
|
|
125
125
|
const before = {
|
|
126
126
|
'src/a.ts': "import { b } from '@/b';\nexport const a = b;\n",
|
|
127
127
|
'src/b.ts': 'export const b = 1;\n',
|
|
@@ -137,7 +137,55 @@ describe('incrementalAnalyze', () => {
|
|
|
137
137
|
expect(got.modules.map((m) => m.id)).toEqual(['src/a.ts']);
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
it('
|
|
140
|
+
it('incremental preserves runtime from the server-only package (== cold)', async () => {
|
|
141
|
+
const before = {
|
|
142
|
+
'package.json': JSON.stringify({ dependencies: { next: '^14.0.0' } }),
|
|
143
|
+
'app/Form.tsx':
|
|
144
|
+
"'use client';\nimport { getSecret } from '../lib/secret';\nexport default function F(){ return getSecret(); }\n",
|
|
145
|
+
'lib/secret.ts': "import 'server-only';\nexport const getSecret = () => 1;\n",
|
|
146
|
+
};
|
|
147
|
+
const prev = await analyze(project(before));
|
|
148
|
+
const after = {
|
|
149
|
+
...before,
|
|
150
|
+
'lib/secret.ts': "import 'server-only';\n// touched\nexport const getSecret = () => 1;\n",
|
|
151
|
+
};
|
|
152
|
+
const expected = await analyze(project(after));
|
|
153
|
+
const got = await incrementalAnalyze({
|
|
154
|
+
prev,
|
|
155
|
+
source: project(after),
|
|
156
|
+
changedFiles: ['lib/secret.ts'],
|
|
157
|
+
});
|
|
158
|
+
expect(got.modules.find((m) => m.id === 'lib/secret.ts')?.runtime).toBe('server');
|
|
159
|
+
const rsc = (v: { kind: string }): boolean => v.kind === 'rsc-leak';
|
|
160
|
+
expect(got.contractViolations.filter(rsc).length).toBe(
|
|
161
|
+
expected.contractViolations.filter(rsc).length,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('incremental preserves the Nuxt composable auto-import edge (== cold)', async () => {
|
|
166
|
+
const before = {
|
|
167
|
+
'package.json': JSON.stringify({ dependencies: { nuxt: '^3.0.0' } }),
|
|
168
|
+
'app.vue': '<template><NuxtPage /></template>',
|
|
169
|
+
'pages/index.vue':
|
|
170
|
+
'<script setup lang="ts">\nconst n = useCounter();\n</script>\n<template><div>{{ n }}</div></template>',
|
|
171
|
+
'composables/useCounter.ts': 'export function useCounter() { return 0; }',
|
|
172
|
+
};
|
|
173
|
+
const prev = await analyze(project(before));
|
|
174
|
+
const after = {
|
|
175
|
+
...before,
|
|
176
|
+
'pages/index.vue':
|
|
177
|
+
'<script setup lang="ts">\n// touched\nconst n = useCounter();\n</script>\n<template><div>{{ n }}</div></template>',
|
|
178
|
+
};
|
|
179
|
+
const expected = await analyze(project(after));
|
|
180
|
+
const got = await incrementalAnalyze({
|
|
181
|
+
prev,
|
|
182
|
+
source: project(after),
|
|
183
|
+
changedFiles: ['pages/index.vue'],
|
|
184
|
+
});
|
|
185
|
+
expect(sortEdges(got.edges)).toEqual(sortEdges(expected.edges));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('empty changedFiles → returns prev without doing work', async () => {
|
|
141
189
|
const before = { 'src/a.ts': 'export const a = 1;\n' };
|
|
142
190
|
const prev = await analyze(project(before));
|
|
143
191
|
const got = await incrementalAnalyze({
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Controlled precision/recall for layer violations (plan A3).
|
|
2
|
+
//
|
|
3
|
+
// Real OSS projects rarely declare FSD layers, so there is no external oracle.
|
|
4
|
+
// Instead we measure on a fixture with a KNOWN ground truth: a fixed set of
|
|
5
|
+
// legal (top-down / same-layer) edges and a fixed set of injected violations
|
|
6
|
+
// (bottom-up imports). The detector must flag exactly the violations.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { detectLayerViolations } from '../layers';
|
|
10
|
+
import type { DependencyEdge, ModuleNode } from '../types';
|
|
11
|
+
|
|
12
|
+
const mod = (id: string): ModuleNode => ({
|
|
13
|
+
id,
|
|
14
|
+
absPath: `/${id}`,
|
|
15
|
+
kind: 'unknown',
|
|
16
|
+
language: 'ts',
|
|
17
|
+
loc: 1,
|
|
18
|
+
exports: [],
|
|
19
|
+
isInfra: false,
|
|
20
|
+
});
|
|
21
|
+
const edge = (from: string, to: string): DependencyEdge => ({
|
|
22
|
+
from,
|
|
23
|
+
to,
|
|
24
|
+
kind: 'static',
|
|
25
|
+
specifier: to,
|
|
26
|
+
resolved: true,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// One module per FSD layer (low → high: shared < entities < features < widgets < pages < app).
|
|
30
|
+
const A = 'src/app/A.ts';
|
|
31
|
+
const P = 'src/pages/P.ts';
|
|
32
|
+
const W = 'src/widgets/W.ts';
|
|
33
|
+
const F = 'src/features/F.ts';
|
|
34
|
+
const F2 = 'src/features/F2.ts';
|
|
35
|
+
const E = 'src/entities/E.ts';
|
|
36
|
+
const S = 'src/shared/S.ts';
|
|
37
|
+
const modules = [A, P, W, F, F2, E, S].map(mod);
|
|
38
|
+
|
|
39
|
+
// Legal: top-down (and same-layer) — must NOT be flagged.
|
|
40
|
+
const legal: DependencyEdge[] = [
|
|
41
|
+
edge(A, P),
|
|
42
|
+
edge(A, S),
|
|
43
|
+
edge(P, W),
|
|
44
|
+
edge(W, F),
|
|
45
|
+
edge(F, E),
|
|
46
|
+
edge(E, S),
|
|
47
|
+
edge(F, F2),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// Injected violations: bottom-up imports — the ground-truth positives.
|
|
51
|
+
const violating: DependencyEdge[] = [
|
|
52
|
+
edge(S, E),
|
|
53
|
+
edge(S, A),
|
|
54
|
+
edge(E, F),
|
|
55
|
+
edge(F, W),
|
|
56
|
+
edge(W, P),
|
|
57
|
+
edge(P, A),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
describe('layer violations — precision/recall on injected ground truth (A3)', () => {
|
|
61
|
+
it('flags exactly the injected violations: precision = recall = 1.0', () => {
|
|
62
|
+
const known = new Set(violating.map((e) => `${e.from}->${e.to}`));
|
|
63
|
+
const detected = detectLayerViolations(modules, [...legal, ...violating]);
|
|
64
|
+
const detectedPairs = detected.map((v) => `${v.from}->${v.to}`);
|
|
65
|
+
|
|
66
|
+
const tp = detectedPairs.filter((p) => known.has(p)).length;
|
|
67
|
+
const fp = detectedPairs.filter((p) => !known.has(p)).length;
|
|
68
|
+
const fn = [...known].filter((p) => !detectedPairs.includes(p)).length;
|
|
69
|
+
|
|
70
|
+
const precision = tp / (tp + fp);
|
|
71
|
+
const recall = tp / (tp + fn);
|
|
72
|
+
|
|
73
|
+
expect(precision).toBe(1); // no legal edge mis-flagged
|
|
74
|
+
expect(recall).toBe(1); // every injected violation caught
|
|
75
|
+
expect(tp).toBe(violating.length);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -56,4 +56,43 @@ describe('computeMetrics', () => {
|
|
|
56
56
|
expect(m['c']!.depth).toBe(2);
|
|
57
57
|
expect(m['d']!.depth).toBe(3);
|
|
58
58
|
});
|
|
59
|
+
|
|
60
|
+
it('collapses an SCC so cycle members share one condensation depth', () => {
|
|
61
|
+
// a -> b -> c -> b (b,c form a cycle), then c -> d.
|
|
62
|
+
const modules = [mod('a'), mod('b'), mod('c'), mod('d')];
|
|
63
|
+
const edges = [edge('a', 'b'), edge('b', 'c'), edge('c', 'b'), edge('c', 'd')];
|
|
64
|
+
const cycles = detectCycles(modules, edges);
|
|
65
|
+
const m = computeMetrics({ modules, edges, cycles, entries: ['a'] });
|
|
66
|
+
expect(m['a']!.depth).toBe(0);
|
|
67
|
+
// b and c are one condensed node -> equal depth, exactly one hop past `a`.
|
|
68
|
+
expect(m['b']!.depth).toBe(1);
|
|
69
|
+
expect(m['c']!.depth).toBe(1);
|
|
70
|
+
// d sits one hop past the condensed {b,c}.
|
|
71
|
+
expect(m['d']!.depth).toBe(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('takes the longest path to a node reachable by two routes (diamond)', () => {
|
|
75
|
+
// a -> b -> d and a -> c -> d, plus a long leg a -> b -> e -> d.
|
|
76
|
+
const modules = [mod('a'), mod('b'), mod('c'), mod('d'), mod('e')];
|
|
77
|
+
const edges = [
|
|
78
|
+
edge('a', 'b'),
|
|
79
|
+
edge('a', 'c'),
|
|
80
|
+
edge('b', 'd'),
|
|
81
|
+
edge('c', 'd'),
|
|
82
|
+
edge('b', 'e'),
|
|
83
|
+
edge('e', 'd'),
|
|
84
|
+
];
|
|
85
|
+
const m = computeMetrics({ modules, edges, cycles: [], entries: ['a'] });
|
|
86
|
+
expect(m['d']!.depth).toBe(3); // a -> b -> e -> d is the longest route
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('weights hotness by file size at equal coupling (sizeFactor)', () => {
|
|
90
|
+
// c1 and c2 are symmetric (both imported by `a`, both import `z`) so their
|
|
91
|
+
// coupling is identical; only the file size differs.
|
|
92
|
+
const modules = [mod('a'), mod('c1', 10), mod('c2', 5000), mod('z')];
|
|
93
|
+
const edges = [edge('a', 'c1'), edge('a', 'c2'), edge('c1', 'z'), edge('c2', 'z')];
|
|
94
|
+
const m = computeMetrics({ modules, edges, cycles: [], entries: ['a'] });
|
|
95
|
+
expect(m['c1']!.couplingScore).toBe(m['c2']!.couplingScore);
|
|
96
|
+
expect(m['c2']!.hotnessScore).toBeGreaterThan(m['c1']!.hotnessScore);
|
|
97
|
+
});
|
|
59
98
|
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { analyze } from '../index';
|
|
3
|
+
import { createInMemoryFileSource } from '../sources/inMemoryFileSource';
|
|
4
|
+
|
|
5
|
+
const nuxtProject = (files: Record<string, string>) =>
|
|
6
|
+
createInMemoryFileSource('/p', {
|
|
7
|
+
'package.json': JSON.stringify({ dependencies: { nuxt: '^3.0.0' } }),
|
|
8
|
+
'tsconfig.json': '{}',
|
|
9
|
+
'app.vue': `<template><NuxtPage /></template>`,
|
|
10
|
+
...files,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('buildGraph: Nuxt auto-import composable resolution', () => {
|
|
14
|
+
it('emits an auto-import edge from a page using a composable by filename convention', async () => {
|
|
15
|
+
const src = nuxtProject({
|
|
16
|
+
'pages/index.vue': `<script setup lang="ts">
|
|
17
|
+
const n = useCounter();
|
|
18
|
+
</script>
|
|
19
|
+
<template><div>{{ n }}</div></template>`,
|
|
20
|
+
'composables/useCounter.ts': `export default function useCounter() { return 0; }`,
|
|
21
|
+
});
|
|
22
|
+
const result = await analyze(src);
|
|
23
|
+
const edge = result.edges.find(
|
|
24
|
+
(e) => e.from === 'pages/index.vue' && e.to === 'composables/useCounter.ts',
|
|
25
|
+
);
|
|
26
|
+
expect(edge).toBeDefined();
|
|
27
|
+
expect(edge?.kind).toBe('auto-import');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('resolves a composable by its named export, not just the filename', async () => {
|
|
31
|
+
const src = nuxtProject({
|
|
32
|
+
'pages/index.vue': `<script setup lang="ts">
|
|
33
|
+
const s = useSession();
|
|
34
|
+
</script>
|
|
35
|
+
<template><div /></template>`,
|
|
36
|
+
'composables/auth.ts': `export function useSession() { return null; }`,
|
|
37
|
+
});
|
|
38
|
+
const result = await analyze(src);
|
|
39
|
+
const edge = result.edges.find(
|
|
40
|
+
(e) => e.from === 'pages/index.vue' && e.to === 'composables/auth.ts',
|
|
41
|
+
);
|
|
42
|
+
expect(edge?.kind).toBe('auto-import');
|
|
43
|
+
expect(edge?.specifier).toBe('useSession');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('does NOT duplicate the edge when the composable is already statically imported', async () => {
|
|
47
|
+
const src = nuxtProject({
|
|
48
|
+
'pages/index.vue': `<script setup lang="ts">
|
|
49
|
+
import { useCounter } from '~/composables/useCounter';
|
|
50
|
+
const n = useCounter();
|
|
51
|
+
</script>
|
|
52
|
+
<template><div /></template>`,
|
|
53
|
+
'composables/useCounter.ts': `export function useCounter() { return 0; }`,
|
|
54
|
+
'tsconfig.json': JSON.stringify({
|
|
55
|
+
compilerOptions: { baseUrl: '.', paths: { '~/*': ['./*'] } },
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
const result = await analyze(src);
|
|
59
|
+
const edges = result.edges.filter(
|
|
60
|
+
(e) => e.from === 'pages/index.vue' && e.to === 'composables/useCounter.ts',
|
|
61
|
+
);
|
|
62
|
+
expect(edges).toHaveLength(1);
|
|
63
|
+
expect(edges[0]?.kind).toBe('static');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does NOT create an edge for an identifier with no matching composable (Nuxt built-ins)', async () => {
|
|
67
|
+
const src = nuxtProject({
|
|
68
|
+
'pages/index.vue': `<script setup lang="ts">
|
|
69
|
+
const r = useRoute();
|
|
70
|
+
const f = useFetch('/api/x');
|
|
71
|
+
</script>
|
|
72
|
+
<template><div /></template>`,
|
|
73
|
+
'composables/useCounter.ts': `export function useCounter() { return 0; }`,
|
|
74
|
+
});
|
|
75
|
+
const result = await analyze(src);
|
|
76
|
+
const autoImports = result.edges.filter(
|
|
77
|
+
(e) => e.from === 'pages/index.vue' && e.kind === 'auto-import',
|
|
78
|
+
);
|
|
79
|
+
expect(autoImports).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('does not emit a self-edge when a composable calls a sibling-named helper in its own file', async () => {
|
|
83
|
+
const src = nuxtProject({
|
|
84
|
+
'composables/useCounter.ts': `export function useCounter() { return useCounter; }`,
|
|
85
|
+
});
|
|
86
|
+
const result = await analyze(src);
|
|
87
|
+
const selfEdges = result.edges.filter(
|
|
88
|
+
(e) => e.from === 'composables/useCounter.ts' && e.to === 'composables/useCounter.ts',
|
|
89
|
+
);
|
|
90
|
+
expect(selfEdges).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('buildGraph: plain Vue (non-Nuxt) does not guess auto-import composables', () => {
|
|
95
|
+
it('does not create composable auto-import edges without Nuxt (documented limitation)', async () => {
|
|
96
|
+
const src = createInMemoryFileSource('/p', {
|
|
97
|
+
'package.json': JSON.stringify({ dependencies: { vue: '^3.0.0' } }),
|
|
98
|
+
'tsconfig.json': '{}',
|
|
99
|
+
'src/App.vue': `<script setup lang="ts">
|
|
100
|
+
const n = useCounter();
|
|
101
|
+
</script>
|
|
102
|
+
<template><div /></template>`,
|
|
103
|
+
'src/composables/useCounter.ts': `export function useCounter() { return 0; }`,
|
|
104
|
+
});
|
|
105
|
+
const result = await analyze(src);
|
|
106
|
+
const autoImports = result.edges.filter((e) => e.kind === 'auto-import');
|
|
107
|
+
expect(autoImports).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -33,6 +33,28 @@ describe('reactParser (via registry, framework=react)', () => {
|
|
|
33
33
|
expect(specs).toEqual(expect.arrayContaining(['./A', './B']));
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
it('captures next/dynamic(() => import(...)) as a dynamic edge', () => {
|
|
37
|
+
const file = parseReact(
|
|
38
|
+
'src/App.tsx',
|
|
39
|
+
`import dynamic from 'next/dynamic';
|
|
40
|
+
const Chart = dynamic(() => import('./widgets/Chart'));
|
|
41
|
+
export const App = () => null;`,
|
|
42
|
+
);
|
|
43
|
+
const dynamic = file.imports.filter((i) => i.kind === 'dynamic');
|
|
44
|
+
expect(dynamic.map((i) => i.specifier)).toContain('./widgets/Chart');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('captures next/dynamic with an options object as a dynamic edge', () => {
|
|
48
|
+
const file = parseReact(
|
|
49
|
+
'src/App.tsx',
|
|
50
|
+
`import dynamic from 'next/dynamic';
|
|
51
|
+
const Map = dynamic(() => import('./widgets/Map'), { ssr: false });
|
|
52
|
+
export const App = () => null;`,
|
|
53
|
+
);
|
|
54
|
+
const dynamic = file.imports.filter((i) => i.kind === 'dynamic');
|
|
55
|
+
expect(dynamic.map((i) => i.specifier)).toContain('./widgets/Map');
|
|
56
|
+
});
|
|
57
|
+
|
|
36
58
|
it('captures static imports of JSX components', () => {
|
|
37
59
|
const file = parseReact(
|
|
38
60
|
'src/App.tsx',
|
|
@@ -43,6 +43,33 @@ describe('resolve', () => {
|
|
|
43
43
|
expect(await r.resolve('@/lib/util', 'src/main.js')).toBe('src/lib/util.js');
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
it('resolves an alias whose target is itself an alias (alias chain)', async () => {
|
|
47
|
+
const src = createInMemoryFileSource('/p', {
|
|
48
|
+
'src/a/foo.ts': 'export const x = 1;',
|
|
49
|
+
'src/entry.ts': "import { x } from '@b/foo';",
|
|
50
|
+
});
|
|
51
|
+
const r = createResolver(src, {
|
|
52
|
+
aliases: [
|
|
53
|
+
{ prefix: '@b', targets: ['@a'] },
|
|
54
|
+
{ prefix: '@a', targets: ['src/a'] },
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
expect(await r.resolve('@b/foo', 'src/entry.ts')).toBe('src/a/foo.ts');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('does not loop on a cyclic alias chain', async () => {
|
|
61
|
+
const src = createInMemoryFileSource('/p', {
|
|
62
|
+
'src/entry.ts': "import { x } from '@x/foo';",
|
|
63
|
+
});
|
|
64
|
+
const r = createResolver(src, {
|
|
65
|
+
aliases: [
|
|
66
|
+
{ prefix: '@x', targets: ['@y'] },
|
|
67
|
+
{ prefix: '@y', targets: ['@x'] },
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
expect(await r.resolve('@x/foo', 'src/entry.ts')).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
46
73
|
it('resolves relative imports', async () => {
|
|
47
74
|
const src = await createNodeFsFileSource({ rootPath: fixturePath('sample-cycles') });
|
|
48
75
|
const r = createResolver(src, { aliases: [] });
|