@archora/core 1.1.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 (112) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +62 -0
  3. package/package.json +36 -0
  4. package/src/README.md +4 -0
  5. package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
  6. package/src/analyzer/__tests__/_paths.ts +8 -0
  7. package/src/analyzer/__tests__/analyze.test.ts +522 -0
  8. package/src/analyzer/__tests__/archDebt.test.ts +111 -0
  9. package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
  10. package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
  11. package/src/analyzer/__tests__/bundle.test.ts +191 -0
  12. package/src/analyzer/__tests__/classify.test.ts +99 -0
  13. package/src/analyzer/__tests__/contracts.test.ts +372 -0
  14. package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
  15. package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
  16. package/src/analyzer/__tests__/cycles.test.ts +74 -0
  17. package/src/analyzer/__tests__/detect.test.ts +62 -0
  18. package/src/analyzer/__tests__/discover.test.ts +68 -0
  19. package/src/analyzer/__tests__/displayId.test.ts +30 -0
  20. package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
  21. package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
  22. package/src/analyzer/__tests__/incremental.test.ts +154 -0
  23. package/src/analyzer/__tests__/layers.test.ts +87 -0
  24. package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
  25. package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
  26. package/src/analyzer/__tests__/metrics.test.ts +59 -0
  27. package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
  28. package/src/analyzer/__tests__/parsers.test.ts +187 -0
  29. package/src/analyzer/__tests__/reactParser.test.ts +93 -0
  30. package/src/analyzer/__tests__/recommendations.test.ts +171 -0
  31. package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
  32. package/src/analyzer/__tests__/resolve.test.ts +294 -0
  33. package/src/analyzer/__tests__/rsc.test.ts +130 -0
  34. package/src/analyzer/__tests__/signals.test.ts +316 -0
  35. package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
  36. package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
  37. package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
  38. package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
  39. package/src/analyzer/archDebt.ts +68 -0
  40. package/src/analyzer/asyncLifecycleRisk.ts +234 -0
  41. package/src/analyzer/buildGraph.ts +683 -0
  42. package/src/analyzer/bundle/analyzeBundle.ts +147 -0
  43. package/src/analyzer/bundle/index.ts +12 -0
  44. package/src/analyzer/bundle/parseStats.ts +152 -0
  45. package/src/analyzer/bundle/types.ts +85 -0
  46. package/src/analyzer/classify.ts +54 -0
  47. package/src/analyzer/contracts.ts +265 -0
  48. package/src/analyzer/cyclePatterns.ts +138 -0
  49. package/src/analyzer/cycles.ts +98 -0
  50. package/src/analyzer/detect.ts +34 -0
  51. package/src/analyzer/discover.ts +131 -0
  52. package/src/analyzer/displayId.ts +21 -0
  53. package/src/analyzer/entryPoints.ts +136 -0
  54. package/src/analyzer/feedbackArcSet.ts +332 -0
  55. package/src/analyzer/fileSource.ts +8 -0
  56. package/src/analyzer/hotZones.ts +17 -0
  57. package/src/analyzer/incremental.ts +455 -0
  58. package/src/analyzer/index.ts +444 -0
  59. package/src/analyzer/layers.ts +183 -0
  60. package/src/analyzer/loadAliases.ts +288 -0
  61. package/src/analyzer/memoryRisk.ts +345 -0
  62. package/src/analyzer/metrics.ts +156 -0
  63. package/src/analyzer/parsers/index.ts +62 -0
  64. package/src/analyzer/parsers/reactParser.ts +24 -0
  65. package/src/analyzer/parsers/svelteParser.ts +46 -0
  66. package/src/analyzer/parsers/tsParser.ts +364 -0
  67. package/src/analyzer/parsers/vueParser.ts +109 -0
  68. package/src/analyzer/recommendations.ts +432 -0
  69. package/src/analyzer/resolve.ts +315 -0
  70. package/src/analyzer/rsc.ts +120 -0
  71. package/src/analyzer/signals.ts +684 -0
  72. package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
  73. package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
  74. package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
  75. package/src/analyzer/sources/tauriFileSource.ts +68 -0
  76. package/src/analyzer/suggestContracts.ts +214 -0
  77. package/src/analyzer/typeOnlyCandidates.ts +233 -0
  78. package/src/analyzer/types.ts +537 -0
  79. package/src/cache/__tests__/cache.test.ts +316 -0
  80. package/src/cache/index.ts +432 -0
  81. package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
  82. package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
  83. package/src/codegen/__tests__/configSnippets.test.ts +230 -0
  84. package/src/codegen/applyTypeOnlyFix.ts +344 -0
  85. package/src/codegen/configSnippets.ts +172 -0
  86. package/src/codegen/initConfig.ts +223 -0
  87. package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
  88. package/src/config/frontScopeConfig.ts +830 -0
  89. package/src/diff/__tests__/diffScans.test.ts +103 -0
  90. package/src/diff/diffScans.ts +61 -0
  91. package/src/diff/index.ts +2 -0
  92. package/src/diff/types.ts +39 -0
  93. package/src/git/__tests__/computeChurn.test.ts +113 -0
  94. package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
  95. package/src/git/__tests__/parseGitLog.test.ts +120 -0
  96. package/src/git/computeChurn.ts +111 -0
  97. package/src/git/computeTemporalCoupling.ts +114 -0
  98. package/src/git/index.ts +24 -0
  99. package/src/git/parseGitLog.ts +124 -0
  100. package/src/git/readGitHistory.ts +130 -0
  101. package/src/git/types.ts +119 -0
  102. package/src/index.ts +137 -0
  103. package/src/report/__tests__/buildFixPlan.test.ts +357 -0
  104. package/src/report/__tests__/buildJsonReport.test.ts +34 -0
  105. package/src/report/buildFixPlan.ts +481 -0
  106. package/src/report/buildJsonReport.ts +27 -0
  107. package/src/search/__tests__/parseQuery.test.ts +67 -0
  108. package/src/search/__tests__/search.test.ts +172 -0
  109. package/src/search/index.ts +281 -0
  110. package/src/search/parseQuery.ts +75 -0
  111. package/src/views/__tests__/analyzerViews.test.ts +558 -0
  112. package/src/views/analyzerViews.ts +1294 -0
