@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
|
@@ -43,6 +43,26 @@ describe('classifyModuleRuntime', () => {
|
|
|
43
43
|
).toBe('server');
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
it("importing the 'server-only' package marks the module server (any framework)", () => {
|
|
47
|
+
expect(
|
|
48
|
+
classifyModuleRuntime({
|
|
49
|
+
relPath: 'lib/secret.ts',
|
|
50
|
+
framework: 'unknown',
|
|
51
|
+
importsServerOnly: true,
|
|
52
|
+
}),
|
|
53
|
+
).toBe('server');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("importing the 'client-only' package marks the module client", () => {
|
|
57
|
+
expect(
|
|
58
|
+
classifyModuleRuntime({
|
|
59
|
+
relPath: 'lib/browser.ts',
|
|
60
|
+
framework: 'next',
|
|
61
|
+
importsClientOnly: true,
|
|
62
|
+
}),
|
|
63
|
+
).toBe('client');
|
|
64
|
+
});
|
|
65
|
+
|
|
46
66
|
it('Next App Router defaults to server, pages/api server-only', () => {
|
|
47
67
|
expect(classifyModuleRuntime({ relPath: 'app/page.tsx', framework: 'next' })).toBe('server');
|
|
48
68
|
expect(classifyModuleRuntime({ relPath: 'pages/api/hello.ts', framework: 'next' })).toBe(
|
|
@@ -98,6 +118,57 @@ describe('detectRscLeaks', () => {
|
|
|
98
118
|
expect(leaks[0]?.edge?.to).toBe('lib/db.ts');
|
|
99
119
|
});
|
|
100
120
|
|
|
121
|
+
it("flags a client component importing a module poisoned with 'server-only'", async () => {
|
|
122
|
+
const fs = createInMemoryFileSource('/repo', {
|
|
123
|
+
'package.json': JSON.stringify({ dependencies: { next: '^14.0.0' } }),
|
|
124
|
+
'app/Form.tsx':
|
|
125
|
+
"'use client';\nimport { getSecret } from '../lib/secret';\nexport default function F(){ return getSecret(); }\n",
|
|
126
|
+
'lib/secret.ts': "import 'server-only';\nexport const getSecret = () => 1;\n",
|
|
127
|
+
});
|
|
128
|
+
const scan = await analyze(fs);
|
|
129
|
+
const leaks = scan.contractViolations.filter((v) => v.kind === 'rsc-leak');
|
|
130
|
+
expect(
|
|
131
|
+
leaks.some((l) => l.edge?.from === 'app/Form.tsx' && l.edge?.to === 'lib/secret.ts'),
|
|
132
|
+
).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('flags a transitive leak: client -> shared barrel -> server', () => {
|
|
136
|
+
const modules = [
|
|
137
|
+
mod('app/Form.tsx', 'client'),
|
|
138
|
+
mod('lib/index.ts', 'shared'), // barrel
|
|
139
|
+
mod('lib/db.ts', 'server'),
|
|
140
|
+
];
|
|
141
|
+
const edges = [
|
|
142
|
+
edge('app/Form.tsx', 'lib/index.ts'), // client -> shared (legal alone)
|
|
143
|
+
edge('lib/index.ts', 'lib/db.ts'), // shared re-exports server
|
|
144
|
+
];
|
|
145
|
+
const leaks = detectRscLeaks({ modules, edges });
|
|
146
|
+
const transitive = leaks.find(
|
|
147
|
+
(l) => l.edge?.from === 'app/Form.tsx' && l.edge?.to === 'lib/db.ts',
|
|
148
|
+
);
|
|
149
|
+
expect(transitive).toBeDefined();
|
|
150
|
+
expect(transitive?.kind).toBe('rsc-leak');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('does not flag a client -> shared chain that never reaches a server module', () => {
|
|
154
|
+
const modules = [
|
|
155
|
+
mod('app/Form.tsx', 'client'),
|
|
156
|
+
mod('lib/index.ts', 'shared'),
|
|
157
|
+
mod('lib/util.ts', 'shared'),
|
|
158
|
+
];
|
|
159
|
+
const edges = [edge('app/Form.tsx', 'lib/index.ts'), edge('lib/index.ts', 'lib/util.ts')];
|
|
160
|
+
expect(detectRscLeaks({ modules, edges })).toHaveLength(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('does not double-report when a client directly imports a server module', () => {
|
|
164
|
+
const modules = [mod('app/Form.tsx', 'client'), mod('lib/db.ts', 'server')];
|
|
165
|
+
const edges = [edge('app/Form.tsx', 'lib/db.ts')];
|
|
166
|
+
const leaks = detectRscLeaks({ modules, edges }).filter(
|
|
167
|
+
(l) => l.edge?.from === 'app/Form.tsx' && l.edge?.to === 'lib/db.ts',
|
|
168
|
+
);
|
|
169
|
+
expect(leaks).toHaveLength(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
101
172
|
it('does not flag type-only edges or shared modules', () => {
|
|
102
173
|
const modules = [mod('a.ts', 'client'), mod('b.ts', 'server'), mod('shared.ts', 'shared')];
|
|
103
174
|
const edges = [
|
package/src/analyzer/archDebt.ts
CHANGED
|
@@ -8,20 +8,39 @@ interface Inputs {
|
|
|
8
8
|
hotZoneCount: number;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// Composite **heuristic summary** arch-debt 0..100 (higher = worse). Not a
|
|
12
|
+
// measurement but a weighted index of four proven components. Weights are ordered
|
|
13
|
+
// by structural severity and cost-to-fix (not data-calibrated — that needs a
|
|
14
|
+
// labeled corpus, see block A of the plan):
|
|
15
|
+
// - cycles 0.35 — heaviest and most expensive to fix, breaks isolation.
|
|
16
|
+
// - layers 0.30 — architectural erosion, but more local than a cycle.
|
|
17
|
+
// - coupling 0.20 — a continuous soft signal (instability), not a bug per se.
|
|
18
|
+
// - hotZones 0.15 — derived signal, partly overlaps coupling → lowest.
|
|
19
|
+
// Sum = 1.0. Each component saturates, normalized to project size. Surfaced in
|
|
20
|
+
// UI/reports as a "grade summary", not as a precise metric.
|
|
21
|
+
const DEBT_WEIGHTS = { cycles: 0.35, layers: 0.3, coupling: 0.2, hotZones: 0.15 } as const;
|
|
22
|
+
|
|
23
|
+
// Saturation divisors: the share of project modules at which a component nears
|
|
24
|
+
// saturation. cycles: ~5% of modules in cycles is already "bad"; layers stricter (~3%).
|
|
25
|
+
const CYCLE_SATURATION_RATIO = 0.05;
|
|
26
|
+
const LAYER_SATURATION_RATIO = 0.03;
|
|
27
|
+
|
|
28
|
+
// Letter-grade thresholds (score → grade). Ordinal cut-offs of the heuristic
|
|
29
|
+
// summary: <15 A (healthy) … ≥70 F (systemic debt).
|
|
30
|
+
const GRADE_THRESHOLDS = { A: 15, B: 30, C: 50, D: 70 } as const;
|
|
31
|
+
|
|
13
32
|
export function computeArchDebt(inputs: Inputs): ArchDebt {
|
|
14
33
|
const realModules = inputs.modules.filter((m) => !m.isInfra);
|
|
15
34
|
const moduleCount = Math.max(1, realModules.length);
|
|
16
35
|
|
|
17
36
|
const cycleWeight = inputs.cycles.reduce((acc, c) => acc + (c.severity === 'direct' ? 2 : 1), 0);
|
|
18
|
-
const cycleScore = saturate(cycleWeight / Math.max(1, moduleCount *
|
|
37
|
+
const cycleScore = saturate(cycleWeight / Math.max(1, moduleCount * CYCLE_SATURATION_RATIO));
|
|
19
38
|
|
|
20
39
|
const violationWeight = inputs.layerViolations.reduce(
|
|
21
40
|
(acc, v) => acc + (v.severity === 'error' ? 3 : 1),
|
|
22
41
|
0,
|
|
23
42
|
);
|
|
24
|
-
const layerScore = saturate(violationWeight / Math.max(1, moduleCount *
|
|
43
|
+
const layerScore = saturate(violationWeight / Math.max(1, moduleCount * LAYER_SATURATION_RATIO));
|
|
25
44
|
|
|
26
45
|
const hotScore = saturate((inputs.hotZoneCount * 10) / moduleCount);
|
|
27
46
|
|
|
@@ -36,7 +55,11 @@ export function computeArchDebt(inputs: Inputs): ArchDebt {
|
|
|
36
55
|
const couplingScore = instCount > 0 ? instSum / instCount : 0;
|
|
37
56
|
|
|
38
57
|
const score = clamp(
|
|
39
|
-
100 *
|
|
58
|
+
100 *
|
|
59
|
+
(cycleScore * DEBT_WEIGHTS.cycles +
|
|
60
|
+
layerScore * DEBT_WEIGHTS.layers +
|
|
61
|
+
hotScore * DEBT_WEIGHTS.hotZones +
|
|
62
|
+
couplingScore * DEBT_WEIGHTS.coupling),
|
|
40
63
|
);
|
|
41
64
|
|
|
42
65
|
return {
|
|
@@ -60,9 +83,9 @@ function clamp(x: number): number {
|
|
|
60
83
|
}
|
|
61
84
|
|
|
62
85
|
function gradeOf(score: number): ArchDebt['grade'] {
|
|
63
|
-
if (score <
|
|
64
|
-
if (score <
|
|
65
|
-
if (score <
|
|
66
|
-
if (score <
|
|
86
|
+
if (score < GRADE_THRESHOLDS.A) return 'A';
|
|
87
|
+
if (score < GRADE_THRESHOLDS.B) return 'B';
|
|
88
|
+
if (score < GRADE_THRESHOLDS.C) return 'C';
|
|
89
|
+
if (score < GRADE_THRESHOLDS.D) return 'D';
|
|
67
90
|
return 'F';
|
|
68
91
|
}
|
|
@@ -108,6 +108,8 @@ export async function buildGraph(input: BuildGraphInput): Promise<BuildGraphResu
|
|
|
108
108
|
relPath: p.relPath,
|
|
109
109
|
framework: input.framework ?? 'unknown',
|
|
110
110
|
...(p.directives ? { directives: p.directives } : {}),
|
|
111
|
+
...(p.imports.some((i) => i.specifier === 'server-only') ? { importsServerOnly: true } : {}),
|
|
112
|
+
...(p.imports.some((i) => i.specifier === 'client-only') ? { importsClientOnly: true } : {}),
|
|
111
113
|
}),
|
|
112
114
|
}));
|
|
113
115
|
const parserFacts = parsed.map((p) => toParsedFileSummary(p, input.framework ?? 'unknown'));
|
|
@@ -255,6 +257,33 @@ export async function buildGraph(input: BuildGraphInput): Promise<BuildGraphResu
|
|
|
255
257
|
}
|
|
256
258
|
}
|
|
257
259
|
|
|
260
|
+
// Nuxt auto-import: composables/** are exposed globally, a consumer calls
|
|
261
|
+
// useFoo() with no import. Resolve the call against a name -> composable file
|
|
262
|
+
// registry. unplugin-auto-import outside Nuxt is config-driven (see docs limitation).
|
|
263
|
+
const composableRegistry = buildComposableRegistry(parsed, framework);
|
|
264
|
+
if (composableRegistry.size > 0) {
|
|
265
|
+
for (const file of parsed) {
|
|
266
|
+
if (!file.callIdentifiers || file.callIdentifiers.length === 0) continue;
|
|
267
|
+
for (const name of file.callIdentifiers) {
|
|
268
|
+
const target = composableRegistry.get(name);
|
|
269
|
+
if (!target || target === file.relPath) continue;
|
|
270
|
+
const key = `${file.relPath}\u0001${target}`;
|
|
271
|
+
if (existingEdges.has(key)) continue;
|
|
272
|
+
existingEdges.add(key);
|
|
273
|
+
edges.push({
|
|
274
|
+
from: file.relPath,
|
|
275
|
+
to: target,
|
|
276
|
+
kind: 'auto-import',
|
|
277
|
+
specifier: name,
|
|
278
|
+
resolved: true,
|
|
279
|
+
confidence: 'medium',
|
|
280
|
+
resolutionKind: 'framework-auto',
|
|
281
|
+
approximate: true,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
258
287
|
return { modules, edges, parserFacts, warnings };
|
|
259
288
|
}
|
|
260
289
|
|
|
@@ -365,6 +394,44 @@ function buildComponentRegistry(
|
|
|
365
394
|
return registry;
|
|
366
395
|
}
|
|
367
396
|
|
|
397
|
+
// Nuxt auto-import: composables/**/*.{ts,js} are exposed globally. Registry name =
|
|
398
|
+
// named exports + file basename (for `export default`). First-wins on collision.
|
|
399
|
+
// Nuxt only: unplugin-auto-import outside Nuxt is config-driven (see docs limitation).
|
|
400
|
+
const NUXT_COMPOSABLE_EXT = /\.[cm]?[jt]s$/u;
|
|
401
|
+
|
|
402
|
+
// True when one of `rel`'s directory segments equals `segment`. Linear scan, used
|
|
403
|
+
// instead of `(^|\/)<dir>\/.+` regexes: paths come from scanned, untrusted repos,
|
|
404
|
+
// so a `.+`-based pattern that re-anchors on every `/<dir>/` is a ReDoS risk.
|
|
405
|
+
function isUnderDirSegment(rel: string, segment: string): boolean {
|
|
406
|
+
const lastSlash = rel.lastIndexOf('/');
|
|
407
|
+
if (lastSlash < 0) return false;
|
|
408
|
+
return rel.slice(0, lastSlash).split('/').includes(segment);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// File under a `composables/` directory with a JS/TS extension (js/ts/mjs/mts/cjs/cts).
|
|
412
|
+
export function isNuxtComposablePath(rel: string): boolean {
|
|
413
|
+
return NUXT_COMPOSABLE_EXT.test(rel) && isUnderDirSegment(rel, 'composables');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function buildComposableRegistry(
|
|
417
|
+
parsed: ParsedFile[],
|
|
418
|
+
framework: Framework | undefined,
|
|
419
|
+
): Map<string, string> {
|
|
420
|
+
const registry = new Map<string, string>();
|
|
421
|
+
if (framework !== 'nuxt') return registry;
|
|
422
|
+
for (const file of parsed) {
|
|
423
|
+
if (!isNuxtComposablePath(file.relPath)) continue;
|
|
424
|
+
const names = new Set<string>();
|
|
425
|
+
for (const exp of file.exports) if (exp !== 'default') names.add(exp);
|
|
426
|
+
const slash = file.relPath.lastIndexOf('/');
|
|
427
|
+
const dot = file.relPath.lastIndexOf('.');
|
|
428
|
+
const base = file.relPath.slice(slash + 1, dot);
|
|
429
|
+
if (base.length > 0) names.add(base);
|
|
430
|
+
for (const name of names) if (!registry.has(name)) registry.set(name, file.relPath);
|
|
431
|
+
}
|
|
432
|
+
return registry;
|
|
433
|
+
}
|
|
434
|
+
|
|
368
435
|
function indexFilesByDir(files: string[]): Map<string, string[]> {
|
|
369
436
|
const map = new Map<string, string[]>();
|
|
370
437
|
for (const f of files) {
|
|
@@ -599,10 +666,14 @@ function frameworkFactsFromPath(
|
|
|
599
666
|
) {
|
|
600
667
|
facts.push({ kind: 'sveltekit-route', value: relPath, confidence: 'medium' });
|
|
601
668
|
}
|
|
602
|
-
if (
|
|
669
|
+
if (
|
|
670
|
+
framework === 'nuxt' &&
|
|
671
|
+
relPath.endsWith('.vue') &&
|
|
672
|
+
isUnderDirSegment(relPath, 'components')
|
|
673
|
+
) {
|
|
603
674
|
facts.push({ kind: 'nuxt-auto-component', value: relPath, confidence: 'medium' });
|
|
604
675
|
}
|
|
605
|
-
if (framework === 'nuxt' &&
|
|
676
|
+
if (framework === 'nuxt' && isNuxtComposablePath(relPath)) {
|
|
606
677
|
facts.push({ kind: 'nuxt-auto-composable', value: relPath, confidence: 'medium' });
|
|
607
678
|
}
|
|
608
679
|
return facts;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// are kept on the chunk for size accounting but don't surface as recs (we'd
|
|
7
7
|
// flag them as "external" otherwise, which is noise on every chunk).
|
|
8
8
|
|
|
9
|
-
import type { ModuleId, ModuleNode } from '../types';
|
|
9
|
+
import type { DependencyEdge, ModuleId, ModuleNode } from '../types';
|
|
10
10
|
import {
|
|
11
11
|
DEFAULT_BUNDLE_THRESHOLDS,
|
|
12
12
|
type BundleBloat,
|
|
@@ -22,6 +22,8 @@ export interface AnalyzeBundleInput {
|
|
|
22
22
|
modules: ModuleNode[];
|
|
23
23
|
stats: ParsedBundleStats;
|
|
24
24
|
thresholds?: Partial<BundleThresholds>;
|
|
25
|
+
/** Edge graph: needed for barrel-leak (re-export hubs). Without it the signal is skipped. */
|
|
26
|
+
edges?: DependencyEdge[];
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export function analyzeBundle(input: AnalyzeBundleInput): BundleReport {
|
|
@@ -48,6 +50,18 @@ export function analyzeBundle(input: AnalyzeBundleInput): BundleReport {
|
|
|
48
50
|
|
|
49
51
|
const totalSize = chunks.reduce((acc, c) => acc + c.size, 0);
|
|
50
52
|
const bloat = detectBloat(chunks, moduleToChunks, thresholds);
|
|
53
|
+
if (input.edges && input.edges.length > 0) {
|
|
54
|
+
bloat.push(
|
|
55
|
+
...detectBarrelLeaks(
|
|
56
|
+
input.modules,
|
|
57
|
+
input.edges,
|
|
58
|
+
moduleToChunks,
|
|
59
|
+
chunks,
|
|
60
|
+
thresholds,
|
|
61
|
+
bloat.length,
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
51
65
|
|
|
52
66
|
return {
|
|
53
67
|
format: input.stats.format,
|
|
@@ -140,6 +154,75 @@ function detectBloat(
|
|
|
140
154
|
return out;
|
|
141
155
|
}
|
|
142
156
|
|
|
157
|
+
const BARREL_NAME_RE = /(^|\/)index\.[cm]?[jt]sx?$/u;
|
|
158
|
+
|
|
159
|
+
// barrel-leak: a barrel (re-export hub `index.*`) pulls a large share of its
|
|
160
|
+
// re-export targets into one chunk. Symptom of failed tree-shaking — importing the
|
|
161
|
+
// barrel drags the whole directory into the bundle. A "graph × bundle" signal that
|
|
162
|
+
// pure stats tools can't produce: targets come from the import graph, co-location
|
|
163
|
+
// from the chunks.
|
|
164
|
+
function detectBarrelLeaks(
|
|
165
|
+
modules: ModuleNode[],
|
|
166
|
+
edges: DependencyEdge[],
|
|
167
|
+
moduleToChunks: Record<ModuleId, string[]>,
|
|
168
|
+
chunks: BundleChunk[],
|
|
169
|
+
thresholds: BundleThresholds,
|
|
170
|
+
startSerial: number,
|
|
171
|
+
): BundleBloat[] {
|
|
172
|
+
const out: BundleBloat[] = [];
|
|
173
|
+
let serial = startSerial;
|
|
174
|
+
|
|
175
|
+
const outgoing = new Map<ModuleId, Set<ModuleId>>();
|
|
176
|
+
for (const e of edges) {
|
|
177
|
+
if (e.kind === 'type-only') continue;
|
|
178
|
+
let bucket = outgoing.get(e.from);
|
|
179
|
+
if (!bucket) {
|
|
180
|
+
bucket = new Set();
|
|
181
|
+
outgoing.set(e.from, bucket);
|
|
182
|
+
}
|
|
183
|
+
bucket.add(e.to);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const b of modules) {
|
|
187
|
+
if (!BARREL_NAME_RE.test(b.id)) continue;
|
|
188
|
+
const bChunks = moduleToChunks[b.id];
|
|
189
|
+
if (!bChunks || bChunks.length === 0) continue;
|
|
190
|
+
const dir = b.id.slice(0, b.id.lastIndexOf('/') + 1);
|
|
191
|
+
// re-export targets in the barrel's subtree (a barrel almost always re-exports siblings)
|
|
192
|
+
const targets = [...(outgoing.get(b.id) ?? [])].filter((t) => t !== b.id && t.startsWith(dir));
|
|
193
|
+
if (targets.length < thresholds.barrelMinModules) continue;
|
|
194
|
+
|
|
195
|
+
const bChunkSet = new Set(bChunks);
|
|
196
|
+
const colocated = targets.filter((t) =>
|
|
197
|
+
(moduleToChunks[t] ?? []).some((c) => bChunkSet.has(c)),
|
|
198
|
+
);
|
|
199
|
+
const share = colocated.length / targets.length;
|
|
200
|
+
if (colocated.length < thresholds.barrelMinModules || share < thresholds.barrelLeakShare) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const colocatedSet = new Set(colocated);
|
|
205
|
+
let sizeBytes = 0;
|
|
206
|
+
for (const c of chunks) {
|
|
207
|
+
if (!bChunkSet.has(c.id)) continue;
|
|
208
|
+
for (const m of c.modules)
|
|
209
|
+
if (m.moduleId && colocatedSet.has(m.moduleId)) sizeBytes += m.size;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
out.push({
|
|
213
|
+
id: `bundle:barrel:${serial++}:${b.id}`,
|
|
214
|
+
kind: 'barrel-leak',
|
|
215
|
+
severity: colocated.length >= thresholds.barrelMinModules * 2 ? 'high' : 'medium',
|
|
216
|
+
message: `Barrel "${displayShortId(b.id)}" pulls ${colocated.length}/${targets.length} re-exported modules into the same chunk (tree-shaking leak)`,
|
|
217
|
+
modules: [b.id],
|
|
218
|
+
chunks: [...bChunkSet],
|
|
219
|
+
detail: { sizeBytes, moduleCount: colocated.length },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
143
226
|
function formatBytes(bytes: number): string {
|
|
144
227
|
if (bytes < 1024) return `${bytes} B`;
|
|
145
228
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -29,7 +29,7 @@ export interface BundleChunkModule {
|
|
|
29
29
|
size: number;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export type BundleBloatKind = 'duplicate' | 'heavy-chunk' | 'solo-hot';
|
|
32
|
+
export type BundleBloatKind = 'duplicate' | 'heavy-chunk' | 'solo-hot' | 'barrel-leak';
|
|
33
33
|
|
|
34
34
|
export interface BundleBloat {
|
|
35
35
|
/** Stable id for grouping/diffing. */
|
|
@@ -47,6 +47,8 @@ export interface BundleBloat {
|
|
|
47
47
|
sizeBytes?: number;
|
|
48
48
|
chunkCount?: number;
|
|
49
49
|
sharePercent?: number;
|
|
50
|
+
/** barrel-leak: number of the barrel's re-export targets that landed in the same chunk. */
|
|
51
|
+
moduleCount?: number;
|
|
50
52
|
};
|
|
51
53
|
}
|
|
52
54
|
|
|
@@ -70,12 +72,18 @@ export interface BundleThresholds {
|
|
|
70
72
|
duplicateMinChunks: number;
|
|
71
73
|
/** Module taking >= share of a heavy chunk triggers `solo-hot` (0..1). */
|
|
72
74
|
soloHotShare: number;
|
|
75
|
+
/** Barrel re-exporting >= N same-tree modules is a `barrel-leak` candidate. */
|
|
76
|
+
barrelMinModules: number;
|
|
77
|
+
/** Share of a barrel's re-export targets co-located in its chunk to flag (0..1). */
|
|
78
|
+
barrelLeakShare: number;
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
export const DEFAULT_BUNDLE_THRESHOLDS: BundleThresholds = {
|
|
76
82
|
heavyChunkBytes: 500_000,
|
|
77
83
|
duplicateMinChunks: 2,
|
|
78
84
|
soloHotShare: 0.8,
|
|
85
|
+
barrelMinModules: 8,
|
|
86
|
+
barrelLeakShare: 0.8,
|
|
79
87
|
};
|
|
80
88
|
|
|
81
89
|
/** Parsed-but-not-yet-analyzed stats. Produced by the format parsers. */
|
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// на полный `analyze()`.
|
|
1
|
+
// Incremental rescan: takes the previous ScanResult and a list of changed
|
|
2
|
+
// relative paths. If every change is a "pure modify" of existing modules with
|
|
3
|
+
// static imports, we only re-resolve their outgoing edges and recompute
|
|
4
|
+
// downstream metrics/cycles. Otherwise we fall back to a full `analyze()`.
|
|
6
5
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// The "simple variant" from plan 15.2: optimize only the typical IDE-save
|
|
7
|
+
// scenario. Glob/prefix imports, template auto-imports, config changes and
|
|
8
|
+
// file add/remove are routed through the full pipeline - correctness matters
|
|
9
|
+
// more than speed, and these cases are rare when saving a single file.
|
|
11
10
|
|
|
12
11
|
import type { FileSource } from './fileSource';
|
|
13
12
|
import type {
|
|
@@ -26,6 +25,7 @@ import { loadAliases } from './loadAliases';
|
|
|
26
25
|
import { createResolver } from './resolve';
|
|
27
26
|
import { createParserRegistry, isParseFailure } from './parsers';
|
|
28
27
|
import { classifyKind, isInfra } from './classify';
|
|
28
|
+
import { isNuxtComposablePath } from './buildGraph';
|
|
29
29
|
import { classifyModuleRuntime, detectRscLeaks } from './rsc';
|
|
30
30
|
import { detectCycles } from './cycles';
|
|
31
31
|
import { computeMetrics } from './metrics';
|
|
@@ -148,6 +148,17 @@ export async function incrementalAnalyze(input: IncrementalAnalyzeInput): Promis
|
|
|
148
148
|
if (result.templateRefs && result.templateRefs.length > 0) {
|
|
149
149
|
return analyze(source, options);
|
|
150
150
|
}
|
|
151
|
+
// Nuxt composable auto-import edges are built from the full file list
|
|
152
|
+
// (the composables/** registry crossed with consumer call identifiers) -
|
|
153
|
+
// like glob/templateRefs, they can only be safely recomputed by a cold scan.
|
|
154
|
+
if (prev.project.detectedFramework === 'nuxt') {
|
|
155
|
+
if (
|
|
156
|
+
isNuxtComposablePath(rel) ||
|
|
157
|
+
(result.callIdentifiers && result.callIdentifiers.length > 0)
|
|
158
|
+
) {
|
|
159
|
+
return analyze(source, options);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
151
162
|
reparsed.set(rel, result);
|
|
152
163
|
}
|
|
153
164
|
|
|
@@ -173,6 +184,12 @@ export async function incrementalAnalyze(input: IncrementalAnalyzeInput): Promis
|
|
|
173
184
|
relPath: p.relPath,
|
|
174
185
|
framework: prev.project.detectedFramework as Framework,
|
|
175
186
|
...(p.directives ? { directives: p.directives } : {}),
|
|
187
|
+
...(p.imports.some((i) => i.specifier === 'server-only')
|
|
188
|
+
? { importsServerOnly: true }
|
|
189
|
+
: {}),
|
|
190
|
+
...(p.imports.some((i) => i.specifier === 'client-only')
|
|
191
|
+
? { importsClientOnly: true }
|
|
192
|
+
: {}),
|
|
176
193
|
}),
|
|
177
194
|
});
|
|
178
195
|
}
|
package/src/analyzer/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Loads path aliases from tsconfig (following the `extends` chain) and the
|
|
2
|
+
// Vite config. Pulled out of `analyze()` so `incrementalAnalyze()` can reuse
|
|
3
|
+
// it - a single-file rescan needs the same resolver as a full analysis,
|
|
4
|
+
// otherwise `@/*` resolution breaks midway.
|
|
5
5
|
|
|
6
6
|
import type { FileSource } from './fileSource';
|
|
7
7
|
import type { ProjectRef } from './types';
|
package/src/analyzer/metrics.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { Cycle, DependencyEdge, ModuleId, ModuleMetrics, ModuleNode } from './types';
|
|
2
2
|
|
|
3
|
+
// Hotness premium for cycle membership: a module in an SCC is riskier than an
|
|
4
|
+
// equally-coupled module outside one (changes ripple both ways). 1.5 = a modest
|
|
5
|
+
// +50%: cycle membership lifts the score but does not override coupling itself.
|
|
6
|
+
// Heuristic default, justified ordinally (cycle > non-cycle), not data-calibrated.
|
|
7
|
+
const CYCLE_HOTNESS_MULTIPLIER = 1.5;
|
|
8
|
+
|
|
3
9
|
export interface ComputeMetricsInput {
|
|
4
10
|
modules: ModuleNode[];
|
|
5
11
|
edges: DependencyEdge[];
|
|
@@ -56,8 +62,11 @@ export function computeMetrics(input: ComputeMetricsInput): Record<ModuleId, Mod
|
|
|
56
62
|
const instability = sum === 0 ? 0 : fo / sum;
|
|
57
63
|
const cycle = inCycle.has(id);
|
|
58
64
|
const coupling = maxCoupling === 0 ? 0 : (rawCoupling.get(id) ?? 0) / maxCoupling;
|
|
65
|
+
// sizeFactor ∈ [1, 2]: size, log2-normalized against the largest file. log
|
|
66
|
+
// (not linear) so one giant file doesn't dominate; the upper bound of 2 caps
|
|
67
|
+
// the size contribution at a doubling.
|
|
59
68
|
const sizeFactor = maxLoc === 0 ? 1 : 1 + Math.log2(1 + m.loc) / Math.log2(1 + maxLoc);
|
|
60
|
-
const hotness = coupling * (cycle ?
|
|
69
|
+
const hotness = coupling * (cycle ? CYCLE_HOTNESS_MULTIPLIER : 1) * sizeFactor;
|
|
61
70
|
result[id] = {
|
|
62
71
|
fanIn: fi,
|
|
63
72
|
fanOut: fo,
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Beta: extracts script blocks with a regex, without svelte/compiler. Picks up
|
|
2
|
+
// imports (static and dynamic) from `<script>` and `<script context="module">`;
|
|
3
|
+
// the template and `$:` are not parsed (Svelte resolves components via script
|
|
4
|
+
// imports anyway). First-class targets are Vue/React/Nuxt/Next; Svelte is
|
|
5
|
+
// deliberately left short of full AST parsing.
|
|
1
6
|
import type { ParsedFile } from '../types';
|
|
2
7
|
import type { TsParser } from './tsParser';
|
|
3
8
|
|
|
@@ -39,6 +39,7 @@ export function createTsParser(options: TsParserOptions = {}): TsParser {
|
|
|
39
39
|
|
|
40
40
|
const imports: RawImport[] = [];
|
|
41
41
|
const exports = new Set<string>();
|
|
42
|
+
const callees = new Set<string>();
|
|
42
43
|
let hasDefineStore = false;
|
|
43
44
|
|
|
44
45
|
visit(
|
|
@@ -46,6 +47,7 @@ export function createTsParser(options: TsParserOptions = {}): TsParser {
|
|
|
46
47
|
sf,
|
|
47
48
|
imports,
|
|
48
49
|
exports,
|
|
50
|
+
callees,
|
|
49
51
|
() => {
|
|
50
52
|
hasDefineStore = true;
|
|
51
53
|
},
|
|
@@ -62,6 +64,7 @@ export function createTsParser(options: TsParserOptions = {}): TsParser {
|
|
|
62
64
|
exports: [...exports].sort(),
|
|
63
65
|
hasDefineStore: hasDefineStore || /\bdefineStore\s*\(/.test(input.content),
|
|
64
66
|
...(directives.length > 0 ? { directives } : {}),
|
|
67
|
+
...(callees.size > 0 ? { callIdentifiers: [...callees].sort() } : {}),
|
|
65
68
|
};
|
|
66
69
|
},
|
|
67
70
|
};
|
|
@@ -78,9 +81,14 @@ function visit(
|
|
|
78
81
|
sf: ts.SourceFile,
|
|
79
82
|
imports: RawImport[],
|
|
80
83
|
exports: Set<string>,
|
|
84
|
+
callees: Set<string>,
|
|
81
85
|
markDefineStore: () => void,
|
|
82
86
|
loaders: DynamicLoaderConfig[],
|
|
83
87
|
): void {
|
|
88
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
89
|
+
callees.add(node.expression.text);
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
if (ts.isImportDeclaration(node)) {
|
|
85
93
|
const spec = literalText(node.moduleSpecifier);
|
|
86
94
|
if (spec !== null) {
|
|
@@ -208,7 +216,9 @@ function visit(
|
|
|
208
216
|
exports.add('default');
|
|
209
217
|
}
|
|
210
218
|
|
|
211
|
-
ts.forEachChild(node, (child) =>
|
|
219
|
+
ts.forEachChild(node, (child) =>
|
|
220
|
+
visit(child, sf, imports, exports, callees, markDefineStore, loaders),
|
|
221
|
+
);
|
|
212
222
|
}
|
|
213
223
|
|
|
214
224
|
function literalText(node: ts.Node): string | null {
|
|
@@ -271,8 +271,9 @@ export function computeRecommendations(inputs: {
|
|
|
271
271
|
// Round to 2 decimals so i18n templates render `0.83` not `0.8333…`.
|
|
272
272
|
score: Math.round(c.score * 100) / 100,
|
|
273
273
|
},
|
|
274
|
-
// 0.5 .. 0.
|
|
275
|
-
|
|
274
|
+
// 0.5 .. 0.9 — driven by risk (strength + evidence + hidden + cross-boundary),
|
|
275
|
+
// so a cross-boundary missing-abstraction outranks a same-folder pair.
|
|
276
|
+
weight: 0.5 + Math.min(0.4, c.risk * 0.45),
|
|
276
277
|
});
|
|
277
278
|
}
|
|
278
279
|
}
|
|
@@ -393,12 +394,21 @@ function bundleParams(b: BundleBloat): Recommendation['params'] {
|
|
|
393
394
|
if (b.detail?.sizeBytes !== undefined) p['sizeBytes'] = b.detail.sizeBytes;
|
|
394
395
|
if (b.detail?.chunkCount !== undefined) p['chunkCount'] = b.detail.chunkCount;
|
|
395
396
|
if (b.detail?.sharePercent !== undefined) p['sharePercent'] = b.detail.sharePercent;
|
|
397
|
+
if (b.detail?.moduleCount !== undefined) p['moduleCount'] = b.detail.moduleCount;
|
|
396
398
|
return p;
|
|
397
399
|
}
|
|
398
400
|
|
|
399
401
|
function bundleWeight(b: BundleBloat): number {
|
|
400
402
|
// duplicates feel more actionable than just-large chunks; weight them up.
|
|
401
|
-
|
|
403
|
+
// barrel-leak is a concrete, fixable tree-shaking miss - rank near duplicates.
|
|
404
|
+
const base =
|
|
405
|
+
b.kind === 'duplicate'
|
|
406
|
+
? 0.85
|
|
407
|
+
: b.kind === 'barrel-leak'
|
|
408
|
+
? 0.8
|
|
409
|
+
: b.kind === 'heavy-chunk'
|
|
410
|
+
? 0.7
|
|
411
|
+
: 0.6;
|
|
402
412
|
const bump = b.severity === 'high' ? 0.1 : b.severity === 'medium' ? 0.05 : 0;
|
|
403
413
|
return Math.min(0.95, base + bump);
|
|
404
414
|
}
|
package/src/analyzer/resolve.ts
CHANGED
|
@@ -199,6 +199,25 @@ export interface Resolver {
|
|
|
199
199
|
|
|
200
200
|
export function createResolver(source: FileSource, config: ResolverConfig): Resolver {
|
|
201
201
|
const cache = new Map<string, string | null>();
|
|
202
|
+
// Resolve an alias whose target is itself an alias (alias→alias chain).
|
|
203
|
+
// `visited` guards against cyclic aliases (@x→@y→@x).
|
|
204
|
+
const resolveAliased = async (spec: string, visited: Set<string>): Promise<string | null> => {
|
|
205
|
+
if (visited.has(spec)) return null;
|
|
206
|
+
visited.add(spec);
|
|
207
|
+
const aliased = applyAlias(spec, config.aliases);
|
|
208
|
+
if (aliased.length === 0) return null;
|
|
209
|
+
for (const candidate of aliased) {
|
|
210
|
+
const found = await tryFile(source, candidate);
|
|
211
|
+
if (found) return found;
|
|
212
|
+
}
|
|
213
|
+
for (const candidate of aliased) {
|
|
214
|
+
if (isBare(candidate) && hasAliasCandidate(candidate, config.aliases)) {
|
|
215
|
+
const chained = await resolveAliased(candidate, visited);
|
|
216
|
+
if (chained) return chained;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
};
|
|
202
221
|
return {
|
|
203
222
|
hasLocalCandidate(specifier): boolean {
|
|
204
223
|
return hasAliasCandidate(specifier, config.aliases);
|
|
@@ -207,20 +226,9 @@ export function createResolver(source: FileSource, config: ResolverConfig): Reso
|
|
|
207
226
|
const cacheKey = `${fromRel}\u0001${specifier}`;
|
|
208
227
|
if (cache.has(cacheKey)) return cache.get(cacheKey) ?? null;
|
|
209
228
|
if (isBare(specifier)) {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
for (const candidate of aliased) {
|
|
216
|
-
const found = await tryFile(source, candidate);
|
|
217
|
-
if (found) {
|
|
218
|
-
cache.set(cacheKey, found);
|
|
219
|
-
return found;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
cache.set(cacheKey, null);
|
|
223
|
-
return null;
|
|
229
|
+
const found = await resolveAliased(specifier, new Set());
|
|
230
|
+
cache.set(cacheKey, found);
|
|
231
|
+
return found;
|
|
224
232
|
}
|
|
225
233
|
|
|
226
234
|
const fromDir = posixDirname(fromRel);
|