@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.
@@ -41,7 +41,7 @@ const PATTERNS: readonly RiskPattern[] = [
41
41
  kind: 'timer-cleanup',
42
42
  acquire: 'setTimeout',
43
43
  cleanup: 'clearTimeout',
44
- testAcquire: isNamedCall('setTimeout'),
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
- false,
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. Only the subset already filtered by
47
- * the detector lands here we further narrow to "hidden" couplings
48
- * (no static edge between the pair), since visible ones duplicate signal
49
- * the dependency graph already exposes.
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 *hidden* couplings — pairs
254
- // that change together a lot AND have no static edge. The dependency
255
- // graph already shows the rest. Weight scales with score: a 0.9 score
256
- // pair ranks alongside a god-module rec, a 0.5 pair sits below cycle
257
- // breaks. Cap at 8 to keep the panel readable on big histories.
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 hidden = inputs.temporalCoupling.filter((c) => c.hidden).slice(0, 8);
260
- for (const c of hidden) {
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',
@@ -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
- const prefix = trimSuffix(key, '/*');
188
- const targets = list.map((t) => joinPosix(baseUrl, trimSuffix(t, '/*')));
189
- aliases.push({ prefix, targets });
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) => (suffix ? joinPosix(t, suffix) : 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);
@@ -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);
@@ -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';
@@ -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` or the manifest changes.
34
- * A mismatch causes `loadCache` to treat the entry as missing.
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 = 2;
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 { Cycle, ModuleNode, ScanResult } from '../../analyzer/types';
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
  });
@@ -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 { Cycle, ModuleId, ModuleNode } from '../analyzer/types';
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
- commitFanOutCap: 50,
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
+ }