@archora/core 1.3.0 → 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archora/core",
3
- "version": "1.3.0",
3
+ "version": "2.1.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>",
package/src/README.md CHANGED
@@ -1,4 +1,4 @@
1
1
  # core
2
2
 
3
- Фреймворк-независимое ядро: analyzer, graph, metrics, rules, report.
4
- Не импортирует Vue/Pinia/Tailwind/Tauri. Должно работать в чистом Node.
3
+ Framework-independent core: analyzer, graph, metrics, rules, report.
4
+ Does not import Vue/Pinia/Tailwind/Tauri. Must run in plain Node.
@@ -38,7 +38,7 @@ exports[`reference corpus snapshots > nuxt-server-routes: stable summary 1`] = `
38
38
 
39
39
  exports[`reference corpus snapshots > react-admin-basic: stable summary 1`] = `
40
40
  {
41
- "archDebtGrade": "B",
41
+ "archDebtGrade": "A",
42
42
  "cycles": 0,
43
43
  "edges": 11,
44
44
  "modules": 11,
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { markBarrelModules, rankHotZones } from '../hotZones';
3
+ import type { ModuleMetrics, ModuleNode, ParsedFileSummary } from '../types';
4
+
5
+ function module(id: string, overrides: Partial<ModuleNode> = {}): ModuleNode {
6
+ return {
7
+ id,
8
+ absPath: `/${id}`,
9
+ kind: 'unknown',
10
+ language: 'ts',
11
+ loc: 1,
12
+ exports: [],
13
+ isInfra: false,
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ function metrics(overrides: Partial<ModuleMetrics> = {}): ModuleMetrics {
19
+ return {
20
+ fanIn: 0,
21
+ fanOut: 0,
22
+ instability: 0,
23
+ depth: 0,
24
+ inCycle: false,
25
+ couplingScore: 0,
26
+ hotnessScore: 0,
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function summary(relPath: string, overrides: Partial<ParsedFileSummary> = {}): ParsedFileSummary {
32
+ return {
33
+ relPath,
34
+ language: 'ts',
35
+ loc: 1,
36
+ imports: [],
37
+ exports: [],
38
+ runtimeFacts: [],
39
+ frameworkFacts: [],
40
+ routeFacts: [],
41
+ stateFacts: [],
42
+ assetFacts: [],
43
+ limitations: [],
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ describe('markBarrelModules', () => {
49
+ it('flags a thin index barrel with fan-out >= 3 even when no export source is enumerated', () => {
50
+ // Pinia `packages/pinia/src/index.ts`: fan-out 8, 66 named re-exports the
51
+ // parser couldn't resolve a source for, ~70 loc — thin glue.
52
+ const modules = [
53
+ module('packages/pinia/src/index.ts', {
54
+ loc: 70,
55
+ exports: Array.from({ length: 66 }, (_, i) => `reexport${i}`),
56
+ }),
57
+ ];
58
+ const m = { 'packages/pinia/src/index.ts': metrics({ fanOut: 8 }) };
59
+ markBarrelModules({
60
+ modules,
61
+ metrics: m,
62
+ parserFacts: [summary('packages/pinia/src/index.ts', { loc: 70 })],
63
+ });
64
+ expect(modules[0]?.isBarrel).toBe(true);
65
+ });
66
+
67
+ it('flags a mid/small package barrel below the old fan-out fallback', () => {
68
+ // VueUse `packages/math/index.ts`: fan-out 18, ~20 loc.
69
+ const modules = [module('packages/math/index.ts', { loc: 20 })];
70
+ const m = { 'packages/math/index.ts': metrics({ fanOut: 18 }) };
71
+ markBarrelModules({ modules, metrics: m, parserFacts: [] });
72
+ expect(modules[0]?.isBarrel).toBe(true);
73
+ });
74
+
75
+ it('does not flag a fat module with many source lines per export', () => {
76
+ // VueUse `useStorage/index.ts`: loc 328, surface 7 → 46 loc/item.
77
+ const modules = [module('packages/core/useStorage/index.ts', { loc: 328 })];
78
+ const m = { 'packages/core/useStorage/index.ts': metrics({ fanOut: 5 }) };
79
+ markBarrelModules({
80
+ modules,
81
+ metrics: m,
82
+ parserFacts: [summary('packages/core/useStorage/index.ts', { loc: 328 })],
83
+ });
84
+ expect(modules[0]?.isBarrel).toBeUndefined();
85
+ });
86
+
87
+ it('does not flag a fan-out-0 multi-export util that re-exports nothing', () => {
88
+ // VueUse `shared/utils/is.ts`: fan-out 0, 13 own exports — a leaf, not glue.
89
+ const modules = [
90
+ module('packages/shared/utils/is.ts', {
91
+ loc: 30,
92
+ exports: Array.from({ length: 13 }, (_, i) => `is${i}`),
93
+ }),
94
+ ];
95
+ const m = { 'packages/shared/utils/is.ts': metrics({ fanOut: 0 }) };
96
+ markBarrelModules({ modules, metrics: m, parserFacts: [] });
97
+ expect(modules[0]?.isBarrel).toBeUndefined();
98
+ });
99
+
100
+ it('does not flag a thin module whose surface is below the floor', () => {
101
+ // Two imports, two re-exports: not an aggregation surface.
102
+ const modules = [module('src/glue.ts', { loc: 3 })];
103
+ const m = { 'src/glue.ts': metrics({ fanOut: 3 }) };
104
+ markBarrelModules({ modules, metrics: m, parserFacts: [] });
105
+ expect(modules[0]?.isBarrel).toBeUndefined();
106
+ });
107
+ });
108
+
109
+ describe('rankHotZones', () => {
110
+ it('drops barrels from the ranked window without backfilling lower-signal modules', () => {
111
+ const modules = [module('barrel/index.ts', { isBarrel: true }), module('a.ts'), module('b.ts')];
112
+ const m = {
113
+ 'barrel/index.ts': metrics({ hotnessScore: 5 }),
114
+ 'a.ts': metrics({ hotnessScore: 3 }),
115
+ 'b.ts': metrics({ hotnessScore: 1 }),
116
+ };
117
+ expect(rankHotZones({ modules, metrics: m, topN: 2 })).toEqual(['a.ts']);
118
+ });
119
+
120
+ it('excludes infra modules', () => {
121
+ const modules = [module('infra.ts', { isInfra: true }), module('app.ts')];
122
+ const m = {
123
+ 'infra.ts': metrics({ hotnessScore: 9 }),
124
+ 'app.ts': metrics({ hotnessScore: 2 }),
125
+ };
126
+ expect(rankHotZones({ modules, metrics: m })).toEqual(['app.ts']);
127
+ });
128
+ });
@@ -111,6 +111,100 @@ describe('memory risk analysis', () => {
111
111
  ]);
112
112
  });
113
113
 
114
+ it('does not flag a bare fire-and-forget setTimeout', async () => {
115
+ const result = await analyze(
116
+ createInMemoryFileSource('/p', {
117
+ 'package.json': JSON.stringify({ dependencies: { react: '^18.0.0' } }),
118
+ 'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }),
119
+ 'src/App.tsx': `
120
+ import { useEffect } from 'react';
121
+
122
+ export function App() {
123
+ useEffect(() => {
124
+ setTimeout(() => window.removeEventListener('x', () => {}), 100);
125
+ }, []);
126
+ return null;
127
+ }
128
+ `,
129
+ }),
130
+ );
131
+
132
+ expect((result.memoryRisks ?? []).filter((risk) => risk.kind === 'timer-cleanup')).toEqual([]);
133
+ });
134
+
135
+ it('flags a captured setTimeout handle without clearTimeout', async () => {
136
+ const result = await analyze(
137
+ createInMemoryFileSource('/p', {
138
+ 'package.json': JSON.stringify({ dependencies: { react: '^18.0.0' } }),
139
+ 'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }),
140
+ 'src/App.tsx': `
141
+ import { useEffect } from 'react';
142
+
143
+ export function App() {
144
+ useEffect(() => {
145
+ const id = setTimeout(() => {}, 100);
146
+ return undefined;
147
+ }, []);
148
+ return null;
149
+ }
150
+ `,
151
+ }),
152
+ );
153
+
154
+ expect(result.memoryRisks).toEqual([
155
+ expect.objectContaining({
156
+ kind: 'timer-cleanup',
157
+ moduleId: 'src/App.tsx',
158
+ framework: 'react',
159
+ }),
160
+ ]);
161
+ });
162
+
163
+ it('keeps a captured setTimeout with clearTimeout clean', async () => {
164
+ const result = await analyze(
165
+ createInMemoryFileSource('/p', {
166
+ 'package.json': JSON.stringify({ dependencies: { react: '^18.0.0' } }),
167
+ 'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }),
168
+ 'src/App.tsx': `
169
+ import { useEffect } from 'react';
170
+
171
+ export function App() {
172
+ useEffect(() => {
173
+ const id = setTimeout(() => {}, 100);
174
+ return () => clearTimeout(id);
175
+ }, []);
176
+ return null;
177
+ }
178
+ `,
179
+ }),
180
+ );
181
+
182
+ expect((result.memoryRisks ?? []).filter((risk) => risk.kind === 'timer-cleanup')).toEqual([]);
183
+ });
184
+
185
+ it('still flags setInterval without clearInterval', async () => {
186
+ const result = await analyze(
187
+ createInMemoryFileSource('/p', {
188
+ 'package.json': JSON.stringify({ dependencies: { react: '^18.0.0' } }),
189
+ 'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }),
190
+ 'src/App.tsx': `
191
+ import { useEffect } from 'react';
192
+
193
+ export function App() {
194
+ useEffect(() => {
195
+ setInterval(() => {}, 1000);
196
+ }, []);
197
+ return null;
198
+ }
199
+ `,
200
+ }),
201
+ );
202
+
203
+ expect(result.memoryRisks).toEqual([
204
+ expect.objectContaining({ kind: 'timer-cleanup', moduleId: 'src/App.tsx' }),
205
+ ]);
206
+ });
207
+
114
208
  it('does not flag Next server modules as browser memory risks', async () => {
115
209
  const result = await analyze(
116
210
  createInMemoryFileSource('/p', {
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { computeRecommendations } from '../recommendations';
3
3
  import type { Cycle, DependencyEdge, ModuleMetrics, ModuleNode } from '../types';
4
+ import type { TemporalCoupling } from '../../git/types';
4
5
 
5
6
  function module(id: string, overrides: Partial<ModuleNode> = {}): ModuleNode {
6
7
  return {
@@ -168,4 +169,70 @@ describe('computeRecommendations', () => {
168
169
  const r = recs.find((x) => x.kind === 'unused-utility')!;
169
170
  expect(r.params.name).toBe('dead.ts');
170
171
  });
172
+
173
+ it('only surfaces hidden cross-boundary temporal couplings, ranked, capped at 10', () => {
174
+ function coupling(
175
+ a: string,
176
+ b: string,
177
+ hidden: boolean,
178
+ crossBoundary: boolean,
179
+ risk: number,
180
+ ): TemporalCoupling {
181
+ return {
182
+ a,
183
+ b,
184
+ coOccurrences: 5,
185
+ scoreA: 0.8,
186
+ scoreB: 0.8,
187
+ score: 0.8,
188
+ hidden,
189
+ crossBoundary,
190
+ risk,
191
+ };
192
+ }
193
+ const temporalCoupling: TemporalCoupling[] = [
194
+ coupling('features/a.ts', 'entities/b.ts', true, true, 0.9),
195
+ coupling('features/c.ts', 'shared/d.ts', true, true, 0.7),
196
+ coupling('shared/x.ts', 'shared/y.ts', true, false, 0.95), // same group: dropped
197
+ coupling('features/e.ts', 'entities/f.ts', false, true, 0.95), // visible: dropped
198
+ ];
199
+ const recs = computeRecommendations({
200
+ modules: [],
201
+ edges: [],
202
+ metrics: {},
203
+ cycles: [],
204
+ layerViolations: [],
205
+ hotZones: [],
206
+ temporalCoupling,
207
+ });
208
+ const temporal = recs.filter((r) => r.kind === 'temporal-coupling');
209
+ expect(temporal).toHaveLength(2);
210
+ // Input order is risk-sorted by the detector and preserved.
211
+ expect(temporal[0]!.modules).toEqual(['features/a.ts', 'entities/b.ts']);
212
+ expect(temporal[1]!.modules).toEqual(['features/c.ts', 'shared/d.ts']);
213
+ });
214
+
215
+ it('caps temporal-coupling recommendations at 10', () => {
216
+ const temporalCoupling: TemporalCoupling[] = Array.from({ length: 15 }, (_, i) => ({
217
+ a: `features/a${i}.ts`,
218
+ b: `entities/b${i}.ts`,
219
+ coOccurrences: 5,
220
+ scoreA: 0.8,
221
+ scoreB: 0.8,
222
+ score: 0.8,
223
+ hidden: true,
224
+ crossBoundary: true,
225
+ risk: 1 - i * 0.01,
226
+ }));
227
+ const recs = computeRecommendations({
228
+ modules: [],
229
+ edges: [],
230
+ metrics: {},
231
+ cycles: [],
232
+ layerViolations: [],
233
+ hotZones: [],
234
+ temporalCoupling,
235
+ });
236
+ expect(recs.filter((r) => r.kind === 'temporal-coupling')).toHaveLength(10);
237
+ });
171
238
  });
@@ -14,6 +14,33 @@ describe('resolve', () => {
14
14
  expect(aliases).toEqual([{ prefix: '@', targets: ['src'] }]);
15
15
  });
16
16
 
17
+ it('resolves a tsconfig wildcard whose target has an interior star to the package index', async () => {
18
+ const src = createInMemoryFileSource('/p', {
19
+ 'tsconfig.json': JSON.stringify({
20
+ compilerOptions: {
21
+ baseUrl: '.',
22
+ paths: { '@x/*': ['pkgs/*/src'], '@x/legacy': ['pkgs/legacy/src'] },
23
+ },
24
+ }),
25
+ 'pkgs/foo/src/index.ts': 'export const foo = 1;',
26
+ 'pkgs/legacy/src/index.ts': 'export const legacy = 1;',
27
+ 'src/main.ts': "import { foo } from '@x/foo';",
28
+ });
29
+ const project: ProjectRef = {
30
+ id: 'p',
31
+ name: 'p',
32
+ rootPath: '/p',
33
+ detectedFramework: 'unknown',
34
+ tsconfigPath: 'tsconfig.json',
35
+ };
36
+ const r = createResolver(src, { aliases: await loadAliases(src, project) });
37
+ // `@x/*` -> `pkgs/*/src`: the captured `foo` is substituted at the star,
38
+ // then directory->index resolution lands on the package index file.
39
+ expect(await r.resolve('@x/foo', 'src/main.ts')).toBe('pkgs/foo/src/index.ts');
40
+ // an exact tsconfig path (no star) shadows the wildcard for its own key.
41
+ expect(await r.resolve('@x/legacy', 'src/main.ts')).toBe('pkgs/legacy/src/index.ts');
42
+ });
43
+
17
44
  it('resolves @/ alias to src/*.ts and *.vue', async () => {
18
45
  const src = await createNodeFsFileSource({ rootPath: fixturePath('sample-vue-app') });
19
46
  const r = createResolver(src, { aliases: parseTsconfigPaths(await src.read('tsconfig.json')) });
@@ -7,7 +7,11 @@ import { analyze } from '../index';
7
7
  import { createInMemoryFileSource } from '../sources/inMemoryFileSource';
8
8
  import type { DependencyEdge, ModuleNode } from '../types';
9
9
 
10
- function mod(id: string, runtime: ModuleNode['runtime'] = 'shared'): ModuleNode {
10
+ function mod(
11
+ id: string,
12
+ runtime: ModuleNode['runtime'] = 'shared',
13
+ opts: { isServerActions?: boolean } = {},
14
+ ): ModuleNode {
11
15
  return {
12
16
  id,
13
17
  absPath: id,
@@ -17,6 +21,7 @@ function mod(id: string, runtime: ModuleNode['runtime'] = 'shared'): ModuleNode
17
21
  exports: [],
18
22
  isInfra: false,
19
23
  runtime,
24
+ ...(opts.isServerActions ? { isServerActions: true } : {}),
20
25
  };
21
26
  }
22
27
 
@@ -118,6 +123,39 @@ describe('detectRscLeaks', () => {
118
123
  expect(leaks[0]?.edge?.to).toBe('lib/db.ts');
119
124
  });
120
125
 
126
+ it("does not flag a client importing a 'use server' Server Actions module", () => {
127
+ // The ubiquitous Next.js app-router pattern: a client component imports
128
+ // server actions (forms/mutations). Next compiles them into RPC references;
129
+ // nothing server-only ships to the browser, so this is not a leak.
130
+ const modules = [
131
+ mod('components/cart/modal.tsx', 'client'),
132
+ mod('components/cart/actions.ts', 'server', { isServerActions: true }),
133
+ ];
134
+ const edges = [edge('components/cart/modal.tsx', 'components/cart/actions.ts')];
135
+ expect(detectRscLeaks({ modules, edges })).toHaveLength(0);
136
+ });
137
+
138
+ it("still flags client -> server-only when the server module is not 'use server'", () => {
139
+ const modules = [
140
+ mod('app/Form.tsx', 'client'),
141
+ mod('lib/secret.ts', 'server'), // server via server-only / convention, no 'use server'
142
+ ];
143
+ const edges = [edge('app/Form.tsx', 'lib/secret.ts')];
144
+ const leaks = detectRscLeaks({ modules, edges });
145
+ expect(leaks).toHaveLength(1);
146
+ expect(leaks[0]?.edge?.to).toBe('lib/secret.ts');
147
+ });
148
+
149
+ it("does not flag a transitive client -> shared -> 'use server' actions chain", () => {
150
+ const modules = [
151
+ mod('app/Form.tsx', 'client'),
152
+ mod('lib/index.ts', 'shared'), // barrel
153
+ mod('lib/actions.ts', 'server', { isServerActions: true }),
154
+ ];
155
+ const edges = [edge('app/Form.tsx', 'lib/index.ts'), edge('lib/index.ts', 'lib/actions.ts')];
156
+ expect(detectRscLeaks({ modules, edges })).toHaveLength(0);
157
+ });
158
+
121
159
  it("flags a client component importing a module poisoned with 'server-only'", async () => {
122
160
  const fs = createInMemoryFileSource('/repo', {
123
161
  'package.json': JSON.stringify({ dependencies: { next: '^14.0.0' } }),
@@ -181,14 +219,14 @@ describe('detectRscLeaks', () => {
181
219
  });
182
220
 
183
221
  describe('rsc-leak end-to-end', () => {
184
- it('surfaces client->server import as a contract violation in analyze()', async () => {
222
+ it('surfaces client->server-only import as a contract violation in analyze()', async () => {
185
223
  const fs = createInMemoryFileSource('/repo', {
186
224
  'package.json': JSON.stringify({ name: 'x' }),
187
225
  'app/page.tsx':
188
226
  "import { db } from '../lib/db';\nexport default function P(){ return db; }\n",
189
227
  'app/Form.tsx':
190
228
  "'use client';\nimport { db } from '../lib/db';\nexport default function F(){ return db; }\n",
191
- 'lib/db.ts': "'use server';\nexport const db = 1;\n",
229
+ 'lib/db.ts': "import 'server-only';\nexport const db = 1;\n",
192
230
  });
193
231
 
194
232
  const scan = await analyze(fs);
@@ -198,4 +236,25 @@ describe('rsc-leak end-to-end', () => {
198
236
  true,
199
237
  );
200
238
  });
239
+
240
+ it("does not flag a client importing a 'use server' actions module in analyze()", async () => {
241
+ // Mirrors vercel/commerce: components/cart/modal.tsx ('use client') imports
242
+ // components/cart/actions.ts ('use server'). This must not be an rsc-leak.
243
+ const fs = createInMemoryFileSource('/repo', {
244
+ 'package.json': JSON.stringify({ dependencies: { next: '^14.0.0' } }),
245
+ 'components/cart/modal.tsx':
246
+ "'use client';\nimport { addItem } from './actions';\nexport default function M(){ return addItem; }\n",
247
+ 'components/cart/actions.ts': "'use server';\nexport async function addItem(){ return 1; }\n",
248
+ });
249
+
250
+ const scan = await analyze(fs);
251
+ const leaks = scan.contractViolations.filter((v) => v.kind === 'rsc-leak');
252
+ expect(
253
+ leaks.some(
254
+ (l) =>
255
+ l.edge?.from === 'components/cart/modal.tsx' &&
256
+ l.edge?.to === 'components/cart/actions.ts',
257
+ ),
258
+ ).toBe(false);
259
+ });
201
260
  });
@@ -14,7 +14,7 @@ import { createParserRegistry, isParseFailure } from './parsers';
14
14
  import type { Framework } from './detect';
15
15
  import { applyAlias, type PathAlias, type Resolver } from './resolve';
16
16
  import { classifyKind, isInfra } from './classify';
17
- import { classifyModuleRuntime } from './rsc';
17
+ import { classifyModuleRuntime, isServerActionsModule } from './rsc';
18
18
  import type { ArchoraConfig } from '../config/frontScopeConfig';
19
19
 
20
20
  export interface BuildGraphInput {
@@ -111,6 +111,7 @@ export async function buildGraph(input: BuildGraphInput): Promise<BuildGraphResu
111
111
  ...(p.imports.some((i) => i.specifier === 'server-only') ? { importsServerOnly: true } : {}),
112
112
  ...(p.imports.some((i) => i.specifier === 'client-only') ? { importsClientOnly: true } : {}),
113
113
  }),
114
+ ...(isServerActionsModule(p.directives) ? { isServerActions: true } : {}),
114
115
  }));
115
116
  const parserFacts = parsed.map((p) => toParsedFileSummary(p, input.framework ?? 'unknown'));
116
117
 
@@ -1,4 +1,4 @@
1
- import type { ModuleId, ModuleMetrics, ModuleNode } from './types';
1
+ import type { ModuleId, ModuleMetrics, ModuleNode, ParsedFileSummary } from './types';
2
2
 
3
3
  export interface RankHotZonesInput {
4
4
  modules: ModuleNode[];
@@ -13,5 +13,97 @@ export function rankHotZones(input: RankHotZonesInput): ModuleId[] {
13
13
  .map((m) => ({ id: m.id, score: metrics[m.id]?.hotnessScore ?? 0 }))
14
14
  .filter((x) => x.score > 0);
15
15
  candidates.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
16
- return candidates.slice(0, topN).map((c) => c.id);
16
+ // Rank the full hotness window first, then drop re-export barrels from it.
17
+ // A barrel's high fan-out is by design, not a risk — but excluding it from the
18
+ // candidate pool before the cut would only backfill the window with lower-
19
+ // signal modules. Removing it from the top-N keeps the surfaced set focused on
20
+ // genuine hot zones without inventing new ones.
21
+ const barrels = new Set(modules.filter((m) => m.isBarrel).map((m) => m.id));
22
+ return candidates
23
+ .slice(0, topN)
24
+ .map((c) => c.id)
25
+ .filter((id) => !barrels.has(id));
26
+ }
27
+
28
+ // A barrel imports the things it re-exports, so it has a non-trivial fan-out;
29
+ // a leaf that defines its own exports has fan-out 0. Below this floor we keep
30
+ // the module even if it is thin (small multi-export utils re-export nothing).
31
+ const BARREL_MIN_FANOUT = 3;
32
+ // Barrels expose a real aggregation surface — too few pass-throughs and it is
33
+ // just a normal module with a couple of imports.
34
+ const BARREL_MIN_SURFACE = 5;
35
+ // Source lines per surface item. Barrels are thin glue (≈1–2 loc per
36
+ // re-export); real modules carry many loc per export.
37
+ const BARREL_MAX_LOC_PER_SURFACE = 4;
38
+
39
+ export interface MarkBarrelModulesInput {
40
+ modules: ModuleNode[];
41
+ metrics: Record<ModuleId, ModuleMetrics>;
42
+ parserFacts?: ParsedFileSummary[];
43
+ }
44
+
45
+ /**
46
+ * Tag re-export barrels so hot-zone ranking can skip them. Runs post-graph
47
+ * because barrel detection needs the fan-out metric plus the module's loc and
48
+ * export count. Mutates `ModuleNode.isBarrel` in place; additive, leaves every
49
+ * other field untouched.
50
+ */
51
+ export function markBarrelModules(input: MarkBarrelModulesInput): void {
52
+ const { modules, metrics, parserFacts } = input;
53
+ const factsByModule = indexParserFacts(parserFacts);
54
+ for (const m of modules) {
55
+ if (isBarrelModule(m, metrics[m.id], factsByModule)) m.isBarrel = true;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * A re-export barrel (e.g. `packages/core/index.ts` re-exporting every module)
61
+ * has high fan-out by design — that is its job, not a risk, and "split this
62
+ * module" is bad advice. Detected by THINNESS rather than a re-export ratio:
63
+ * the parser almost never populates `ExportFact.source` (star re-exports, and
64
+ * named re-exports it can't resolve), so the ratio reads 0 even for obvious
65
+ * barrels. A barrel instead imports the things it passes through (fan-out),
66
+ * exposes a wide surface, and carries very few source lines per surface item —
67
+ * it is thin glue, not a module with real logic.
68
+ */
69
+ function isBarrelModule(
70
+ module: ModuleNode,
71
+ metrics: ModuleMetrics | undefined,
72
+ factsByModule: Map<string, ParsedFileSummary>,
73
+ ): boolean {
74
+ if (!metrics) return false;
75
+ const fanOut = metrics.fanOut;
76
+ if (fanOut < BARREL_MIN_FANOUT) return false;
77
+ const summary = matchParserFacts(factsByModule, module.id);
78
+ const ownExportCount = module.exports.length || (summary?.exports.length ?? 0);
79
+ const surface = Math.max(fanOut, ownExportCount);
80
+ if (surface < BARREL_MIN_SURFACE) return false;
81
+ const loc = module.loc || summary?.loc || 0;
82
+ return loc < surface * BARREL_MAX_LOC_PER_SURFACE;
83
+ }
84
+
85
+ function indexParserFacts(
86
+ parserFacts: ParsedFileSummary[] | undefined,
87
+ ): Map<string, ParsedFileSummary> {
88
+ const out = new Map<string, ParsedFileSummary>();
89
+ if (!parserFacts) return out;
90
+ for (const f of parserFacts) out.set(normalizeRel(f.relPath), f);
91
+ return out;
92
+ }
93
+
94
+ function matchParserFacts(
95
+ factsByModule: Map<string, ParsedFileSummary>,
96
+ moduleId: ModuleId,
97
+ ): ParsedFileSummary | undefined {
98
+ const id = normalizeRel(moduleId);
99
+ const direct = factsByModule.get(id);
100
+ if (direct) return direct;
101
+ for (const [rel, f] of factsByModule) {
102
+ if (id.endsWith(`/${rel}`) || rel.endsWith(`/${id}`)) return f;
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ function normalizeRel(p: string): string {
108
+ return p.replace(/^\.\//u, '');
17
109
  }
@@ -26,7 +26,7 @@ import { createResolver } from './resolve';
26
26
  import { createParserRegistry, isParseFailure } from './parsers';
27
27
  import { classifyKind, isInfra } from './classify';
28
28
  import { isNuxtComposablePath } from './buildGraph';
29
- import { classifyModuleRuntime, detectRscLeaks } from './rsc';
29
+ import { classifyModuleRuntime, detectRscLeaks, isServerActionsModule } from './rsc';
30
30
  import { detectCycles } from './cycles';
31
31
  import { computeMetrics } from './metrics';
32
32
  import { rankHotZones } from './hotZones';
@@ -191,6 +191,7 @@ export async function incrementalAnalyze(input: IncrementalAnalyzeInput): Promis
191
191
  ? { importsClientOnly: true }
192
192
  : {}),
193
193
  }),
194
+ ...(isServerActionsModule(p.directives) ? { isServerActions: true } : {}),
194
195
  });
195
196
  }
196
197
  const updatedModuleIds = new Set(updatedModules.map((m) => m.id));
@@ -14,7 +14,7 @@ import { detectFramework, type Framework } from './detect';
14
14
  import { detectCycles } from './cycles';
15
15
  import { countBrokenCycles, parseEdgeKey } from './feedbackArcSet';
16
16
  import { computeMetrics } from './metrics';
17
- import { rankHotZones } from './hotZones';
17
+ import { markBarrelModules, rankHotZones } from './hotZones';
18
18
  import { detectLayerViolations } from './layers';
19
19
  import { computeArchDebt } from './archDebt';
20
20
  import { computeRecommendations } from './recommendations';
@@ -181,6 +181,7 @@ export async function analyze(
181
181
  if (entries.includes(m.id) && m.kind === 'unknown') m.kind = 'entry';
182
182
  }
183
183
 
184
+ markBarrelModules({ modules, metrics, parserFacts });
184
185
  const hotZones = rankHotZones({ modules, metrics, topN: options.topHotZones ?? 10 });
185
186
  const layerViolations = detectLayerViolations(modules, edges, config.layerOverrides);
186
187
  const archDebt = computeArchDebt({