@archora/core 1.3.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/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__/hotZones.test.ts +128 -0
- package/src/analyzer/__tests__/memoryRisk.test.ts +94 -0
- package/src/analyzer/__tests__/recommendations.test.ts +67 -0
- package/src/analyzer/__tests__/resolve.test.ts +27 -0
- package/src/analyzer/__tests__/rsc.test.ts +62 -3
- package/src/analyzer/buildGraph.ts +2 -1
- package/src/analyzer/hotZones.ts +94 -2
- package/src/analyzer/incremental.ts +2 -1
- package/src/analyzer/index.ts +2 -1
- package/src/analyzer/memoryRisk.ts +33 -2
- package/src/analyzer/recommendations.ts +15 -11
- package/src/analyzer/resolve.ts +29 -4
- package/src/analyzer/rsc.ts +18 -1
- package/src/analyzer/types.ts +17 -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/computeTemporalCoupling.ts +5 -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/views/__tests__/analyzerViews.test.ts +6 -0
- package/src/views/analyzerViews.ts +1 -6
|
@@ -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
|
}
|
|
@@ -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',
|
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;
|
|
@@ -263,7 +274,13 @@ export function applyAlias(spec: string, aliases: PathAlias[]): string[] {
|
|
|
263
274
|
const isWildcard = alias.prefix.length > 0;
|
|
264
275
|
if (isWildcard && (spec === alias.prefix || spec.startsWith(`${alias.prefix}/`))) {
|
|
265
276
|
const suffix = spec.slice(alias.prefix.length).replace(/^\//u, '');
|
|
266
|
-
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
|
+
);
|
|
267
284
|
}
|
|
268
285
|
if (alias.prefix === '' && !isWildcard) {
|
|
269
286
|
return alias.targets.map((t) => joinPosix(t, spec));
|
|
@@ -302,6 +319,14 @@ function joinPosix(...parts: string[]): string {
|
|
|
302
319
|
return normalizePosix(filtered.join('/'));
|
|
303
320
|
}
|
|
304
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
|
+
|
|
305
330
|
function posixDirname(p: string): string {
|
|
306
331
|
const i = p.lastIndexOf('/');
|
|
307
332
|
return i === -1 ? '' : p.slice(0, i);
|
package/src/analyzer/rsc.ts
CHANGED
|
@@ -75,6 +75,19 @@ export function classifyModuleRuntime(input: ClassifyRuntimeInput): ModuleRuntim
|
|
|
75
75
|
return 'shared';
|
|
76
76
|
}
|
|
77
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
|
+
|
|
78
91
|
export interface DetectRscLeaksInput {
|
|
79
92
|
modules: ModuleNode[];
|
|
80
93
|
edges: DependencyEdge[];
|
|
@@ -97,6 +110,10 @@ export function detectRscLeaks(input: DetectRscLeaksInput): ContractViolation[]
|
|
|
97
110
|
const from = moduleById.get(e.from);
|
|
98
111
|
const to = moduleById.get(e.to);
|
|
99
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;
|
|
100
117
|
const leak = classifyLeak(from.runtime ?? 'shared', to.runtime ?? 'shared');
|
|
101
118
|
if (!leak) continue;
|
|
102
119
|
directLeaks.add(`${from.id}->${to.id}`);
|
|
@@ -142,7 +159,7 @@ export function detectRscLeaks(input: DetectRscLeaksInput): ContractViolation[]
|
|
|
142
159
|
const node = queue.shift()!;
|
|
143
160
|
for (const to of adj.get(node) ?? []) {
|
|
144
161
|
const toRt = rt(to);
|
|
145
|
-
if (toRt === 'server') {
|
|
162
|
+
if (toRt === 'server' && !moduleById.get(to)?.isServerActions) {
|
|
146
163
|
const key = `${c.id}->${to}`;
|
|
147
164
|
if (directLeaks.has(key) || seenTransitive.has(key)) continue;
|
|
148
165
|
seenTransitive.add(key);
|
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';
|
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
|
}
|
|
@@ -30,7 +30,11 @@ const DEFAULTS: Required<TemporalCouplingThresholds> = {
|
|
|
30
30
|
minCoOccurrences: 3,
|
|
31
31
|
minScore: 0.5,
|
|
32
32
|
maxPairs: 100,
|
|
33
|
-
|
|
33
|
+
// Batch PRs (codegen bumps, "touch every composable" passes) co-change dozens
|
|
34
|
+
// of files at once and flood the raw list with same-group noise. A real
|
|
35
|
+
// architectural coupling shows up in small, focused commits, so cap fan-out
|
|
36
|
+
// tighter than a typical batch PR.
|
|
37
|
+
commitFanOutCap: 30,
|
|
34
38
|
};
|
|
35
39
|
|
|
36
40
|
export function computeTemporalCoupling(input: ComputeTemporalCouplingInput): TemporalCoupling[] {
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,11 @@ export {
|
|
|
31
31
|
type FixPlanRepairGroup,
|
|
32
32
|
type BuildFixPlanOptions,
|
|
33
33
|
} from './report/buildFixPlan';
|
|
34
|
+
export {
|
|
35
|
+
buildDeadCodeReport,
|
|
36
|
+
type DeadCodeReport,
|
|
37
|
+
type DeadCodeCandidate,
|
|
38
|
+
} from './report/buildDeadCodeReport';
|
|
34
39
|
export { diffScans, type ScanDiff, type ChangedModule, type ScanDiffSummary } from './diff/index';
|
|
35
40
|
export { compileGlob } from './analyzer/discover';
|
|
36
41
|
export {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildDeadCodeReport } from '../buildDeadCodeReport';
|
|
3
|
+
import type { ScanResult } from '../../analyzer/types';
|
|
4
|
+
|
|
5
|
+
describe('buildDeadCodeReport', () => {
|
|
6
|
+
it('returns candidates with correct groups and totalLoc, excluding non-qualifying modules', () => {
|
|
7
|
+
const scan = scanFixture();
|
|
8
|
+
const report = buildDeadCodeReport(scan);
|
|
9
|
+
|
|
10
|
+
// entry-kind, live module (fanIn>0), and test file must be excluded
|
|
11
|
+
const ids = report.candidates.map((c) => c.id);
|
|
12
|
+
expect(ids).not.toContain('src/app/main.ts');
|
|
13
|
+
expect(ids).not.toContain('src/features/active/model.ts');
|
|
14
|
+
expect(ids).not.toContain('src/utils/parse.test.ts');
|
|
15
|
+
|
|
16
|
+
// isolated source module and script-entry must be included
|
|
17
|
+
expect(ids).toContain('src/legacy/orphan.ts');
|
|
18
|
+
expect(ids).toContain('scripts/seed.mjs');
|
|
19
|
+
|
|
20
|
+
// groups
|
|
21
|
+
const orphan = report.candidates.find((c) => c.id === 'src/legacy/orphan.ts');
|
|
22
|
+
expect(orphan?.group).toBe('isolated');
|
|
23
|
+
expect(orphan?.loc).toBe(40);
|
|
24
|
+
|
|
25
|
+
const script = report.candidates.find((c) => c.id === 'scripts/seed.mjs');
|
|
26
|
+
expect(script?.group).toBe('script-entry');
|
|
27
|
+
|
|
28
|
+
// counts
|
|
29
|
+
expect(report.isolatedCount).toBe(1);
|
|
30
|
+
expect(report.scriptEntryCount).toBe(1);
|
|
31
|
+
expect(report.candidates).toHaveLength(2);
|
|
32
|
+
|
|
33
|
+
// totalLoc = sum of candidate locs
|
|
34
|
+
expect(report.totalLoc).toBe(40 + 8);
|
|
35
|
+
|
|
36
|
+
// sorted by loc desc
|
|
37
|
+
expect(report.candidates.map((c) => c.id)).toEqual([
|
|
38
|
+
'src/legacy/orphan.ts',
|
|
39
|
+
'scripts/seed.mjs',
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns an empty report when no dead modules exist', () => {
|
|
44
|
+
const report = buildDeadCodeReport({
|
|
45
|
+
...scanFixture(),
|
|
46
|
+
modules: [mod('src/app/main.ts', 'entry', 20)],
|
|
47
|
+
metrics: {},
|
|
48
|
+
});
|
|
49
|
+
expect(report.candidates).toHaveLength(0);
|
|
50
|
+
expect(report.totalLoc).toBe(0);
|
|
51
|
+
expect(report.isolatedCount).toBe(0);
|
|
52
|
+
expect(report.scriptEntryCount).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function scanFixture(): ScanResult {
|
|
57
|
+
return {
|
|
58
|
+
project: { id: 'test', name: 'test', rootPath: '/repo', detectedFramework: 'generic' },
|
|
59
|
+
modules: [
|
|
60
|
+
mod('src/app/main.ts', 'entry', 15),
|
|
61
|
+
mod('src/features/active/model.ts', 'model', 30),
|
|
62
|
+
mod('src/legacy/orphan.ts', 'module', 40),
|
|
63
|
+
mod('scripts/seed.mjs', 'module', 8),
|
|
64
|
+
mod('src/utils/parse.test.ts', 'test', 20),
|
|
65
|
+
],
|
|
66
|
+
edges: [],
|
|
67
|
+
cycles: [],
|
|
68
|
+
metrics: {
|
|
69
|
+
'src/app/main.ts': metric(1, 2),
|
|
70
|
+
'src/features/active/model.ts': metric(3, 1),
|
|
71
|
+
'src/legacy/orphan.ts': metric(0, 0),
|
|
72
|
+
'scripts/seed.mjs': metric(0, 0),
|
|
73
|
+
'src/utils/parse.test.ts': metric(0, 0),
|
|
74
|
+
},
|
|
75
|
+
hotZones: [],
|
|
76
|
+
layerViolations: [],
|
|
77
|
+
archDebt: {
|
|
78
|
+
score: 0,
|
|
79
|
+
grade: 'A',
|
|
80
|
+
breakdown: { cycles: 0, layerViolations: 0, hotZones: 0, coupling: 0 },
|
|
81
|
+
},
|
|
82
|
+
recommendations: [],
|
|
83
|
+
contractViolations: [],
|
|
84
|
+
scannedAt: '2026-06-21T00:00:00.000Z',
|
|
85
|
+
durationMs: 1,
|
|
86
|
+
warnings: [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function mod(
|
|
91
|
+
id: string,
|
|
92
|
+
kind: ScanResult['modules'][number]['kind'],
|
|
93
|
+
loc: number,
|
|
94
|
+
): ScanResult['modules'][number] {
|
|
95
|
+
return { id, absPath: `/repo/${id}`, kind, language: 'ts', loc, exports: [], isInfra: false };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function metric(fanIn: number, fanOut: number): ScanResult['metrics'][string] {
|
|
99
|
+
return {
|
|
100
|
+
fanIn,
|
|
101
|
+
fanOut,
|
|
102
|
+
instability: 0,
|
|
103
|
+
depth: 0,
|
|
104
|
+
inCycle: false,
|
|
105
|
+
couplingScore: 0,
|
|
106
|
+
hotnessScore: 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { ModuleId, ModuleNode, ScanResult } from '../analyzer/types';
|
|
2
|
+
|
|
3
|
+
export interface DeadCodeCandidate {
|
|
4
|
+
id: ModuleId;
|
|
5
|
+
loc: number;
|
|
6
|
+
kind: ModuleNode['kind'];
|
|
7
|
+
group: 'isolated' | 'script-entry';
|
|
8
|
+
reason: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DeadCodeReport {
|
|
12
|
+
candidates: DeadCodeCandidate[];
|
|
13
|
+
/** Sum of candidate loc — the reclaimable LOC estimate. */
|
|
14
|
+
totalLoc: number;
|
|
15
|
+
isolatedCount: number;
|
|
16
|
+
scriptEntryCount: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEAD_MODULE_REASON =
|
|
20
|
+
'No resolved imports connect this module to the analyzed dependency model.';
|
|
21
|
+
|
|
22
|
+
export function buildDeadCodeReport(scan: ScanResult): DeadCodeReport {
|
|
23
|
+
const candidates: DeadCodeCandidate[] = [];
|
|
24
|
+
|
|
25
|
+
for (const module of scan.modules) {
|
|
26
|
+
if (!isReviewModule(module.id)) continue;
|
|
27
|
+
const metrics = scan.metrics[module.id];
|
|
28
|
+
const fanIn = metrics?.fanIn ?? 0;
|
|
29
|
+
const fanOut = metrics?.fanOut ?? 0;
|
|
30
|
+
if (
|
|
31
|
+
fanIn === 0 &&
|
|
32
|
+
fanOut === 0 &&
|
|
33
|
+
module.exports.length === 0 &&
|
|
34
|
+
module.kind !== 'entry' &&
|
|
35
|
+
module.kind !== 'test' &&
|
|
36
|
+
!module.isGenerated &&
|
|
37
|
+
!module.isInfra
|
|
38
|
+
) {
|
|
39
|
+
candidates.push({
|
|
40
|
+
id: module.id,
|
|
41
|
+
loc: module.loc,
|
|
42
|
+
kind: module.kind,
|
|
43
|
+
group: isScriptEntryCandidate(module.id) ? 'script-entry' : 'isolated',
|
|
44
|
+
reason: DEAD_MODULE_REASON,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
candidates.sort((a, b) => b.loc - a.loc || a.id.localeCompare(b.id));
|
|
50
|
+
|
|
51
|
+
const totalLoc = candidates.reduce((sum, c) => sum + c.loc, 0);
|
|
52
|
+
const isolatedCount = candidates.filter((c) => c.group === 'isolated').length;
|
|
53
|
+
const scriptEntryCount = candidates.filter((c) => c.group === 'script-entry').length;
|
|
54
|
+
|
|
55
|
+
return { candidates, totalLoc, isolatedCount, scriptEntryCount };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Repair action and verify text for an unreachable module finding.
|
|
60
|
+
* Shared with buildFixPlan so the two never diverge.
|
|
61
|
+
*/
|
|
62
|
+
export function unreachableRepair(id: ModuleId): {
|
|
63
|
+
action: string;
|
|
64
|
+
verify: string;
|
|
65
|
+
params: Record<string, unknown>;
|
|
66
|
+
} {
|
|
67
|
+
if (isScriptEntryCandidate(id)) {
|
|
68
|
+
return {
|
|
69
|
+
action: `Treat ${id} as a script entry: add it to architecture entry configuration or exclude it from review scope; delete only after confirming no package script or CI job calls it.`,
|
|
70
|
+
verify:
|
|
71
|
+
'Run archora report . --format fix-plan after entry/exclude config and confirm this script is no longer a priority finding.',
|
|
72
|
+
params: { entryCandidate: 'script' },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
action:
|
|
78
|
+
'Check whether this file is dead code, dynamically loaded outside analyzer reach, or should be declared as an entry point.',
|
|
79
|
+
verify:
|
|
80
|
+
'Re-scan after deletion, ignore, or entry-point configuration and confirm the candidate is gone.',
|
|
81
|
+
params: {},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Matches modules that should go through architecture review.
|
|
87
|
+
* Excludes fixture directories and test files — same predicate as buildFixPlan.
|
|
88
|
+
*/
|
|
89
|
+
export function isReviewModule(id: ModuleId): boolean {
|
|
90
|
+
return !/(^|\/)(fixtures|test\/fixtures|__fixtures__|__tests__|__mocks__)(\/|$)|\.(test|spec)\./u.test(
|
|
91
|
+
id,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Heuristic: a file directly under a `scripts/` directory with a JS extension
|
|
97
|
+
* is likely a standalone Node script, not a dead module.
|
|
98
|
+
*/
|
|
99
|
+
export function isScriptEntryCandidate(id: ModuleId): boolean {
|
|
100
|
+
const normalized = id.replace(/\\/gu, '/');
|
|
101
|
+
let scriptsIndex = 0;
|
|
102
|
+
if (!normalized.startsWith('scripts/')) {
|
|
103
|
+
const nestedIndex = normalized.indexOf('/scripts/');
|
|
104
|
+
if (nestedIndex < 0) return false;
|
|
105
|
+
scriptsIndex = nestedIndex + 1;
|
|
106
|
+
}
|
|
107
|
+
const file = normalized.slice(scriptsIndex + 'scripts/'.length);
|
|
108
|
+
if (!file) return false;
|
|
109
|
+
return file.endsWith('.js') || file.endsWith('.cjs') || file.endsWith('.mjs');
|
|
110
|
+
}
|