@@ -0,0 +1,522 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { analyze } from '..';
3
+ import { createNodeFsFileSource } from '../sources/nodeFsFileSource';
4
+ import { createInMemoryFileSource } from '../sources/inMemoryFileSource';
5
+ import { fixturePath } from './_paths';
6
+
7
+ describe('analyze: sample-cycles', () => {
8
+ it('detects two cycles: a<->b and c->d->e->c', async () => {
9
+ const source = await createNodeFsFileSource({ rootPath: fixturePath('sample-cycles') });
10
+ const result = await analyze(source);
11
+
12
+ expect(result.modules.length).toBe(7);
13
+ expect(result.cycles).toHaveLength(2);
14
+
15
+ const lengths = result.cycles.map((c) => c.length).sort();
16
+ expect(lengths).toEqual([2, 3]);
17
+
18
+ const direct = result.cycles.find((c) => c.severity === 'direct');
19
+ expect(direct?.modules.sort()).toEqual(['src/a.ts', 'src/b.ts']);
20
+
21
+ const indirect = result.cycles.find((c) => c.severity === 'indirect');
22
+ expect(indirect?.modules.sort()).toEqual(['src/c.ts', 'src/d.ts', 'src/e.ts']);
23
+ });
24
+
25
+ it('stamps every cycle with a concrete suggestedBreakpoint edge', async () => {
26
+ // Fix-plan contract: every cycle must carry one concrete edge so the
27
+ // inspector, fix-plan JSON and HTML report cite the same break.
28
+ const source = await createNodeFsFileSource({ rootPath: fixturePath('sample-cycles') });
29
+ const result = await analyze(source);
30
+
31
+ for (const cycle of result.cycles) {
32
+ expect(cycle.suggestedBreakpoint, `cycle ${cycle.id}`).toBeDefined();
33
+ const { from, to } = cycle.suggestedBreakpoint!;
34
+ expect(cycle.modules).toContain(from);
35
+ expect(cycle.modules).toContain(to);
36
+ // the analyzer must cite a real edge (not just a module pair).
37
+ const edge = result.edges.find(
38
+ (e) => e.from === from && e.to === to && e.kind !== 'type-only',
39
+ );
40
+ expect(edge, `edge ${from} -> ${to}`).toBeDefined();
41
+ }
42
+ });
43
+ });
44
+
45
+ describe('analyze: sample-vue-app', () => {
46
+ it('produces a coherent ScanResult', async () => {
47
+ const source = await createNodeFsFileSource({ rootPath: fixturePath('sample-vue-app') });
48
+ const result = await analyze(source);
49
+
50
+ expect(result.project.detectedFramework).toBe('vue');
51
+ expect(result.project.tsconfigPath).toBe('tsconfig.json');
52
+
53
+ expect(result.modules.length).toBeGreaterThanOrEqual(20);
54
+
55
+ const ids = new Set(result.modules.map((m) => m.id));
56
+ expect(ids.has('src/main.ts')).toBe(true);
57
+ expect(ids.has('src/App.vue')).toBe(true);
58
+ expect(ids.has('src/stores/userStore.ts')).toBe(true);
59
+
60
+ const userStore = result.modules.find((m) => m.id === 'src/stores/userStore.ts');
61
+ expect(userStore?.kind).toBe('store');
62
+
63
+ const useUsers = result.modules.find((m) => m.id === 'src/composables/useUsers.ts');
64
+ expect(useUsers?.kind).toBe('composable');
65
+
66
+ const main = result.modules.find((m) => m.id === 'src/main.ts');
67
+ expect(main?.kind).toBe('entry');
68
+
69
+ expect(result.modules.find((m) => m.id === 'vite.config.ts')).toBeUndefined();
70
+
71
+ expect(
72
+ result.cycles.some(
73
+ (c) =>
74
+ c.modules.includes('src/services/errors.ts') &&
75
+ c.modules.includes('src/services/logger.ts'),
76
+ ),
77
+ ).toBe(true);
78
+ });
79
+
80
+ it('resolves @/ alias edges', async () => {
81
+ const source = await createNodeFsFileSource({ rootPath: fixturePath('sample-vue-app') });
82
+ const result = await analyze(source);
83
+
84
+ const fromMain = result.edges.filter((e) => e.from === 'src/main.ts');
85
+ const targets = fromMain.map((e) => e.to).sort();
86
+ expect(targets).toContain('src/App.vue');
87
+ expect(targets).toContain('src/router/index.ts');
88
+ expect(targets).toContain('src/services/init.ts');
89
+ });
90
+
91
+ it('classifies dynamic imports from router as edges', async () => {
92
+ const source = await createNodeFsFileSource({ rootPath: fixturePath('sample-vue-app') });
93
+ const result = await analyze(source);
94
+ const fromRouter = result.edges.filter((e) => e.from === 'src/router/index.ts');
95
+ expect(fromRouter.every((e) => e.kind === 'dynamic')).toBe(true);
96
+ const targets = fromRouter.map((e) => e.to);
97
+ expect(targets).toContain('src/pages/Home.vue');
98
+ expect(targets).toContain('src/pages/Users.vue');
99
+ });
100
+
101
+ it('ranks hot zones with cycle members near the top', async () => {
102
+ const source = await createNodeFsFileSource({ rootPath: fixturePath('sample-vue-app') });
103
+ const result = await analyze(source);
104
+ expect(result.hotZones.length).toBeGreaterThan(0);
105
+ expect(result.hotZones).toContain('src/services/logger.ts');
106
+ });
107
+ });
108
+
109
+ describe('analyze: synth-mfe-loader (template-literal dynamic imports)', () => {
110
+ it('connects MFE clusters through the dynamic loader prefix', async () => {
111
+ const root = `${fixturePath('reference')}/synth-mfe-loader`;
112
+ const source = await createNodeFsFileSource({ rootPath: root });
113
+ const result = await analyze(source);
114
+
115
+ // every src/mfes/* file should have an inbound edge from the loader
116
+ const fromLoader = result.edges.filter(
117
+ (e) => e.from === 'src/utils/dynamicMfeLoader.ts' && e.kind === 'dynamic',
118
+ );
119
+ const targets = new Set(fromLoader.map((e) => e.to));
120
+ expect(targets.has('src/mfes/users/index.ts')).toBe(true);
121
+ expect(targets.has('src/mfes/users/UsersWidget.ts')).toBe(true);
122
+ expect(targets.has('src/mfes/products/index.ts')).toBe(true);
123
+ expect(targets.has('src/mfes/products/ProductsWidget.ts')).toBe(true);
124
+ });
125
+
126
+ it('does not flag MFE files as an isolated cluster', async () => {
127
+ const root = `${fixturePath('reference')}/synth-mfe-loader`;
128
+ const source = await createNodeFsFileSource({ rootPath: root });
129
+ const result = await analyze(source);
130
+
131
+ const isolated = result.recommendations.filter((r) => r.kind === 'isolated-cluster');
132
+ expect(isolated).toHaveLength(0);
133
+ });
134
+ });
135
+
136
+ describe('analyze: sample-extends-tsconfig', () => {
137
+ it('follows tsconfig extends to load paths', async () => {
138
+ const source = await createNodeFsFileSource({
139
+ rootPath: fixturePath('sample-extends-tsconfig'),
140
+ });
141
+ const result = await analyze(source);
142
+ const fromMain = result.edges.filter((e) => e.from === 'src/main.ts');
143
+ expect(fromMain.map((e) => e.to)).toContain('src/util/hello.ts');
144
+ });
145
+ });
146
+
147
+ describe('analyze: parser/resolver hardening', () => {
148
+ it('detects jsconfig.json and resolves its paths without tsconfig.json', async () => {
149
+ const source = createInMemoryFileSource('/p', {
150
+ 'jsconfig.json': JSON.stringify({
151
+ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'] } },
152
+ }),
153
+ 'package.json': JSON.stringify({ dependencies: { vue: '^3.0.0' } }),
154
+ 'src/main.js': "import { util } from '@/lib/util';",
155
+ 'src/lib/util.js': 'export const util = 1;',
156
+ });
157
+ const result = await analyze(source);
158
+ expect(result.project.tsconfigPath).toBe('jsconfig.json');
159
+ expect(result.warnings.some((w) => w.code === 'tsconfig-missing')).toBe(false);
160
+ expect(result.edges.map((e) => e.to)).toContain('src/lib/util.js');
161
+ });
162
+
163
+ it('applies negative import.meta.glob patterns as exclusions', async () => {
164
+ const source = createInMemoryFileSource('/p', {
165
+ 'tsconfig.json': '{}',
166
+ 'src/main.ts': "const routes = import.meta.glob(['./routes/*.ts', '!./routes/*.spec.ts']);",
167
+ 'src/routes/home.ts': 'export const home = 1;',
168
+ 'src/routes/home.spec.ts': 'export const test = 1;',
169
+ });
170
+ const result = await analyze(source);
171
+ const fromMain = result.edges.filter((e) => e.from === 'src/main.ts').map((e) => e.to);
172
+ expect(fromMain).toContain('src/routes/home.ts');
173
+ expect(fromMain).not.toContain('src/routes/home.spec.ts');
174
+ expect(result.warnings.some((w) => w.message.includes('!./routes/*.spec.ts'))).toBe(false);
175
+ const fact = result.parserFacts?.find((f) => f.relPath === 'src/main.ts');
176
+ const negative = fact?.imports.find((imp) => imp.specifier === '!./routes/*.spec.ts');
177
+ expect(negative).toMatchObject({
178
+ resolutionKind: 'glob',
179
+ confidence: 'low',
180
+ approximate: true,
181
+ negative: true,
182
+ globEager: false,
183
+ });
184
+ });
185
+
186
+ it('expands import.meta.glob patterns through tsconfig aliases', async () => {
187
+ const source = createInMemoryFileSource('/p', {
188
+ 'tsconfig.json': JSON.stringify({
189
+ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'] } },
190
+ }),
191
+ 'src/main.ts': "const reports = import.meta.glob('@/reports/**/*.ts');",
192
+ 'src/reports/monthly.ts': 'export const report = 1;',
193
+ });
194
+ const result = await analyze(source);
195
+
196
+ expect(result.edges).toEqual(
197
+ expect.arrayContaining([
198
+ expect.objectContaining({
199
+ from: 'src/main.ts',
200
+ to: 'src/reports/monthly.ts',
201
+ resolutionKind: 'glob',
202
+ }),
203
+ ]),
204
+ );
205
+ expect(result.warnings.some((w) => w.message.includes('@/reports/**/*.ts'))).toBe(false);
206
+ });
207
+
208
+ it('keeps asset import.meta.glob patterns out of graph warnings', async () => {
209
+ const source = createInMemoryFileSource('/p', {
210
+ 'tsconfig.json': JSON.stringify({
211
+ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'] } },
212
+ }),
213
+ 'src/main.ts': "const reports = import.meta.glob('@/reports/**/report.html');",
214
+ });
215
+ const result = await analyze(source);
216
+
217
+ expect(result.edges).toHaveLength(0);
218
+ expect(result.warnings.some((w) => w.message.includes('@/reports/**/report.html'))).toBe(false);
219
+ const fact = result.parserFacts?.find((f) => f.relPath === 'src/main.ts');
220
+ expect(fact?.imports[0]).toMatchObject({
221
+ specifier: '@/reports/**/report.html',
222
+ resolutionKind: 'glob',
223
+ });
224
+ });
225
+
226
+ it('records import.meta.glob eager/import metadata as approximate parser facts', async () => {
227
+ const source = createInMemoryFileSource('/p', {
228
+ 'tsconfig.json': '{}',
229
+ 'src/main.ts':
230
+ "const routes = import.meta.glob('./routes/*.ts', { eager: true, import: 'default' });",
231
+ 'src/routes/home.ts': 'export default 1;',
232
+ });
233
+ const result = await analyze(source);
234
+ const edge = result.edges.find((e) => e.from === 'src/main.ts');
235
+ expect(edge).toMatchObject({
236
+ to: 'src/routes/home.ts',
237
+ resolutionKind: 'glob',
238
+ confidence: 'medium',
239
+ approximate: true,
240
+ });
241
+ const fact = result.parserFacts?.find((f) => f.relPath === 'src/main.ts');
242
+ expect(fact?.imports[0]).toMatchObject({
243
+ specifier: './routes/*.ts',
244
+ resolutionKind: 'glob',
245
+ confidence: 'medium',
246
+ approximate: true,
247
+ globEager: true,
248
+ globImport: 'default',
249
+ });
250
+ });
251
+
252
+ it('keeps asset imports as parser facts without graph edges or unresolved warnings', async () => {
253
+ const source = createInMemoryFileSource('/p', {
254
+ 'tsconfig.json': '{}',
255
+ 'src/main.ts':
256
+ "import './style.css'; import data from './data.json'; import logo from './logo.svg';",
257
+ });
258
+ const result = await analyze(source);
259
+ expect(result.edges).toHaveLength(0);
260
+ expect(result.warnings.filter((w) => w.code === 'resolve-failed')).toHaveLength(0);
261
+ const fact = result.parserFacts?.find((f) => f.relPath === 'src/main.ts');
262
+ expect(fact?.assetFacts.map((a) => a.assetKind).sort()).toEqual(['image', 'json', 'style']);
263
+ });
264
+
265
+ it('resolves package exports without allowing private package subpaths', async () => {
266
+ const source = createInMemoryFileSource('/p', {
267
+ 'tsconfig.json': '{}',
268
+ 'packages/ui/package.json': JSON.stringify({
269
+ name: '@acme/ui',
270
+ exports: {
271
+ '.': './src/index.ts',
272
+ './theme': { import: './src/theme.ts', default: './src/theme.ts' },
273
+ },
274
+ }),
275
+ 'packages/ui/src/index.ts': 'export const Button = 1;',
276
+ 'packages/ui/src/theme.ts': 'export const theme = 1;',
277
+ 'packages/ui/src/private.ts': 'export const privateApi = 1;',
278
+ 'src/main.ts': "import '@acme/ui'; import '@acme/ui/theme'; import '@acme/ui/private';",
279
+ });
280
+ const result = await analyze(source);
281
+ const targets = result.edges.filter((e) => e.from === 'src/main.ts').map((e) => e.to);
282
+ expect(targets).toContain('packages/ui/src/index.ts');
283
+ expect(targets).toContain('packages/ui/src/theme.ts');
284
+ expect(targets).not.toContain('packages/ui/src/private.ts');
285
+ expect(result.warnings.some((w) => w.message.includes('@acme/ui/private'))).toBe(true);
286
+ });
287
+
288
+ it('honours analysis.generated `exclude` mode by dropping matched files at discovery', async () => {
289
+ const source = createInMemoryFileSource('/p', {
290
+ 'tsconfig.json': '{}',
291
+ '.archora.json': JSON.stringify({
292
+ analysis: {
293
+ generated: { mode: 'exclude', patterns: ['src/recruit/openapi/**'] },
294
+ },
295
+ }),
296
+ 'src/main.ts': "import { api } from './recruit/openapi/api';\n",
297
+ 'src/recruit/openapi/api.ts': "export const api = '';\n",
298
+ });
299
+ const result = await analyze(source);
300
+ expect(result.modules.map((m) => m.id)).not.toContain('src/recruit/openapi/api.ts');
301
+ });
302
+
303
+ it('honours analysis.generated `classify` mode by tagging matched modules', async () => {
304
+ const source = createInMemoryFileSource('/p', {
305
+ 'tsconfig.json': '{}',
306
+ '.archora.json': JSON.stringify({
307
+ analysis: {
308
+ generated: {
309
+ mode: 'classify',
310
+ patterns: ['src/recruit/openapi/**'],
311
+ presets: ['generated-folder'],
312
+ },
313
+ },
314
+ }),
315
+ 'src/main.ts':
316
+ "import { api } from './recruit/openapi/api';\nimport { x } from './__generated__/x';\n",
317
+ 'src/recruit/openapi/api.ts': "export const api = '';\n",
318
+ 'src/__generated__/x.ts': 'export const x = 1;\n',
319
+ 'src/util.ts': 'export const u = 1;\n',
320
+ });
321
+ const result = await analyze(source);
322
+ const byId = new Map(result.modules.map((m) => [m.id, m] as const));
323
+ expect(byId.get('src/recruit/openapi/api.ts')?.isGenerated).toBe(true);
324
+ expect(byId.get('src/__generated__/x.ts')?.isGenerated).toBe(true);
325
+ expect(byId.get('src/main.ts')?.isGenerated).toBeUndefined();
326
+ expect(byId.get('src/util.ts')?.isGenerated).toBeUndefined();
327
+ });
328
+
329
+ it('applies signal suppressions from .archora.json', async () => {
330
+ const files = {
331
+ 'tsconfig.json': '{}',
332
+ '.archora.json': JSON.stringify({
333
+ contracts: {
334
+ boundaries: [
335
+ {
336
+ name: 'shared-boundary',
337
+ from: 'src/shared/**',
338
+ mode: 'must-not',
339
+ to: 'src/features/**',
340
+ },
341
+ ],
342
+ },
343
+ }),
344
+ 'src/shared/api.ts':
345
+ "import { session } from '../features/auth/session';\nexport const api = session;\n",
346
+ 'src/features/auth/session.ts': "export const session = 'ok';\n",
347
+ };
348
+ const first = await analyze(createInMemoryFileSource('/p', files));
349
+ const stableKey = first.signals?.find(
350
+ (signal) => signal.kind === 'contract-violation',
351
+ )?.stableKey;
352
+ if (!stableKey) throw new Error('contract signal not emitted');
353
+
354
+ const suppressed = await analyze(
355
+ createInMemoryFileSource('/p', {
356
+ ...files,
357
+ '.archora.json': JSON.stringify({
358
+ contracts: {
359
+ boundaries: [
360
+ {
361
+ name: 'shared-boundary',
362
+ from: 'src/shared/**',
363
+ mode: 'must-not',
364
+ to: 'src/features/**',
365
+ },
366
+ ],
367
+ },
368
+ signals: {
369
+ suppressions: [
370
+ {
371
+ stableKey,
372
+ reason: 'Accepted during shared API extraction.',
373
+ createdAt: '2026-05-22T00:00:00.000Z',
374
+ },
375
+ ],
376
+ },
377
+ }),
378
+ }),
379
+ );
380
+
381
+ const signal = suppressed.signals?.find((item) => item.stableKey === stableKey);
382
+ expect(signal).toMatchObject({
383
+ suppressed: true,
384
+ suppressionReason: 'Accepted during shared API extraction.',
385
+ });
386
+ });
387
+
388
+ it('emits safe Nuxt auto-import framework facts for conventional folders', async () => {
389
+ const source = createInMemoryFileSource('/p', {
390
+ 'tsconfig.json': '{}',
391
+ 'package.json': JSON.stringify({ dependencies: { nuxt: '^3.0.0' } }),
392
+ 'components/AppButton.vue': '<template />',
393
+ 'composables/useUser.ts': 'export const useUser = () => null;',
394
+ });
395
+ const result = await analyze(source);
396
+ const kinds = result.parserFacts?.flatMap((fact) => fact.frameworkFacts.map((x) => x.kind));
397
+ expect(kinds).toContain('nuxt-auto-component');
398
+ expect(kinds).toContain('nuxt-auto-composable');
399
+ });
400
+
401
+ it('emits route facts for Next and Nuxt conventional route files', async () => {
402
+ const next = await analyze(
403
+ createInMemoryFileSource('/next', {
404
+ 'tsconfig.json': '{}',
405
+ 'package.json': JSON.stringify({ dependencies: { next: '^14.0.0', react: '^18.0.0' } }),
406
+ 'app/page.tsx': 'export default function Page() { return null; }',
407
+ 'app/layout.tsx': 'export default function Layout() { return null; }',
408
+ 'app/api/users/route.ts': 'export function GET() {}',
409
+ 'pages/index.tsx': 'export default function Home() { return null; }',
410
+ 'pages/api/legacy.ts': 'export default function handler() {}',
411
+ 'middleware.ts': 'export function middleware() {}',
412
+ }),
413
+ );
414
+ expect(routeKind(next, 'app/page.tsx')).toContain('page');
415
+ expect(routeKind(next, 'app/layout.tsx')).toContain('layout');
416
+ expect(routeKind(next, 'app/api/users/route.ts')).toContain('api');
417
+ expect(routeKind(next, 'pages/index.tsx')).toContain('page');
418
+ expect(routeKind(next, 'pages/api/legacy.ts')).toContain('api');
419
+ expect(routeKind(next, 'middleware.ts')).toContain('middleware');
420
+
421
+ const nuxt = await analyze(
422
+ createInMemoryFileSource('/nuxt', {
423
+ 'tsconfig.json': '{}',
424
+ 'package.json': JSON.stringify({ dependencies: { nuxt: '^3.0.0' } }),
425
+ 'pages/index.vue': '<template />',
426
+ 'layouts/default.vue': '<template />',
427
+ 'server/api/users.get.ts': 'export default defineEventHandler(() => null);',
428
+ 'middleware/auth.ts': 'export default defineNuxtRouteMiddleware(() => null);',
429
+ }),
430
+ );
431
+ expect(routeKind(nuxt, 'pages/index.vue')).toContain('page');
432
+ expect(routeKind(nuxt, 'layouts/default.vue')).toContain('layout');
433
+ expect(routeKind(nuxt, 'server/api/users.get.ts')).toContain('api');
434
+ expect(routeKind(nuxt, 'middleware/auth.ts')).toContain('middleware');
435
+ });
436
+
437
+ it('emits route facts for SvelteKit load files and TanStack route modules', async () => {
438
+ const svelte = await analyze(
439
+ createInMemoryFileSource('/svelte', {
440
+ 'tsconfig.json': '{}',
441
+ 'package.json': JSON.stringify({ devDependencies: { svelte: '^4.0.0' } }),
442
+ 'src/routes/+page.svelte': '<script>export let data;</script>',
443
+ 'src/routes/+page.ts': 'export const load = () => ({});',
444
+ 'src/routes/dashboard/+page.server.ts': 'export const load = () => ({});',
445
+ }),
446
+ );
447
+ expect(routeKind(svelte, 'src/routes/+page.svelte')).toContain('page');
448
+ expect(routeKind(svelte, 'src/routes/+page.ts')).toContain('page');
449
+ expect(routeKind(svelte, 'src/routes/dashboard/+page.server.ts')).toContain('server-route');
450
+
451
+ const tanstack = await analyze(
452
+ createInMemoryFileSource('/tanstack', {
453
+ 'tsconfig.json': '{}',
454
+ 'package.json': JSON.stringify({
455
+ dependencies: {
456
+ react: '^18.0.0',
457
+ '@tanstack/react-router': '^1.78.0',
458
+ },
459
+ }),
460
+ 'index.html': '<script type="module" src="/src/main.tsx"></script>',
461
+ 'src/main.tsx': "import { router } from './router'; console.log(router);",
462
+ 'src/router.tsx':
463
+ "import { createRouter, createRootRoute } from '@tanstack/react-router'; export const router = createRouter({ routeTree: createRootRoute() });",
464
+ 'src/routes/__root.tsx': 'export function RootLayout() { return null; }',
465
+ 'src/routes/index.tsx': 'export function HomePage() { return null; }',
466
+ 'src/routes/dashboard.tsx': 'export function DashboardPage() { return null; }',
467
+ }),
468
+ );
469
+ expect(routeKind(tanstack, 'src/routes/__root.tsx')).toContain('layout');
470
+ expect(routeKind(tanstack, 'src/routes/index.tsx')).toContain('page');
471
+ expect(routeKind(tanstack, 'src/routes/dashboard.tsx')).toContain('page');
472
+ });
473
+ });
474
+
475
+ function routeKind(scan: Awaited<ReturnType<typeof analyze>>, relPath: string): string[] {
476
+ return (
477
+ scan.parserFacts
478
+ ?.find((fact) => fact.relPath === relPath)
479
+ ?.routeFacts.map((fact) => fact.routeKind) ?? []
480
+ );
481
+ }
482
+
483
+ describe('analyze: phantom type-only cycles', () => {
484
+ it('does not count a cycle whose feedback edge is a value-syntax type-only import', async () => {
485
+ // b -> a imports `A` with value syntax but uses it only in a type position.
486
+ // The compiler erases that import, so no runtime cycle exists. madge — even
487
+ // with skipTypeImports — keys on syntax and still reports the cycle.
488
+ const source = createInMemoryFileSource('/proj', {
489
+ 'package.json': JSON.stringify({ name: 'p', version: '0.0.0' }),
490
+ 'tsconfig.json': JSON.stringify({ compilerOptions: { strict: true } }),
491
+ 'src/a.ts': [
492
+ `import { runB } from './b';`,
493
+ `export interface A { id: number }`,
494
+ `export const a = runB();`,
495
+ ``,
496
+ ].join('\n'),
497
+ 'src/b.ts': [
498
+ `import { A } from './a';`,
499
+ `export function runB(): number { return 1; }`,
500
+ `export const sizeOf = (x: A): number => x.id;`,
501
+ ``,
502
+ ].join('\n'),
503
+ });
504
+ const result = await analyze(source);
505
+
506
+ expect(result.cycles.filter((c) => c.modules.length > 1)).toHaveLength(0);
507
+ // the actionable hygiene hint stays — the import can be tightened to `type`.
508
+ expect(result.recommendations.some((r) => r.kind === 'type-only-candidate')).toBe(true);
509
+ });
510
+
511
+ it('still reports a genuine value-level cycle', async () => {
512
+ const source = createInMemoryFileSource('/proj', {
513
+ 'package.json': JSON.stringify({ name: 'p', version: '0.0.0' }),
514
+ 'tsconfig.json': '{}',
515
+ 'src/a.ts': `import { b } from './b';\nexport const a = (): number => b();\n`,
516
+ 'src/b.ts': `import { a } from './a';\nexport const b = (): number => a();\n`,
517
+ });
518
+ const result = await analyze(source);
519
+
520
+ expect(result.cycles.some((c) => c.modules.length === 2)).toBe(true);
521
+ });
522
+ });
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { computeArchDebt } from '../archDebt';
3
+ import type { Cycle, LayerViolation, ModuleMetrics, ModuleNode } from '../types';
4
+
5
+ function module(id: string, overrides: Partial<ModuleNode> = {}): ModuleNode {
6
+ return {
7
+ id,
8
+ absPath: `/${id}`,
9
+ kind: 'unknown',
10
+ language: 'ts',
11
+ loc: 1,
12
+ exports: [],
13
+ isInfra: false,
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ function metrics(overrides: Partial<ModuleMetrics> = {}): ModuleMetrics {
19
+ return {
20
+ fanIn: 0,
21
+ fanOut: 0,
22
+ instability: 0,
23
+ depth: 0,
24
+ inCycle: false,
25
+ couplingScore: 0,
26
+ hotnessScore: 0,
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ describe('computeArchDebt', () => {
32
+ it('returns grade A for a clean project', () => {
33
+ const debt = computeArchDebt({
34
+ modules: [module('a.ts'), module('b.ts')],
35
+ cycles: [],
36
+ layerViolations: [],
37
+ metrics: { 'a.ts': metrics(), 'b.ts': metrics() },
38
+ hotZoneCount: 0,
39
+ });
40
+ expect(debt.grade).toBe('A');
41
+ expect(debt.score).toBeLessThan(15);
42
+ });
43
+
44
+ it('penalizes direct cycles harder than indirect ones', () => {
45
+ const direct: Cycle = { id: '1', modules: ['a.ts', 'b.ts'], length: 2, severity: 'direct' };
46
+ const indirect: Cycle = {
47
+ id: '2',
48
+ modules: ['c.ts', 'd.ts', 'e.ts'],
49
+ length: 3,
50
+ severity: 'indirect',
51
+ };
52
+ const modules = ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts'].map((id) => module(id));
53
+ const m: Record<string, ModuleMetrics> = Object.fromEntries(
54
+ modules.map((mod) => [mod.id, metrics()]),
55
+ );
56
+ const withDirect = computeArchDebt({
57
+ modules,
58
+ cycles: [direct],
59
+ layerViolations: [],
60
+ metrics: m,
61
+ hotZoneCount: 0,
62
+ });
63
+ const withIndirect = computeArchDebt({
64
+ modules,
65
+ cycles: [indirect],
66
+ layerViolations: [],
67
+ metrics: m,
68
+ hotZoneCount: 0,
69
+ });
70
+ expect(withDirect.score).toBeGreaterThan(withIndirect.score);
71
+ });
72
+
73
+ it('breakdown sums each subcategory independently', () => {
74
+ const violations: LayerViolation[] = [
75
+ {
76
+ edgeId: 'a',
77
+ from: 'a',
78
+ to: 'b',
79
+ fromLayer: 'entities',
80
+ toLayer: 'widgets',
81
+ severity: 'error',
82
+ },
83
+ ];
84
+ const debt = computeArchDebt({
85
+ modules: [module('a'), module('b')],
86
+ cycles: [],
87
+ layerViolations: violations,
88
+ metrics: { a: metrics(), b: metrics() },
89
+ hotZoneCount: 0,
90
+ });
91
+ expect(debt.breakdown.layerViolations).toBeGreaterThan(0);
92
+ expect(debt.breakdown.cycles).toBe(0);
93
+ });
94
+
95
+ it('scales sub-scores from 0 to 100', () => {
96
+ const debt = computeArchDebt({
97
+ modules: [module('a'), module('b')],
98
+ cycles: Array.from({ length: 50 }).map((_, i) => ({
99
+ id: String(i),
100
+ modules: ['a', 'b'],
101
+ length: 2,
102
+ severity: 'direct' as const,
103
+ })),
104
+ layerViolations: [],
105
+ metrics: { a: metrics(), b: metrics() },
106
+ hotZoneCount: 0,
107
+ });
108
+ expect(debt.breakdown.cycles).toBeGreaterThan(80);
109
+ expect(debt.breakdown.cycles).toBeLessThanOrEqual(100);
110
+ });
111
+ });