@archora/core 1.1.0 → 2.0.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/README.md +2 -2
- package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +1 -1
- package/src/analyzer/__tests__/analyze.test.ts +41 -0
- package/src/analyzer/__tests__/bundle.test.ts +99 -0
- package/src/analyzer/__tests__/hotZones.test.ts +128 -0
- package/src/analyzer/__tests__/incremental.test.ts +61 -13
- package/src/analyzer/__tests__/layerViolationAccuracy.test.ts +77 -0
- package/src/analyzer/__tests__/memoryRisk.test.ts +94 -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__/recommendations.test.ts +67 -0
- package/src/analyzer/__tests__/resolve.test.ts +54 -0
- package/src/analyzer/__tests__/rsc.test.ts +133 -3
- package/src/analyzer/archDebt.ts +32 -9
- package/src/analyzer/buildGraph.ts +75 -3
- package/src/analyzer/bundle/analyzeBundle.ts +84 -1
- package/src/analyzer/bundle/types.ts +9 -1
- package/src/analyzer/hotZones.ts +94 -2
- package/src/analyzer/incremental.ts +28 -10
- package/src/analyzer/index.ts +3 -1
- package/src/analyzer/loadAliases.ts +4 -4
- package/src/analyzer/memoryRisk.ts +33 -2
- 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 +28 -14
- package/src/analyzer/resolve.ts +51 -18
- package/src/analyzer/rsc.ts +90 -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 +22 -0
- package/src/cache/index.ts +18 -3
- package/src/diff/__tests__/diffScans.test.ts +64 -1
- package/src/diff/diffScans.ts +31 -1
- package/src/diff/types.ts +19 -1
- package/src/git/__tests__/computeTemporalCoupling.test.ts +24 -0
- package/src/git/computeTemporalCoupling.ts +35 -4
- package/src/git/types.ts +14 -1
- package/src/index.ts +5 -0
- package/src/report/__tests__/buildDeadCodeReport.test.ts +108 -0
- package/src/report/buildDeadCodeReport.ts +110 -0
- package/src/report/buildFixPlan.ts +14 -69
- 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/src/views/__tests__/analyzerViews.test.ts +6 -0
- package/src/views/analyzerViews.ts +1 -6
|
@@ -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 {
|
|
@@ -108,7 +108,10 @@ 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
|
}),
|
|
114
|
+
...(isServerActionsModule(p.directives) ? { isServerActions: true } : {}),
|
|
112
115
|
}));
|
|
113
116
|
const parserFacts = parsed.map((p) => toParsedFileSummary(p, input.framework ?? 'unknown'));
|
|
114
117
|
|
|
@@ -255,6 +258,33 @@ export async function buildGraph(input: BuildGraphInput): Promise<BuildGraphResu
|
|
|
255
258
|
}
|
|
256
259
|
}
|
|
257
260
|
|
|
261
|
+
// Nuxt auto-import: composables/** are exposed globally, a consumer calls
|
|
262
|
+
// useFoo() with no import. Resolve the call against a name -> composable file
|
|
263
|
+
// registry. unplugin-auto-import outside Nuxt is config-driven (see docs limitation).
|
|
264
|
+
const composableRegistry = buildComposableRegistry(parsed, framework);
|
|
265
|
+
if (composableRegistry.size > 0) {
|
|
266
|
+
for (const file of parsed) {
|
|
267
|
+
if (!file.callIdentifiers || file.callIdentifiers.length === 0) continue;
|
|
268
|
+
for (const name of file.callIdentifiers) {
|
|
269
|
+
const target = composableRegistry.get(name);
|
|
270
|
+
if (!target || target === file.relPath) continue;
|
|
271
|
+
const key = `${file.relPath}\u0001${target}`;
|
|
272
|
+
if (existingEdges.has(key)) continue;
|
|
273
|
+
existingEdges.add(key);
|
|
274
|
+
edges.push({
|
|
275
|
+
from: file.relPath,
|
|
276
|
+
to: target,
|
|
277
|
+
kind: 'auto-import',
|
|
278
|
+
specifier: name,
|
|
279
|
+
resolved: true,
|
|
280
|
+
confidence: 'medium',
|
|
281
|
+
resolutionKind: 'framework-auto',
|
|
282
|
+
approximate: true,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
258
288
|
return { modules, edges, parserFacts, warnings };
|
|
259
289
|
}
|
|
260
290
|
|
|
@@ -365,6 +395,44 @@ function buildComponentRegistry(
|
|
|
365
395
|
return registry;
|
|
366
396
|
}
|
|
367
397
|
|
|
398
|
+
// Nuxt auto-import: composables/**/*.{ts,js} are exposed globally. Registry name =
|
|
399
|
+
// named exports + file basename (for `export default`). First-wins on collision.
|
|
400
|
+
// Nuxt only: unplugin-auto-import outside Nuxt is config-driven (see docs limitation).
|
|
401
|
+
const NUXT_COMPOSABLE_EXT = /\.[cm]?[jt]s$/u;
|
|
402
|
+
|
|
403
|
+
// True when one of `rel`'s directory segments equals `segment`. Linear scan, used
|
|
404
|
+
// instead of `(^|\/)<dir>\/.+` regexes: paths come from scanned, untrusted repos,
|
|
405
|
+
// so a `.+`-based pattern that re-anchors on every `/<dir>/` is a ReDoS risk.
|
|
406
|
+
function isUnderDirSegment(rel: string, segment: string): boolean {
|
|
407
|
+
const lastSlash = rel.lastIndexOf('/');
|
|
408
|
+
if (lastSlash < 0) return false;
|
|
409
|
+
return rel.slice(0, lastSlash).split('/').includes(segment);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// File under a `composables/` directory with a JS/TS extension (js/ts/mjs/mts/cjs/cts).
|
|
413
|
+
export function isNuxtComposablePath(rel: string): boolean {
|
|
414
|
+
return NUXT_COMPOSABLE_EXT.test(rel) && isUnderDirSegment(rel, 'composables');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function buildComposableRegistry(
|
|
418
|
+
parsed: ParsedFile[],
|
|
419
|
+
framework: Framework | undefined,
|
|
420
|
+
): Map<string, string> {
|
|
421
|
+
const registry = new Map<string, string>();
|
|
422
|
+
if (framework !== 'nuxt') return registry;
|
|
423
|
+
for (const file of parsed) {
|
|
424
|
+
if (!isNuxtComposablePath(file.relPath)) continue;
|
|
425
|
+
const names = new Set<string>();
|
|
426
|
+
for (const exp of file.exports) if (exp !== 'default') names.add(exp);
|
|
427
|
+
const slash = file.relPath.lastIndexOf('/');
|
|
428
|
+
const dot = file.relPath.lastIndexOf('.');
|
|
429
|
+
const base = file.relPath.slice(slash + 1, dot);
|
|
430
|
+
if (base.length > 0) names.add(base);
|
|
431
|
+
for (const name of names) if (!registry.has(name)) registry.set(name, file.relPath);
|
|
432
|
+
}
|
|
433
|
+
return registry;
|
|
434
|
+
}
|
|
435
|
+
|
|
368
436
|
function indexFilesByDir(files: string[]): Map<string, string[]> {
|
|
369
437
|
const map = new Map<string, string[]>();
|
|
370
438
|
for (const f of files) {
|
|
@@ -599,10 +667,14 @@ function frameworkFactsFromPath(
|
|
|
599
667
|
) {
|
|
600
668
|
facts.push({ kind: 'sveltekit-route', value: relPath, confidence: 'medium' });
|
|
601
669
|
}
|
|
602
|
-
if (
|
|
670
|
+
if (
|
|
671
|
+
framework === 'nuxt' &&
|
|
672
|
+
relPath.endsWith('.vue') &&
|
|
673
|
+
isUnderDirSegment(relPath, 'components')
|
|
674
|
+
) {
|
|
603
675
|
facts.push({ kind: 'nuxt-auto-component', value: relPath, confidence: 'medium' });
|
|
604
676
|
}
|
|
605
|
-
if (framework === 'nuxt' &&
|
|
677
|
+
if (framework === 'nuxt' && isNuxtComposablePath(relPath)) {
|
|
606
678
|
facts.push({ kind: 'nuxt-auto-composable', value: relPath, confidence: 'medium' });
|
|
607
679
|
}
|
|
608
680
|
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. */
|
package/src/analyzer/hotZones.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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,7 +25,8 @@ import { loadAliases } from './loadAliases';
|
|
|
26
25
|
import { createResolver } from './resolve';
|
|
27
26
|
import { createParserRegistry, isParseFailure } from './parsers';
|
|
28
27
|
import { classifyKind, isInfra } from './classify';
|
|
29
|
-
import {
|
|
28
|
+
import { isNuxtComposablePath } from './buildGraph';
|
|
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';
|
|
@@ -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,7 +184,14 @@ 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
|
}),
|
|
194
|
+
...(isServerActionsModule(p.directives) ? { isServerActions: true } : {}),
|
|
177
195
|
});
|
|
178
196
|
}
|
|
179
197
|
const updatedModuleIds = new Set(updatedModules.map((m) => m.id));
|
package/src/analyzer/index.ts
CHANGED
|
@@ -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({
|
|
@@ -209,6 +210,7 @@ export async function analyze(
|
|
|
209
210
|
const bundle = effectiveBundleStats
|
|
210
211
|
? analyzeBundle({
|
|
211
212
|
modules,
|
|
213
|
+
edges,
|
|
212
214
|
stats: effectiveBundleStats,
|
|
213
215
|
...(Object.keys(bundleThresholds).length > 0 ? { thresholds: bundleThresholds } : {}),
|
|
214
216
|
})
|
|
@@ -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';
|
|
@@ -41,7 +41,7 @@ const PATTERNS: readonly RiskPattern[] = [
|
|
|
41
41
|
kind: 'timer-cleanup',
|
|
42
42
|
acquire: 'setTimeout',
|
|
43
43
|
cleanup: 'clearTimeout',
|
|
44
|
-
testAcquire:
|
|
44
|
+
testAcquire: isCapturedTimer('setTimeout'),
|
|
45
45
|
testCleanup: isNamedCall('clearTimeout'),
|
|
46
46
|
},
|
|
47
47
|
{
|
|
@@ -81,7 +81,9 @@ export async function detectMemoryRisks(
|
|
|
81
81
|
module.id,
|
|
82
82
|
script,
|
|
83
83
|
ts.ScriptTarget.Latest,
|
|
84
|
-
|
|
84
|
+
// Parent links are needed to tell a captured `setTimeout` handle from a
|
|
85
|
+
// discarded fire-and-forget call (see isCapturedResult).
|
|
86
|
+
true,
|
|
85
87
|
scriptKindFor(module.id),
|
|
86
88
|
);
|
|
87
89
|
const context: RiskContext = {
|
|
@@ -255,6 +257,35 @@ function isNamedCall(name: string): (node: ts.Node) => boolean {
|
|
|
255
257
|
return (node) => ts.isCallExpression(node) && callName(node.expression) === name;
|
|
256
258
|
}
|
|
257
259
|
|
|
260
|
+
// A bare `setTimeout(fn, n)` expression statement is fire-and-forget and needs no
|
|
261
|
+
// cleanup. Flag only when the timer id is captured (assigned, stored on a property,
|
|
262
|
+
// returned, or passed on), since a kept handle signals the author intended it to be
|
|
263
|
+
// cancellable and leaving it uncleared is the suspicious case.
|
|
264
|
+
function isCapturedTimer(name: string): (node: ts.Node) => boolean {
|
|
265
|
+
const matchesCall = isNamedCall(name);
|
|
266
|
+
return (node) => matchesCall(node) && isCapturedResult(node);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function isCapturedResult(node: ts.Node): boolean {
|
|
270
|
+
const parent = node.parent;
|
|
271
|
+
if (!parent) return false;
|
|
272
|
+
if (ts.isVariableDeclaration(parent)) return parent.initializer === node;
|
|
273
|
+
if (ts.isPropertyDeclaration(parent)) return parent.initializer === node;
|
|
274
|
+
if (ts.isPropertyAssignment(parent)) return parent.initializer === node;
|
|
275
|
+
if (ts.isBinaryExpression(parent)) {
|
|
276
|
+
return parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.right === node;
|
|
277
|
+
}
|
|
278
|
+
if (ts.isReturnStatement(parent)) return parent.expression === node;
|
|
279
|
+
if (ts.isCallExpression(parent) || ts.isNewExpression(parent)) {
|
|
280
|
+
return parent.arguments?.some((arg) => arg === node) ?? false;
|
|
281
|
+
}
|
|
282
|
+
// Parenthesized / as-expression wrappers keep the handle reachable.
|
|
283
|
+
if (ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent)) {
|
|
284
|
+
return isCapturedResult(parent);
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
258
289
|
function isIdentifierCall(node: ts.CallExpression, name: string): boolean {
|
|
259
290
|
return ts.isIdentifier(node.expression) && node.expression.text === name;
|
|
260
291
|
}
|
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 {
|