@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.
Files changed (52) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/README.md +2 -2
  4. package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +1 -1
  5. package/src/analyzer/__tests__/analyze.test.ts +41 -0
  6. package/src/analyzer/__tests__/bundle.test.ts +99 -0
  7. package/src/analyzer/__tests__/hotZones.test.ts +128 -0
  8. package/src/analyzer/__tests__/incremental.test.ts +61 -13
  9. package/src/analyzer/__tests__/layerViolationAccuracy.test.ts +77 -0
  10. package/src/analyzer/__tests__/memoryRisk.test.ts +94 -0
  11. package/src/analyzer/__tests__/metrics.test.ts +39 -0
  12. package/src/analyzer/__tests__/nuxtComposableAutoImport.test.ts +109 -0
  13. package/src/analyzer/__tests__/reactParser.test.ts +22 -0
  14. package/src/analyzer/__tests__/recommendations.test.ts +67 -0
  15. package/src/analyzer/__tests__/resolve.test.ts +54 -0
  16. package/src/analyzer/__tests__/rsc.test.ts +133 -3
  17. package/src/analyzer/archDebt.ts +32 -9
  18. package/src/analyzer/buildGraph.ts +75 -3
  19. package/src/analyzer/bundle/analyzeBundle.ts +84 -1
  20. package/src/analyzer/bundle/types.ts +9 -1
  21. package/src/analyzer/hotZones.ts +94 -2
  22. package/src/analyzer/incremental.ts +28 -10
  23. package/src/analyzer/index.ts +3 -1
  24. package/src/analyzer/loadAliases.ts +4 -4
  25. package/src/analyzer/memoryRisk.ts +33 -2
  26. package/src/analyzer/metrics.ts +10 -1
  27. package/src/analyzer/parsers/svelteParser.ts +5 -0
  28. package/src/analyzer/parsers/tsParser.ts +11 -1
  29. package/src/analyzer/recommendations.ts +28 -14
  30. package/src/analyzer/resolve.ts +51 -18
  31. package/src/analyzer/rsc.ts +90 -9
  32. package/src/analyzer/sources/browserFsAccessFileSource.ts +1 -1
  33. package/src/analyzer/sources/nodeFsFileSource.ts +1 -1
  34. package/src/analyzer/sources/tauriFileSource.ts +2 -2
  35. package/src/analyzer/types.ts +22 -0
  36. package/src/cache/index.ts +18 -3
  37. package/src/diff/__tests__/diffScans.test.ts +64 -1
  38. package/src/diff/diffScans.ts +31 -1
  39. package/src/diff/types.ts +19 -1
  40. package/src/git/__tests__/computeTemporalCoupling.test.ts +24 -0
  41. package/src/git/computeTemporalCoupling.ts +35 -4
  42. package/src/git/types.ts +14 -1
  43. package/src/index.ts +5 -0
  44. package/src/report/__tests__/buildDeadCodeReport.test.ts +108 -0
  45. package/src/report/buildDeadCodeReport.ts +110 -0
  46. package/src/report/buildFixPlan.ts +14 -69
  47. package/src/search/__tests__/parseQuery.test.ts +13 -13
  48. package/src/search/__tests__/search.test.ts +19 -19
  49. package/src/search/index.ts +39 -39
  50. package/src/search/parseQuery.ts +13 -13
  51. package/src/views/__tests__/analyzerViews.test.ts +6 -0
  52. 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. 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',
@@ -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.85same band as misplaced-by-layer / cycle-break candidates.
275
- weight: 0.5 + Math.min(0.35, c.score * 0.4),
278
+ // 0.5 .. 0.9driven 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
- const base = b.kind === 'duplicate' ? 0.85 : b.kind === 'heavy-chunk' ? 0.7 : 0.6;
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
  }
@@ -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;
@@ -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 aliased = applyAlias(specifier, config.aliases);
211
- if (aliased.length === 0) {
212
- cache.set(cacheKey, null);
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) => (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
+ );
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);
@@ -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 static edge crossing a hard boundary (server importing
10
- // client, client importing server-only).
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
- // Directives win over conventions. A `'use client'` file in `app/` is
13
- // client. `server-only` / `client-only` packages are not modeled separately
14
- // here - the directive system covers the same cases for first-party code,
15
- // and we don't have a way to type-check third-party code without doing real
16
- // resolution.
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
- // 2. SvelteKit conventions (work for any framework value because users
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
- // 3. Framework-specific folder conventions.
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
- /** Если true (по умолчанию) - не заходим в тест/storybook/cypress/playwright директории. */
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
- /** Если true (по умолчанию) - не заходим в тест/storybook/cypress/playwright директории. */
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
- /** Дополнительные ignore-globs (gitignore-синтаксис), напр. ['*.test.ts']. */
11
+ /** Extra ignore globs (gitignore syntax), e.g. ['*.test.ts']. */
12
12
  extraIgnoreGlobs?: string[];
13
- /** Жёсткий лимит размера читаемого файла, байт. По умолчанию 2 МБ. */
13
+ /** Hard limit on the size of a file to read, in bytes. Default 2 MB. */
14
14
  maxFileBytes?: number;
15
15
  }
16
16
 
@@ -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';
@@ -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
  }
@@ -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
  });