@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
|
@@ -43,10 +43,10 @@ export function computeRecommendations(inputs: {
|
|
|
43
43
|
*/
|
|
44
44
|
bundleBloat?: BundleBloat[];
|
|
45
45
|
/**
|
|
46
|
-
* Temporal couplings
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* Temporal couplings (already risk-sorted by the detector). We narrow to
|
|
47
|
+
* pairs that are BOTH hidden (no static edge — visible ones duplicate the
|
|
48
|
+
* dependency graph) AND cross-boundary (different top-level groups), the only
|
|
49
|
+
* intersection worth a recommendation. Same-group co-change is batch-PR noise.
|
|
50
50
|
*/
|
|
51
51
|
temporalCoupling?: TemporalCoupling[];
|
|
52
52
|
}): Recommendation[] {
|
|
@@ -250,14 +250,18 @@ export function computeRecommendations(inputs: {
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
// 8. temporal coupling: only the
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
253
|
+
// 8. temporal coupling: only the couplings that are BOTH hidden (no static
|
|
254
|
+
// edge — the dependency graph already shows the rest) AND cross-boundary
|
|
255
|
+
// (the two modules live in different top-level groups). That intersection is
|
|
256
|
+
// the actionable "missing abstraction / leaky boundary" smell; everything
|
|
257
|
+
// else is same-folder co-change a batch PR produces by the dozen. The raw
|
|
258
|
+
// list is risk-sorted, so the filter keeps the highest-risk pairs first.
|
|
259
|
+
// Cap at 10 to keep the panel readable on big histories.
|
|
258
260
|
if (inputs.temporalCoupling && inputs.temporalCoupling.length > 0) {
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
+
const couplings = inputs.temporalCoupling
|
|
262
|
+
.filter((c) => c.hidden && c.crossBoundary)
|
|
263
|
+
.slice(0, 10);
|
|
264
|
+
for (const c of couplings) {
|
|
261
265
|
out.push({
|
|
262
266
|
id: `temporal:${c.a}\x00${c.b}`,
|
|
263
267
|
kind: 'temporal-coupling',
|
|
@@ -271,8 +275,9 @@ export function computeRecommendations(inputs: {
|
|
|
271
275
|
// Round to 2 decimals so i18n templates render `0.83` not `0.8333…`.
|
|
272
276
|
score: Math.round(c.score * 100) / 100,
|
|
273
277
|
},
|
|
274
|
-
// 0.5 .. 0.
|
|
275
|
-
|
|
278
|
+
// 0.5 .. 0.9 — driven by risk (strength + evidence + hidden + cross-boundary),
|
|
279
|
+
// so a cross-boundary missing-abstraction outranks a same-folder pair.
|
|
280
|
+
weight: 0.5 + Math.min(0.4, c.risk * 0.45),
|
|
276
281
|
});
|
|
277
282
|
}
|
|
278
283
|
}
|
|
@@ -393,12 +398,21 @@ function bundleParams(b: BundleBloat): Recommendation['params'] {
|
|
|
393
398
|
if (b.detail?.sizeBytes !== undefined) p['sizeBytes'] = b.detail.sizeBytes;
|
|
394
399
|
if (b.detail?.chunkCount !== undefined) p['chunkCount'] = b.detail.chunkCount;
|
|
395
400
|
if (b.detail?.sharePercent !== undefined) p['sharePercent'] = b.detail.sharePercent;
|
|
401
|
+
if (b.detail?.moduleCount !== undefined) p['moduleCount'] = b.detail.moduleCount;
|
|
396
402
|
return p;
|
|
397
403
|
}
|
|
398
404
|
|
|
399
405
|
function bundleWeight(b: BundleBloat): number {
|
|
400
406
|
// duplicates feel more actionable than just-large chunks; weight them up.
|
|
401
|
-
|
|
407
|
+
// barrel-leak is a concrete, fixable tree-shaking miss - rank near duplicates.
|
|
408
|
+
const base =
|
|
409
|
+
b.kind === 'duplicate'
|
|
410
|
+
? 0.85
|
|
411
|
+
: b.kind === 'barrel-leak'
|
|
412
|
+
? 0.8
|
|
413
|
+
: b.kind === 'heavy-chunk'
|
|
414
|
+
? 0.7
|
|
415
|
+
: 0.6;
|
|
402
416
|
const bump = b.severity === 'high' ? 0.1 : b.severity === 'medium' ? 0.05 : 0;
|
|
403
417
|
return Math.min(0.95, base + bump);
|
|
404
418
|
}
|
package/src/analyzer/resolve.ts
CHANGED
|
@@ -184,9 +184,20 @@ export function parseTsconfigPaths(content: string): PathAlias[] {
|
|
|
184
184
|
const baseUrl = (opts.baseUrl ?? '.').replace(/\/+$/u, '');
|
|
185
185
|
const aliases: PathAlias[] = [];
|
|
186
186
|
for (const [key, list] of Object.entries(opts.paths)) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
187
|
+
if (key.endsWith('/*')) {
|
|
188
|
+
// `@x/*`: prefix-strip + suffix-append. Targets keep an interior `*`
|
|
189
|
+
// (e.g. `packages/*/src`) so the captured suffix is substituted at the
|
|
190
|
+
// star instead of being appended to a directory that does not exist.
|
|
191
|
+
const prefix = trimSuffix(key, '/*');
|
|
192
|
+
const targets = list.map((t) =>
|
|
193
|
+
t.includes('*') ? joinPosixKeepStar(baseUrl, trimSuffix(t, '/*')) : joinPosix(baseUrl, t),
|
|
194
|
+
);
|
|
195
|
+
aliases.push({ prefix, targets });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
// exact tsconfig path (no wildcard): `vue` -> `packages/vue/src`.
|
|
199
|
+
const targets = list.map((t) => joinPosix(baseUrl, t));
|
|
200
|
+
aliases.push({ prefix: key, targets, exact: true });
|
|
190
201
|
}
|
|
191
202
|
aliases.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
192
203
|
return aliases;
|
|
@@ -199,6 +210,25 @@ export interface Resolver {
|
|
|
199
210
|
|
|
200
211
|
export function createResolver(source: FileSource, config: ResolverConfig): Resolver {
|
|
201
212
|
const cache = new Map<string, string | null>();
|
|
213
|
+
// Resolve an alias whose target is itself an alias (alias→alias chain).
|
|
214
|
+
// `visited` guards against cyclic aliases (@x→@y→@x).
|
|
215
|
+
const resolveAliased = async (spec: string, visited: Set<string>): Promise<string | null> => {
|
|
216
|
+
if (visited.has(spec)) return null;
|
|
217
|
+
visited.add(spec);
|
|
218
|
+
const aliased = applyAlias(spec, config.aliases);
|
|
219
|
+
if (aliased.length === 0) return null;
|
|
220
|
+
for (const candidate of aliased) {
|
|
221
|
+
const found = await tryFile(source, candidate);
|
|
222
|
+
if (found) return found;
|
|
223
|
+
}
|
|
224
|
+
for (const candidate of aliased) {
|
|
225
|
+
if (isBare(candidate) && hasAliasCandidate(candidate, config.aliases)) {
|
|
226
|
+
const chained = await resolveAliased(candidate, visited);
|
|
227
|
+
if (chained) return chained;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
};
|
|
202
232
|
return {
|
|
203
233
|
hasLocalCandidate(specifier): boolean {
|
|
204
234
|
return hasAliasCandidate(specifier, config.aliases);
|
|
@@ -207,20 +237,9 @@ export function createResolver(source: FileSource, config: ResolverConfig): Reso
|
|
|
207
237
|
const cacheKey = `${fromRel}\u0001${specifier}`;
|
|
208
238
|
if (cache.has(cacheKey)) return cache.get(cacheKey) ?? null;
|
|
209
239
|
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;
|
|
240
|
+
const found = await resolveAliased(specifier, new Set());
|
|
241
|
+
cache.set(cacheKey, found);
|
|
242
|
+
return found;
|
|
224
243
|
}
|
|
225
244
|
|
|
226
245
|
const fromDir = posixDirname(fromRel);
|
|
@@ -255,7 +274,13 @@ export function applyAlias(spec: string, aliases: PathAlias[]): string[] {
|
|
|
255
274
|
const isWildcard = alias.prefix.length > 0;
|
|
256
275
|
if (isWildcard && (spec === alias.prefix || spec.startsWith(`${alias.prefix}/`))) {
|
|
257
276
|
const suffix = spec.slice(alias.prefix.length).replace(/^\//u, '');
|
|
258
|
-
return alias.targets.map((t) =>
|
|
277
|
+
return alias.targets.map((t) =>
|
|
278
|
+
t.includes('*')
|
|
279
|
+
? normalizePosix(t.replace('*', suffix))
|
|
280
|
+
: suffix
|
|
281
|
+
? joinPosix(t, suffix)
|
|
282
|
+
: t,
|
|
283
|
+
);
|
|
259
284
|
}
|
|
260
285
|
if (alias.prefix === '' && !isWildcard) {
|
|
261
286
|
return alias.targets.map((t) => joinPosix(t, spec));
|
|
@@ -294,6 +319,14 @@ function joinPosix(...parts: string[]): string {
|
|
|
294
319
|
return normalizePosix(filtered.join('/'));
|
|
295
320
|
}
|
|
296
321
|
|
|
322
|
+
// Like joinPosix but preserves an interior `*` segment (tsconfig `paths`
|
|
323
|
+
// substitution target such as `packages/*/src`). normalizePosix would drop
|
|
324
|
+
// nothing for `*`, but the `*` must survive joining with baseUrl untouched.
|
|
325
|
+
function joinPosixKeepStar(base: string, target: string): string {
|
|
326
|
+
if (!base || base === '.') return target;
|
|
327
|
+
return `${base}/${target}`.replace(/\/+/gu, '/');
|
|
328
|
+
}
|
|
329
|
+
|
|
297
330
|
function posixDirname(p: string): string {
|
|
298
331
|
const i = p.lastIndexOf('/');
|
|
299
332
|
return i === -1 ? '' : p.slice(0, i);
|
package/src/analyzer/rsc.ts
CHANGED
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
// conventions (Next app/, Nuxt server/, SvelteKit `+server.ts`/
|
|
7
7
|
// `*.server.*`).
|
|
8
8
|
// 2. `detectRscLeaks` - emits a `ContractViolation` (`kind: 'rsc-leak'`)
|
|
9
|
-
// for every
|
|
10
|
-
//
|
|
9
|
+
// for every client->server boundary crossing: a direct edge, and a
|
|
10
|
+
// transitive one (client -> shared chain -> server), the classic
|
|
11
|
+
// barrel/re-export leak that fails the Next build.
|
|
11
12
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// Runtime classification precedence: directives (`'use client'` / `'use server'`)
|
|
14
|
+
// win, then the `server-only` / `client-only` packages (Next's own poison-package
|
|
15
|
+
// enforcement - an explicit, framework-independent runtime declaration), then
|
|
16
|
+
// framework folder conventions. Passing server-only *data* as props into a client
|
|
17
|
+
// component needs data-flow analysis and is out of scope (would be high-FP).
|
|
17
18
|
|
|
18
19
|
import type { ContractViolation } from './contracts';
|
|
19
20
|
import type { DependencyEdge, ModuleId, ModuleNode, ModuleRuntime, ParsedFile } from './types';
|
|
@@ -23,6 +24,10 @@ export interface ClassifyRuntimeInput {
|
|
|
23
24
|
relPath: string;
|
|
24
25
|
framework: Framework;
|
|
25
26
|
directives?: ParsedFile['directives'];
|
|
27
|
+
/** File imports the `server-only` package (Next's poison package — throws if bundled for the client). */
|
|
28
|
+
importsServerOnly?: boolean;
|
|
29
|
+
/** File imports the `client-only` package. */
|
|
30
|
+
importsClientOnly?: boolean;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export function classifyModuleRuntime(input: ClassifyRuntimeInput): ModuleRuntime {
|
|
@@ -32,9 +37,14 @@ export function classifyModuleRuntime(input: ClassifyRuntimeInput): ModuleRuntim
|
|
|
32
37
|
if (input.directives.includes('use client')) return 'client';
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
// 2. `server-only` / `client-only` packages — an explicit runtime declaration,
|
|
41
|
+
// framework-independent (this is Next RSC's own enforcement mechanism, not a convention).
|
|
42
|
+
if (input.importsServerOnly) return 'server';
|
|
43
|
+
if (input.importsClientOnly) return 'client';
|
|
44
|
+
|
|
35
45
|
const path = input.relPath.replace(/\\/gu, '/');
|
|
36
46
|
|
|
37
|
-
//
|
|
47
|
+
// 3. SvelteKit conventions (work for any framework value because users
|
|
38
48
|
// pin them by file name, not framework detection):
|
|
39
49
|
// - `+server.ts` / `+server.js` -> endpoint, server only
|
|
40
50
|
// - `*.server.ts` (and `+page.server.ts`, `+layout.server.ts`) -> server
|
|
@@ -45,7 +55,7 @@ export function classifyModuleRuntime(input: ClassifyRuntimeInput): ModuleRuntim
|
|
|
45
55
|
if (/\.client\.[mc]?[jt]sx?$/u.test(path)) return 'client';
|
|
46
56
|
if (/(^|\/)\+(?:page|layout)\.svelte$/u.test(path)) return 'client';
|
|
47
57
|
|
|
48
|
-
//
|
|
58
|
+
// 4. Framework-specific folder conventions.
|
|
49
59
|
if (input.framework === 'next') {
|
|
50
60
|
// Next App Router: `app/` files are server components by default.
|
|
51
61
|
// The 'use client' directive (handled above) opts a tree into client.
|
|
@@ -65,6 +75,19 @@ export function classifyModuleRuntime(input: ClassifyRuntimeInput): ModuleRuntim
|
|
|
65
75
|
return 'shared';
|
|
66
76
|
}
|
|
67
77
|
|
|
78
|
+
/**
|
|
79
|
+
* A `'use server'` directive marks a Next.js Server Actions module: it is
|
|
80
|
+
* server runtime, but it is *meant* to be imported from client components
|
|
81
|
+
* (forms/mutations), so a client->this edge is not an `rsc-leak`. Distinct from
|
|
82
|
+
* server-only modules (`server-only` package / server component / endpoint
|
|
83
|
+
* conventions), whose import from a client is a real leak. Directives win over
|
|
84
|
+
* conventions in `classifyModuleRuntime`, so the directive is an authoritative
|
|
85
|
+
* reason here.
|
|
86
|
+
*/
|
|
87
|
+
export function isServerActionsModule(directives?: ParsedFile['directives']): boolean {
|
|
88
|
+
return !!directives && directives.includes('use server');
|
|
89
|
+
}
|
|
90
|
+
|
|
68
91
|
export interface DetectRscLeaksInput {
|
|
69
92
|
modules: ModuleNode[];
|
|
70
93
|
edges: DependencyEdge[];
|
|
@@ -80,14 +103,20 @@ export function detectRscLeaks(input: DetectRscLeaksInput): ContractViolation[]
|
|
|
80
103
|
|
|
81
104
|
const out: ContractViolation[] = [];
|
|
82
105
|
let serial = 0;
|
|
106
|
+
const directLeaks = new Set<string>();
|
|
83
107
|
|
|
84
108
|
for (const e of input.edges) {
|
|
85
109
|
if (e.kind === 'type-only') continue;
|
|
86
110
|
const from = moduleById.get(e.from);
|
|
87
111
|
const to = moduleById.get(e.to);
|
|
88
112
|
if (!from || !to) continue;
|
|
113
|
+
// Server Actions (`'use server'`) are server runtime but designed to be
|
|
114
|
+
// imported from client components — that edge is the intended Next.js
|
|
115
|
+
// pattern, not a leak.
|
|
116
|
+
if (to.isServerActions) continue;
|
|
89
117
|
const leak = classifyLeak(from.runtime ?? 'shared', to.runtime ?? 'shared');
|
|
90
118
|
if (!leak) continue;
|
|
119
|
+
directLeaks.add(`${from.id}->${to.id}`);
|
|
91
120
|
out.push({
|
|
92
121
|
id: `rsc-leak:${serial++}:${e.from}\u0001${e.to}`,
|
|
93
122
|
kind: 'rsc-leak',
|
|
@@ -98,6 +127,58 @@ export function detectRscLeaks(input: DetectRscLeaksInput): ContractViolation[]
|
|
|
98
127
|
edge: { from: from.id, to: to.id, specifier: e.specifier },
|
|
99
128
|
});
|
|
100
129
|
}
|
|
130
|
+
|
|
131
|
+
// Transitive leaks: a client module pulls server-only code through a chain of
|
|
132
|
+
// shared modules (the classic barrel/re-export leak — in Next this is a build
|
|
133
|
+
// error, since the shared chain plus the server code lands in the client bundle).
|
|
134
|
+
// Walk from each client module through shared intermediates only; sink = server.
|
|
135
|
+
// Direct client→server edges are already handled above.
|
|
136
|
+
const adj = new Map<ModuleId, ModuleId[]>();
|
|
137
|
+
for (const e of input.edges) {
|
|
138
|
+
if (e.kind === 'type-only') continue;
|
|
139
|
+
let bucket = adj.get(e.from);
|
|
140
|
+
if (!bucket) {
|
|
141
|
+
bucket = [];
|
|
142
|
+
adj.set(e.from, bucket);
|
|
143
|
+
}
|
|
144
|
+
bucket.push(e.to);
|
|
145
|
+
}
|
|
146
|
+
const rt = (id: ModuleId): ModuleRuntime => moduleById.get(id)?.runtime ?? 'shared';
|
|
147
|
+
const seenTransitive = new Set<string>();
|
|
148
|
+
for (const c of input.modules) {
|
|
149
|
+
if ((c.runtime ?? 'shared') !== 'client') continue;
|
|
150
|
+
const visitedShared = new Set<ModuleId>();
|
|
151
|
+
const queue: ModuleId[] = [];
|
|
152
|
+
for (const to of adj.get(c.id) ?? []) {
|
|
153
|
+
if (rt(to) === 'shared' && !visitedShared.has(to)) {
|
|
154
|
+
visitedShared.add(to);
|
|
155
|
+
queue.push(to);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
while (queue.length > 0) {
|
|
159
|
+
const node = queue.shift()!;
|
|
160
|
+
for (const to of adj.get(node) ?? []) {
|
|
161
|
+
const toRt = rt(to);
|
|
162
|
+
if (toRt === 'server' && !moduleById.get(to)?.isServerActions) {
|
|
163
|
+
const key = `${c.id}->${to}`;
|
|
164
|
+
if (directLeaks.has(key) || seenTransitive.has(key)) continue;
|
|
165
|
+
seenTransitive.add(key);
|
|
166
|
+
out.push({
|
|
167
|
+
id: `rsc-leak:${serial++}:${key}`,
|
|
168
|
+
kind: 'rsc-leak',
|
|
169
|
+
ruleName: 'rsc-leak',
|
|
170
|
+
severity: 'error',
|
|
171
|
+
message: `client module "${c.id}" transitively imports server-only "${to}" through shared module(s)`,
|
|
172
|
+
modules: [c.id, to],
|
|
173
|
+
edge: { from: c.id, to, specifier: to },
|
|
174
|
+
});
|
|
175
|
+
} else if (toRt === 'shared' && !visitedShared.has(to)) {
|
|
176
|
+
visitedShared.add(to);
|
|
177
|
+
queue.push(to);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
101
182
|
return out;
|
|
102
183
|
}
|
|
103
184
|
|
|
@@ -8,7 +8,7 @@ export interface BrowserFsAccessFileSourceOptions {
|
|
|
8
8
|
rootHandle: FileSystemDirectoryHandle;
|
|
9
9
|
rootName?: string;
|
|
10
10
|
onProgress?: (visited: number) => void;
|
|
11
|
-
/**
|
|
11
|
+
/** If true (default) - skip test/storybook/cypress/playwright directories. */
|
|
12
12
|
skipTestLikeDirs?: boolean;
|
|
13
13
|
/** Read `.gitignore` from root and filter the listing. Default: true. */
|
|
14
14
|
respectGitignore?: boolean;
|
|
@@ -10,7 +10,7 @@ export interface NodeFsFileSourceOptions {
|
|
|
10
10
|
rootPath: string;
|
|
11
11
|
respectGitignore?: boolean;
|
|
12
12
|
extraIgnore?: string[];
|
|
13
|
-
/**
|
|
13
|
+
/** If true (default) - skip test/storybook/cypress/playwright directories. */
|
|
14
14
|
skipTestLikeDirs?: boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -8,9 +8,9 @@ export interface TauriFileSourceOptions {
|
|
|
8
8
|
rootPath: string;
|
|
9
9
|
rootName?: string;
|
|
10
10
|
invoke: TauriInvoke;
|
|
11
|
-
/**
|
|
11
|
+
/** Extra ignore globs (gitignore syntax), e.g. ['*.test.ts']. */
|
|
12
12
|
extraIgnoreGlobs?: string[];
|
|
13
|
-
/**
|
|
13
|
+
/** Hard limit on the size of a file to read, in bytes. Default 2 MB. */
|
|
14
14
|
maxFileBytes?: number;
|
|
15
15
|
}
|
|
16
16
|
|
package/src/analyzer/types.ts
CHANGED
|
@@ -65,7 +65,24 @@ export interface ModuleNode {
|
|
|
65
65
|
* Defaults to `'shared'` when nothing pins it down.
|
|
66
66
|
*/
|
|
67
67
|
runtime?: ModuleRuntime;
|
|
68
|
+
/**
|
|
69
|
+
* Module is server because of a `'use server'` directive — a Next.js Server
|
|
70
|
+
* Actions module, designed to be imported and called from client components
|
|
71
|
+
* (Next compiles the exports into RPC references; nothing server-only ships).
|
|
72
|
+
* A client->server-actions edge is the intended pattern and is NOT an
|
|
73
|
+
* `rsc-leak`, unlike client->server-only (`server-only` package / server
|
|
74
|
+
* component / endpoint conventions). Set only when `'use server'` is the
|
|
75
|
+
* classification reason.
|
|
76
|
+
*/
|
|
77
|
+
isServerActions?: boolean;
|
|
68
78
|
isGenerated?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Re-export barrel (e.g. `index.ts` re-exporting every sibling). High fan-out
|
|
81
|
+
* is its purpose, not a risk — so barrels are excluded from hot zones and
|
|
82
|
+
* "split this module" advice. Detected post-graph, where parsed export facts
|
|
83
|
+
* and the fan-out metric are both available.
|
|
84
|
+
*/
|
|
85
|
+
isBarrel?: boolean;
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
export type ModuleRuntime = 'server' | 'client' | 'shared';
|
|
@@ -401,6 +418,11 @@ export interface ParsedFile {
|
|
|
401
418
|
directives?: ('use server' | 'use client')[];
|
|
402
419
|
/** PascalCase component tags from <template>, resolved against the component registry in buildGraph. */
|
|
403
420
|
templateRefs?: string[];
|
|
421
|
+
/**
|
|
422
|
+
* Identifiers called as a function (`foo(...)`, not `obj.foo(...)`).
|
|
423
|
+
* Used to resolve Nuxt auto-import composables in buildGraph.
|
|
424
|
+
*/
|
|
425
|
+
callIdentifiers?: string[];
|
|
404
426
|
}
|
|
405
427
|
|
|
406
428
|
export type SignalSeverity = 'info' | 'low' | 'medium' | 'high' | 'critical';
|
package/src/cache/index.ts
CHANGED
|
@@ -30,10 +30,25 @@ import { incrementalAnalyze } from '../analyzer/incremental';
|
|
|
30
30
|
import { discoverFiles } from '../analyzer/discover';
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Bump whenever the on-disk shape of `ScanResult
|
|
34
|
-
*
|
|
33
|
+
* Bump whenever the on-disk shape of `ScanResult`/manifest changes, OR whenever
|
|
34
|
+
* the analyzer's OUTPUT semantics change (new/changed findings, ranking, or
|
|
35
|
+
* filtering) — otherwise a warm cache keyed only on source+tool version serves
|
|
36
|
+
* stale results that don't reflect the improved analysis. A mismatch causes
|
|
37
|
+
* `loadCache` to treat the entry as missing.
|
|
38
|
+
*
|
|
39
|
+
* v3: barrel modules excluded from hot zones; temporal-coupling findings limited
|
|
40
|
+
* to hidden cross-boundary pairs.
|
|
41
|
+
* v4: tsconfig path wildcards with an interior star (key @x/* mapping to target
|
|
42
|
+
* pkgs/[star]/src) now resolve, so cross-package edges that were previously
|
|
43
|
+
* dropped appear in the graph.
|
|
44
|
+
* v5: timer-cleanup memory risk no longer flags bare fire-and-forget setTimeout
|
|
45
|
+
* calls; only captured (stored/returned/passed) timer handles without
|
|
46
|
+
* clearTimeout are reported, reducing false positives.
|
|
47
|
+
* v7: modules carry `isServerActions`; `rsc-leak` no longer flags client
|
|
48
|
+
* imports of `'use server'` Server Actions modules (the intended Next.js
|
|
49
|
+
* pattern), only client->server-only imports.
|
|
35
50
|
*/
|
|
36
|
-
export const CACHE_FORMAT_VERSION =
|
|
51
|
+
export const CACHE_FORMAT_VERSION = 7;
|
|
37
52
|
|
|
38
53
|
/**
|
|
39
54
|
* Marker used inside `node_modules/.cache/archora/`. Also the directory
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { diffScans } from '../diffScans';
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
ContractViolation,
|
|
5
|
+
Cycle,
|
|
6
|
+
LayerViolation,
|
|
7
|
+
ModuleNode,
|
|
8
|
+
ScanResult,
|
|
9
|
+
} from '../../analyzer/types';
|
|
4
10
|
|
|
5
11
|
function mod(id: string, overrides: Partial<ModuleNode> = {}): ModuleNode {
|
|
6
12
|
return {
|
|
@@ -24,6 +30,33 @@ function cycle(id: string, modules: string[]): Cycle {
|
|
|
24
30
|
};
|
|
25
31
|
}
|
|
26
32
|
|
|
33
|
+
function layerViolation(edgeId: string, overrides: Partial<LayerViolation> = {}): LayerViolation {
|
|
34
|
+
return {
|
|
35
|
+
edgeId,
|
|
36
|
+
from: 'src/a.ts',
|
|
37
|
+
to: 'src/b.ts',
|
|
38
|
+
fromLayer: 'features',
|
|
39
|
+
toLayer: 'app',
|
|
40
|
+
severity: 'error',
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function contractViolation(
|
|
46
|
+
id: string,
|
|
47
|
+
overrides: Partial<ContractViolation> = {},
|
|
48
|
+
): ContractViolation {
|
|
49
|
+
return {
|
|
50
|
+
id,
|
|
51
|
+
kind: 'boundary',
|
|
52
|
+
ruleName: 'no-cross-layer',
|
|
53
|
+
severity: 'error',
|
|
54
|
+
message: 'boundary violation',
|
|
55
|
+
modules: ['src/a.ts'],
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
27
60
|
function scan(overrides: Partial<ScanResult>): ScanResult {
|
|
28
61
|
return {
|
|
29
62
|
project: { id: 'p', name: 'p', rootPath: '/p', detectedFramework: 'unknown' },
|
|
@@ -98,6 +131,36 @@ describe('diffScans', () => {
|
|
|
98
131
|
changedModules: 0,
|
|
99
132
|
newCycles: 0,
|
|
100
133
|
resolvedCycles: 0,
|
|
134
|
+
newLayerViolations: 0,
|
|
135
|
+
newContractViolations: 0,
|
|
101
136
|
});
|
|
102
137
|
});
|
|
138
|
+
|
|
139
|
+
it('diffs layer violations by edgeId', () => {
|
|
140
|
+
const shared = layerViolation('edge:a→b');
|
|
141
|
+
const prev = scan({
|
|
142
|
+
layerViolations: [shared, layerViolation('edge:c→d')],
|
|
143
|
+
});
|
|
144
|
+
const next = scan({
|
|
145
|
+
layerViolations: [shared, layerViolation('edge:e→f')],
|
|
146
|
+
});
|
|
147
|
+
const d = diffScans(prev, next);
|
|
148
|
+
expect(d.newLayerViolations.map((v) => v.edgeId)).toEqual(['edge:e→f']);
|
|
149
|
+
expect(d.resolvedLayerViolations.map((v) => v.edgeId)).toEqual(['edge:c→d']);
|
|
150
|
+
expect(d.summary.newLayerViolations).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('diffs contract violations by id', () => {
|
|
154
|
+
const shared = contractViolation('boundary:auth:0');
|
|
155
|
+
const prev = scan({
|
|
156
|
+
contractViolations: [shared, contractViolation('boundary:payment:0')],
|
|
157
|
+
});
|
|
158
|
+
const next = scan({
|
|
159
|
+
contractViolations: [shared, contractViolation('boundary:cart:0')],
|
|
160
|
+
});
|
|
161
|
+
const d = diffScans(prev, next);
|
|
162
|
+
expect(d.newContractViolations.map((v) => v.id)).toEqual(['boundary:cart:0']);
|
|
163
|
+
expect(d.resolvedContractViolations.map((v) => v.id)).toEqual(['boundary:payment:0']);
|
|
164
|
+
expect(d.summary.newContractViolations).toBe(1);
|
|
165
|
+
});
|
|
103
166
|
});
|
package/src/diff/diffScans.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Cycle, ScanResult } from '../analyzer/types';
|
|
1
|
+
import type { ContractViolation, Cycle, LayerViolation, ScanResult } from '../analyzer/types';
|
|
2
2
|
import type { ChangedModule, ScanDiff } from './types';
|
|
3
3
|
|
|
4
4
|
// module id = project-relative path; cycle id = sorted member set.
|
|
@@ -35,6 +35,30 @@ export function diffScans(prev: ScanResult, next: ScanResult): ScanDiff {
|
|
|
35
35
|
if (!nextCyclesByKey.has(key)) resolvedCycles.push(cycle);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const prevLayerByEdge = new Map(prev.layerViolations.map((v) => [v.edgeId, v]));
|
|
39
|
+
const nextLayerByEdge = new Map(next.layerViolations.map((v) => [v.edgeId, v]));
|
|
40
|
+
|
|
41
|
+
const newLayerViolations: LayerViolation[] = [];
|
|
42
|
+
for (const [edgeId, v] of nextLayerByEdge) {
|
|
43
|
+
if (!prevLayerByEdge.has(edgeId)) newLayerViolations.push(v);
|
|
44
|
+
}
|
|
45
|
+
const resolvedLayerViolations: LayerViolation[] = [];
|
|
46
|
+
for (const [edgeId, v] of prevLayerByEdge) {
|
|
47
|
+
if (!nextLayerByEdge.has(edgeId)) resolvedLayerViolations.push(v);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const prevContractById = new Map(prev.contractViolations.map((v) => [v.id, v]));
|
|
51
|
+
const nextContractById = new Map(next.contractViolations.map((v) => [v.id, v]));
|
|
52
|
+
|
|
53
|
+
const newContractViolations: ContractViolation[] = [];
|
|
54
|
+
for (const [id, v] of nextContractById) {
|
|
55
|
+
if (!prevContractById.has(id)) newContractViolations.push(v);
|
|
56
|
+
}
|
|
57
|
+
const resolvedContractViolations: ContractViolation[] = [];
|
|
58
|
+
for (const [id, v] of prevContractById) {
|
|
59
|
+
if (!nextContractById.has(id)) resolvedContractViolations.push(v);
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
return {
|
|
39
63
|
projectId: next.project.id,
|
|
40
64
|
projectName: next.project.name,
|
|
@@ -45,12 +69,18 @@ export function diffScans(prev: ScanResult, next: ScanResult): ScanDiff {
|
|
|
45
69
|
changedModules,
|
|
46
70
|
newCycles,
|
|
47
71
|
resolvedCycles,
|
|
72
|
+
newLayerViolations,
|
|
73
|
+
resolvedLayerViolations,
|
|
74
|
+
newContractViolations,
|
|
75
|
+
resolvedContractViolations,
|
|
48
76
|
summary: {
|
|
49
77
|
addedModules: addedModules.length,
|
|
50
78
|
removedModules: removedModules.length,
|
|
51
79
|
changedModules: changedModules.length,
|
|
52
80
|
newCycles: newCycles.length,
|
|
53
81
|
resolvedCycles: resolvedCycles.length,
|
|
82
|
+
newLayerViolations: newLayerViolations.length,
|
|
83
|
+
newContractViolations: newContractViolations.length,
|
|
54
84
|
},
|
|
55
85
|
};
|
|
56
86
|
}
|
package/src/diff/types.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ContractViolation,
|
|
3
|
+
Cycle,
|
|
4
|
+
LayerViolation,
|
|
5
|
+
ModuleId,
|
|
6
|
+
ModuleNode,
|
|
7
|
+
} from '../analyzer/types';
|
|
2
8
|
|
|
3
9
|
export interface ScanDiff {
|
|
4
10
|
/** Project id and name from the *current* (newer) scan, for display. */
|
|
@@ -20,6 +26,16 @@ export interface ScanDiff {
|
|
|
20
26
|
/** Cycles that disappeared in `next`. */
|
|
21
27
|
resolvedCycles: Cycle[];
|
|
22
28
|
|
|
29
|
+
/** Layer violations whose `edgeId` is absent from `prev`. */
|
|
30
|
+
newLayerViolations: LayerViolation[];
|
|
31
|
+
/** Layer violations whose `edgeId` was present in `prev` but not in `next`. */
|
|
32
|
+
resolvedLayerViolations: LayerViolation[];
|
|
33
|
+
|
|
34
|
+
/** Contract violations whose `id` is absent from `prev`. */
|
|
35
|
+
newContractViolations: ContractViolation[];
|
|
36
|
+
/** Contract violations whose `id` was present in `prev` but not in `next`. */
|
|
37
|
+
resolvedContractViolations: ContractViolation[];
|
|
38
|
+
|
|
23
39
|
/** Aggregate counts for quick rendering at the top of a diff view. */
|
|
24
40
|
summary: ScanDiffSummary;
|
|
25
41
|
}
|
|
@@ -36,4 +52,6 @@ export interface ScanDiffSummary {
|
|
|
36
52
|
changedModules: number;
|
|
37
53
|
newCycles: number;
|
|
38
54
|
resolvedCycles: number;
|
|
55
|
+
newLayerViolations: number;
|
|
56
|
+
newContractViolations: number;
|
|
39
57
|
}
|
|
@@ -122,4 +122,28 @@ describe('computeTemporalCoupling', () => {
|
|
|
122
122
|
expect(out[0]?.a).toBe('src/c.ts');
|
|
123
123
|
expect(out[1]?.hidden).toBe(false);
|
|
124
124
|
});
|
|
125
|
+
|
|
126
|
+
it('ranks a cross-boundary hidden coupling above an equally-strong same-folder one', () => {
|
|
127
|
+
const modules = ['features/auth/a.ts', 'entities/user/b.ts', 'shared/x.ts', 'shared/y.ts'].map(
|
|
128
|
+
mod,
|
|
129
|
+
);
|
|
130
|
+
const h = history([
|
|
131
|
+
commit('1', ['features/auth/a.ts', 'entities/user/b.ts']),
|
|
132
|
+
commit('2', ['features/auth/a.ts', 'entities/user/b.ts']),
|
|
133
|
+
commit('3', ['features/auth/a.ts', 'entities/user/b.ts']),
|
|
134
|
+
commit('4', ['shared/x.ts', 'shared/y.ts']),
|
|
135
|
+
commit('5', ['shared/x.ts', 'shared/y.ts']),
|
|
136
|
+
commit('6', ['shared/x.ts', 'shared/y.ts']),
|
|
137
|
+
]);
|
|
138
|
+
const out = computeTemporalCoupling({ modules, edges: NO_EDGES, history: h });
|
|
139
|
+
expect(out).toHaveLength(2);
|
|
140
|
+
// Both are hidden, score 1.0, 3 co-occurrences — only the boundary differs.
|
|
141
|
+
const cross = out.find((c) => c.a === 'entities/user/b.ts' || c.b === 'entities/user/b.ts');
|
|
142
|
+
const same = out.find((c) => c.a === 'shared/x.ts' && c.b === 'shared/y.ts');
|
|
143
|
+
expect(cross?.crossBoundary).toBe(true);
|
|
144
|
+
expect(same?.crossBoundary).toBe(false);
|
|
145
|
+
expect(cross!.risk).toBeGreaterThan(same!.risk);
|
|
146
|
+
expect(out[0]).toBe(cross); // cross-boundary ranks first
|
|
147
|
+
expect(cross!.risk).toBeLessThanOrEqual(1);
|
|
148
|
+
});
|
|
125
149
|
});
|