@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,316 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ applySignalSuppressions,
4
+ buildArchitectureSignals,
5
+ canSignalFailCi,
6
+ projectSignalsToRecommendations,
7
+ reconcileSignalLifecycle,
8
+ } from '../signals';
9
+ import type { ArchitectureSignal, ParsedFileSummary, Recommendation } from '../types';
10
+
11
+ function rec(overrides: Partial<Recommendation> = {}): Recommendation {
12
+ return {
13
+ id: 'r1',
14
+ kind: 'contract-violation',
15
+ modules: ['src/a.ts'],
16
+ params: { severity: 'error' },
17
+ weight: 10,
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ function parserFact(overrides: Partial<ParsedFileSummary> = {}): ParsedFileSummary {
23
+ return {
24
+ relPath: 'src/a.ts',
25
+ language: 'ts',
26
+ loc: 1,
27
+ imports: [],
28
+ exports: [],
29
+ runtimeFacts: [],
30
+ frameworkFacts: [],
31
+ routeFacts: [],
32
+ stateFacts: [],
33
+ assetFacts: [],
34
+ limitations: [],
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ describe('buildArchitectureSignals', () => {
40
+ it('builds stable keys without legacy recommendation ids', () => {
41
+ const a = buildArchitectureSignals({
42
+ recommendations: [rec({ id: 'generated-a', modules: ['src/b.ts', 'src/a.ts'] })],
43
+ warnings: [],
44
+ }).signals[0];
45
+ const b = buildArchitectureSignals({
46
+ recommendations: [rec({ id: 'generated-b', modules: ['src/a.ts', 'src/b.ts'] })],
47
+ warnings: [],
48
+ }).signals[0];
49
+
50
+ expect(a?.stableKey).toBe(b?.stableKey);
51
+ });
52
+
53
+ it('changes stable keys when evidence modules or facts differ', () => {
54
+ const a = buildArchitectureSignals({
55
+ recommendations: [rec({ modules: ['src/a.ts'] })],
56
+ warnings: [],
57
+ }).signals[0];
58
+ const b = buildArchitectureSignals({
59
+ recommendations: [rec({ modules: ['src/other.ts'] })],
60
+ warnings: [],
61
+ }).signals[0];
62
+
63
+ expect(a?.stableKey).not.toBe(b?.stableKey);
64
+ });
65
+
66
+ it('keeps heuristic-only legacy signals out of top insights', () => {
67
+ const { signals, insights } = buildArchitectureSignals({
68
+ recommendations: [
69
+ rec({
70
+ id: 'h1',
71
+ kind: 'unused-utility',
72
+ modules: ['src/unused.ts'],
73
+ weight: 100,
74
+ }),
75
+ ],
76
+ warnings: [],
77
+ });
78
+
79
+ expect(signals[0]?.confidence).toBe('low');
80
+ expect(signals[0]?.maturity).toBe('experimental');
81
+ expect(insights).toEqual([]);
82
+ });
83
+
84
+ it('demotes graph-derived signals when parser facts are approximate', () => {
85
+ const { signals, insights } = buildArchitectureSignals({
86
+ recommendations: [rec({ kind: 'cycle-break-cluster', params: {}, weight: 10 })],
87
+ warnings: [],
88
+ parserFacts: [
89
+ parserFact({
90
+ imports: [
91
+ {
92
+ specifier: './dynamic/',
93
+ kind: 'dynamic',
94
+ resolutionKind: 'prefix',
95
+ confidence: 'low',
96
+ approximate: true,
97
+ },
98
+ ],
99
+ }),
100
+ ],
101
+ });
102
+
103
+ expect(signals[0]?.confidence).toBe('medium');
104
+ expect(signals[0]?.limitations[0]).toContain('parser uncertainty');
105
+ expect(insights[0]?.confidence).toBe('medium');
106
+ });
107
+
108
+ it('groups related actionable signals into one insight', () => {
109
+ const { insights } = buildArchitectureSignals({
110
+ recommendations: [
111
+ rec({
112
+ id: 'contract',
113
+ kind: 'contract-violation',
114
+ modules: ['src/a.ts'],
115
+ params: { severity: 'error' },
116
+ weight: 10,
117
+ }),
118
+ rec({
119
+ id: 'cycle',
120
+ kind: 'cycle-break-candidate',
121
+ modules: ['src/a.ts', 'src/b.ts'],
122
+ params: {},
123
+ weight: 8,
124
+ }),
125
+ ],
126
+ warnings: [],
127
+ });
128
+
129
+ expect(insights).toHaveLength(1);
130
+ expect(insights[0]?.signals).toHaveLength(2);
131
+ expect(insights[0]?.modules).toEqual(['src/a.ts', 'src/b.ts']);
132
+ expect(insights[0]?.title).toContain('related');
133
+ });
134
+
135
+ it('applies insight noise controls without dropping raw signals', () => {
136
+ const { signals, insights } = buildArchitectureSignals({
137
+ recommendations: [
138
+ rec({ id: 'stable', kind: 'contract-violation', params: { severity: 'error' } }),
139
+ rec({ id: 'medium', kind: 'cycle-break-candidate', params: {}, modules: ['src/b.ts'] }),
140
+ ],
141
+ warnings: [],
142
+ insightLimit: 1,
143
+ minInsightSeverity: 'high',
144
+ minInsightConfidence: 'high',
145
+ });
146
+
147
+ expect(signals).toHaveLength(2);
148
+ expect(insights).toHaveLength(1);
149
+ expect(insights[0]?.severity).toBe('high');
150
+ });
151
+
152
+ it('projects resolve warnings as non-CI parser signals', () => {
153
+ const { signals, insights } = buildArchitectureSignals({
154
+ recommendations: [],
155
+ warnings: [
156
+ {
157
+ code: 'resolve-failed',
158
+ message: 'Could not resolve import',
159
+ file: 'src/a.ts',
160
+ detail: './missing',
161
+ },
162
+ ],
163
+ });
164
+
165
+ expect(signals[0]?.kind).toBe('warning:resolve-failed');
166
+ expect(signals[0] ? canSignalFailCi(signals[0]) : true).toBe(false);
167
+ expect(insights).toEqual([]);
168
+ });
169
+ });
170
+
171
+ describe('canSignalFailCi', () => {
172
+ it('allows stable high-confidence high severity signals to fail CI', () => {
173
+ const { signals } = buildArchitectureSignals({
174
+ recommendations: [rec()],
175
+ warnings: [],
176
+ });
177
+
178
+ expect(signals[0] ? canSignalFailCi(signals[0]) : false).toBe(true);
179
+ });
180
+
181
+ it('does not let beta or experimental signals fail CI by default', () => {
182
+ const { signals } = buildArchitectureSignals({
183
+ recommendations: [
184
+ rec({
185
+ id: 'b1',
186
+ kind: 'bundle-bloat',
187
+ params: { severity: 'high' },
188
+ weight: 10,
189
+ }),
190
+ rec({
191
+ id: 'h1',
192
+ kind: 'unused-utility',
193
+ modules: ['src/unused.ts'],
194
+ weight: 100,
195
+ }),
196
+ ],
197
+ warnings: [],
198
+ });
199
+
200
+ expect(signals.map((signal) => signal.maturity)).toEqual(['beta', 'experimental']);
201
+ expect(signals.some((signal) => canSignalFailCi(signal))).toBe(false);
202
+ });
203
+
204
+ it('does not fail CI for suppressed or resolved signals', () => {
205
+ const signal = buildArchitectureSignals({ recommendations: [rec()], warnings: [] }).signals[0];
206
+ expect(signal).toBeDefined();
207
+ expect(canSignalFailCi({ ...signal!, suppressed: true })).toBe(false);
208
+ expect(canSignalFailCi({ ...signal!, status: 'resolved' })).toBe(false);
209
+ });
210
+ });
211
+
212
+ describe('projectSignalsToRecommendations', () => {
213
+ it('projects signal-only compatible findings for legacy consumers', () => {
214
+ const signal = buildArchitectureSignals({ recommendations: [rec()], warnings: [] }).signals[0]!;
215
+ const signalOnly = { ...signal };
216
+ delete signalOnly.legacyRecommendationId;
217
+ const projected = projectSignalsToRecommendations([{ ...signalOnly, id: 'signal-only' }]);
218
+
219
+ expect(projected[0]).toMatchObject({
220
+ id: `signal:${signal.stableKey}`,
221
+ kind: 'contract-violation',
222
+ modules: ['src/a.ts'],
223
+ params: {
224
+ stableKey: signal.stableKey,
225
+ severity: 'high',
226
+ },
227
+ });
228
+ });
229
+ });
230
+
231
+ describe('reconcileSignalLifecycle', () => {
232
+ it('marks matching signals existing and missing baseline signals resolved', () => {
233
+ const current = buildArchitectureSignals({ recommendations: [rec()], warnings: [] })
234
+ .signals[0]!;
235
+ const baseline: ArchitectureSignal = { ...current, id: 'baseline-signal' };
236
+
237
+ const result = reconcileSignalLifecycle([baseline], [current]);
238
+
239
+ expect(result.current[0]?.status).toBe('existing');
240
+ expect(result.resolved).toEqual([]);
241
+ });
242
+
243
+ it('marks severity or confidence increases as regressed', () => {
244
+ const current = buildArchitectureSignals({ recommendations: [rec()], warnings: [] })
245
+ .signals[0]!;
246
+ const baseline: ArchitectureSignal = { ...current, severity: 'medium', confidence: 'medium' };
247
+
248
+ const result = reconcileSignalLifecycle([baseline], [current]);
249
+
250
+ expect(result.current[0]?.status).toBe('regressed');
251
+ });
252
+
253
+ it('returns resolved signals that disappeared from current scan', () => {
254
+ const baseline = buildArchitectureSignals({ recommendations: [rec()], warnings: [] })
255
+ .signals[0]!;
256
+
257
+ const result = reconcileSignalLifecycle([baseline], []);
258
+
259
+ expect(result.current).toEqual([]);
260
+ expect(result.resolved[0]?.status).toBe('resolved');
261
+ expect(result.resolved[0]?.stableKey).toBe(baseline.stableKey);
262
+ });
263
+ });
264
+
265
+ describe('applySignalSuppressions', () => {
266
+ it('marks active matching suppressions without removing signals', () => {
267
+ const signal = buildArchitectureSignals({ recommendations: [rec()], warnings: [] }).signals[0]!;
268
+
269
+ const result = applySignalSuppressions(
270
+ [signal],
271
+ [
272
+ {
273
+ stableKey: signal.stableKey,
274
+ reason: 'accepted debt',
275
+ createdAt: '2026-01-01T00:00:00.000Z',
276
+ },
277
+ ],
278
+ { now: '2026-02-01T00:00:00.000Z' },
279
+ );
280
+
281
+ expect(result.signals[0]?.suppressed).toBe(true);
282
+ expect(result.signals[0]?.suppressionReason).toBe('accepted debt');
283
+ });
284
+
285
+ it('ignores expired suppressions and reports stale suppressions', () => {
286
+ const signal = buildArchitectureSignals({ recommendations: [rec()], warnings: [] }).signals[0]!;
287
+
288
+ const result = applySignalSuppressions(
289
+ [signal],
290
+ [
291
+ {
292
+ stableKey: signal.stableKey,
293
+ reason: 'expired',
294
+ createdAt: '2026-01-01T00:00:00.000Z',
295
+ expiresAt: '2026-01-15T00:00:00.000Z',
296
+ },
297
+ {
298
+ stableKey: 'missing',
299
+ reason: 'old',
300
+ createdAt: '2026-01-01T00:00:00.000Z',
301
+ },
302
+ ],
303
+ { now: '2026-02-01T00:00:00.000Z' },
304
+ );
305
+
306
+ expect(result.signals[0]?.suppressed).toBeUndefined();
307
+ expect(result.staleSuppressions).toEqual([
308
+ {
309
+ stableKey: 'missing',
310
+ reason: 'old',
311
+ createdAt: '2026-01-01T00:00:00.000Z',
312
+ status: 'stale',
313
+ },
314
+ ]);
315
+ });
316
+ });
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { suggestContracts } from '../suggestContracts';
3
+ import type { Cycle, DependencyEdge, ModuleNode } from '../types';
4
+
5
+ function mod(id: string): ModuleNode {
6
+ return { id, absPath: id, kind: 'unknown', language: 'ts', loc: 10, exports: [], isInfra: false };
7
+ }
8
+ function edge(from: string, to: string): DependencyEdge {
9
+ return { from, to, kind: 'static', specifier: to, resolved: true };
10
+ }
11
+
12
+ describe('suggestContracts', () => {
13
+ it('proposes features-isolation when there are >=2 sibling features', () => {
14
+ const modules = [
15
+ mod('src/features/auth/index.ts'),
16
+ mod('src/features/auth/lib/x.ts'),
17
+ mod('src/features/billing/index.ts'),
18
+ mod('src/features/billing/lib/y.ts'),
19
+ ];
20
+ const result = suggestContracts({ modules, edges: [], cycles: [] });
21
+ const rule = result.contracts.boundaries?.find((r) => r.name === 'features-isolation');
22
+ expect(rule).toBeDefined();
23
+ expect(rule?.crossInstance).toBe(true);
24
+ expect(rule?.from).toBe('src/features/*/**');
25
+ expect(rule?.to).toBe('src/features/*/**');
26
+ });
27
+
28
+ it('does not propose features-isolation when only one feature exists', () => {
29
+ const modules = [mod('src/features/auth/index.ts'), mod('src/features/auth/lib/x.ts')];
30
+ const result = suggestContracts({ modules, edges: [], cycles: [] });
31
+ expect(
32
+ result.contracts.boundaries?.find((r) => r.name === 'features-isolation'),
33
+ ).toBeUndefined();
34
+ });
35
+
36
+ it('proposes layer-discipline rules for observed FSD violations', () => {
37
+ const modules = [mod('src/shared/lib/u.ts'), mod('src/widgets/foo/Foo.vue')];
38
+ const edges = [edge('src/shared/lib/u.ts', 'src/widgets/foo/Foo.vue')];
39
+ const result = suggestContracts({ modules, edges, cycles: [] });
40
+ const rule = result.contracts.boundaries?.find((r) => r.name === 'layer-shared-not-widgets');
41
+ expect(rule).toBeDefined();
42
+ expect(rule?.from).toBe('src/shared/**');
43
+ expect(rule?.to).toBe('src/widgets/**');
44
+ expect(rule?.mode).toBe('must-not');
45
+ });
46
+
47
+ it('proposes no-cycles budgets only for clean folders that exist', () => {
48
+ const modules = [
49
+ mod('src/shared/lib/a.ts'),
50
+ mod('src/shared/lib/b.ts'),
51
+ mod('src/entities/x/y.ts'),
52
+ ];
53
+ const cycles: Cycle[] = [
54
+ {
55
+ id: 'c1',
56
+ modules: ['src/entities/x/y.ts', 'src/entities/x/z.ts'],
57
+ length: 2,
58
+ severity: 'direct',
59
+ },
60
+ ];
61
+ const result = suggestContracts({ modules, edges: [], cycles });
62
+ const sharedBudget = result.contracts.budgets?.find((r) => r.name === 'no-cycles-shared');
63
+ const entitiesBudget = result.contracts.budgets?.find((r) => r.name === 'no-cycles-entities');
64
+ expect(sharedBudget).toBeDefined();
65
+ expect(sharedBudget?.maxCycles).toBe(0);
66
+ // entities has cycles -> skip
67
+ expect(entitiesBudget).toBeUndefined();
68
+ });
69
+
70
+ it('does not duplicate rules already present in existing config', () => {
71
+ const modules = [mod('src/features/auth/index.ts'), mod('src/features/billing/index.ts')];
72
+ const result = suggestContracts({
73
+ modules,
74
+ edges: [],
75
+ cycles: [],
76
+ existing: {
77
+ boundaries: [
78
+ {
79
+ name: 'features-isolation',
80
+ from: 'src/features/*/**',
81
+ to: 'src/features/*/**',
82
+ mode: 'must-not',
83
+ },
84
+ ],
85
+ },
86
+ });
87
+ expect(
88
+ result.contracts.boundaries?.find((r) => r.name === 'features-isolation'),
89
+ ).toBeUndefined();
90
+ });
91
+
92
+ it('skips type-only edges when computing layer pairs', () => {
93
+ const modules = [mod('src/shared/lib/u.ts'), mod('src/widgets/foo/Foo.vue')];
94
+ const edges: DependencyEdge[] = [
95
+ {
96
+ from: 'src/shared/lib/u.ts',
97
+ to: 'src/widgets/foo/Foo.vue',
98
+ kind: 'type-only',
99
+ specifier: 'x',
100
+ resolved: true,
101
+ },
102
+ ];
103
+ const result = suggestContracts({ modules, edges, cycles: [] });
104
+ expect(
105
+ result.contracts.boundaries?.find((r) => r.name === 'layer-shared-not-widgets'),
106
+ ).toBeUndefined();
107
+ });
108
+ });
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createParserRegistry, isParseFailure } from '../parsers';
3
+ import type { ParsedFile } from '../types';
4
+
5
+ const parseSvelte = (relPath: string, content: string): ParsedFile => {
6
+ const registry = createParserRegistry({ framework: 'svelte' });
7
+ const r = registry.parse({ relPath, content });
8
+ if (isParseFailure(r)) throw new Error(`unexpected parse failure: ${r.reason}`);
9
+ return r;
10
+ };
11
+
12
+ describe('svelteParser', () => {
13
+ it('extracts static imports from <script lang="ts">', () => {
14
+ const sfc = `
15
+ <script lang="ts">
16
+ import Header from './Header.svelte';
17
+ import { theme } from './stores/theme';
18
+ export let route: string = 'home';
19
+ </script>
20
+
21
+ <header><Header /></header>
22
+ `;
23
+ const file = parseSvelte('App.svelte', sfc);
24
+ expect(file.language).toBe('svelte');
25
+ const specs = file.imports.filter((i) => i.kind === 'static').map((i) => i.specifier);
26
+ expect(specs).toEqual(expect.arrayContaining(['./Header.svelte', './stores/theme']));
27
+ });
28
+
29
+ it('extracts dynamic import() inside script as dynamic edge', () => {
30
+ const sfc = `
31
+ <script lang="ts">
32
+ const lazyPage = import('./routes/Settings.svelte');
33
+ </script>
34
+
35
+ {#await lazyPage then mod}
36
+ <svelte:component this={mod.default} />
37
+ {/await}
38
+ `;
39
+ const file = parseSvelte('App.svelte', sfc);
40
+ const dynamic = file.imports.filter((i) => i.kind === 'dynamic').map((i) => i.specifier);
41
+ expect(dynamic).toContain('./routes/Settings.svelte');
42
+ });
43
+
44
+ it('handles <script context="module"> in addition to <script>', () => {
45
+ const sfc = `
46
+ <script context="module" lang="ts">
47
+ import { sharedConfig } from './shared';
48
+ export const meta = { name: 'page' };
49
+ </script>
50
+
51
+ <script lang="ts">
52
+ import { onMount } from 'svelte';
53
+ import Footer from './Footer.svelte';
54
+ let mounted = false;
55
+ onMount(() => { mounted = true; });
56
+ </script>
57
+
58
+ <Footer />
59
+ `;
60
+ const file = parseSvelte('Page.svelte', sfc);
61
+ const specs = file.imports.map((i) => i.specifier);
62
+ expect(specs).toEqual(expect.arrayContaining(['./shared', 'svelte', './Footer.svelte']));
63
+ });
64
+
65
+ it('treats template-only .svelte (no <script>) as zero-import file, not a failure', () => {
66
+ const file = parseSvelte(
67
+ 'Icon.svelte',
68
+ `<svg width="16" height="16"><circle cx="8" cy="8" r="7" /></svg>`,
69
+ );
70
+ expect(file.language).toBe('svelte');
71
+ expect(file.imports).toEqual([]);
72
+ });
73
+
74
+ it('script tag is matched case-insensitively and tolerates whitespace', () => {
75
+ const sfc = `<SCRIPT lang="ts" >
76
+ import { foo } from './foo';
77
+ </ Script >`;
78
+ const file = parseSvelte('Weird.svelte', sfc);
79
+ expect(file.imports.map((i) => i.specifier)).toContain('./foo');
80
+ });
81
+
82
+ it('reports loc of the entire SFC, not just the script', () => {
83
+ const sfc = `<script lang="ts">
84
+ import x from './x';
85
+ </script>
86
+
87
+ <div>line</div>
88
+ <div>line</div>
89
+ <div>line</div>
90
+ `;
91
+ const file = parseSvelte('A.svelte', sfc);
92
+ expect(file.loc).toBeGreaterThan(5);
93
+ });
94
+ });
95
+
96
+ describe('parserRegistry: .svelte routing', () => {
97
+ it('routes .svelte to svelteParser regardless of framework', () => {
98
+ for (const framework of ['svelte', 'vue', 'react', 'unknown'] as const) {
99
+ const registry = createParserRegistry({ framework });
100
+ const r = registry.parse({
101
+ relPath: 'A.svelte',
102
+ content: `<script>import x from './x';</script>`,
103
+ });
104
+ expect(isParseFailure(r)).toBe(false);
105
+ expect((r as ParsedFile).language).toBe('svelte');
106
+ }
107
+ });
108
+ });
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { findTypeOnlyCandidates } from '../typeOnlyCandidates';
3
+ import type { FileSource } from '../fileSource';
4
+ import type { ModuleNode } from '../types';
5
+
6
+ function makeSource(files: Record<string, string>): FileSource {
7
+ return {
8
+ rootPath: '/virtual',
9
+ list: async () => Object.keys(files),
10
+ read: async (rel: string) => {
11
+ const c = files[rel];
12
+ if (c === undefined) throw new Error(`missing: ${rel}`);
13
+ return c;
14
+ },
15
+ exists: async (rel: string) => rel in files,
16
+ };
17
+ }
18
+
19
+ const mod = (id: string, language: 'ts' | 'js' | 'vue' | 'svelte' = 'ts'): ModuleNode => ({
20
+ id,
21
+ absPath: id,
22
+ kind: 'unknown',
23
+ language,
24
+ loc: 1,
25
+ exports: [],
26
+ isInfra: false,
27
+ });
28
+
29
+ describe('findTypeOnlyCandidates', () => {
30
+ it('flags import used only in TypeReference', () => {
31
+ const source = makeSource({
32
+ 'a.ts': `
33
+ import { Foo } from './b';
34
+ export function take(x: Foo): void { void x; }
35
+ `,
36
+ });
37
+ const modules = [mod('a.ts'), mod('b.ts')];
38
+ return findTypeOnlyCandidates({
39
+ edges: [{ from: 'a.ts', to: 'b.ts', specifier: './b' }],
40
+ source,
41
+ modules,
42
+ }).then((cands) => {
43
+ expect(cands).toHaveLength(1);
44
+ expect(cands[0]?.bindings).toEqual(['Foo']);
45
+ });
46
+ });
47
+
48
+ it('flags import used in HeritageClause', async () => {
49
+ const source = makeSource({
50
+ 'a.ts': `
51
+ import { Base } from './b';
52
+ export class Derived extends Base {}
53
+ `,
54
+ });
55
+ // class-extends is a value position, not a type position - should NOT flag
56
+ const cands = await findTypeOnlyCandidates({
57
+ edges: [{ from: 'a.ts', to: 'b.ts', specifier: './b' }],
58
+ source,
59
+ modules: [mod('a.ts'), mod('b.ts')],
60
+ });
61
+ expect(cands).toHaveLength(0);
62
+ });
63
+
64
+ it('flags interface heritage', async () => {
65
+ const source = makeSource({
66
+ 'a.ts': `
67
+ import type {} from 'unrelated';
68
+ import { IFoo } from './b';
69
+ export interface Bar extends IFoo {}
70
+ `,
71
+ });
72
+ const cands = await findTypeOnlyCandidates({
73
+ edges: [{ from: 'a.ts', to: 'b.ts', specifier: './b' }],
74
+ source,
75
+ modules: [mod('a.ts'), mod('b.ts')],
76
+ });
77
+ expect(cands).toHaveLength(1);
78
+ expect(cands[0]?.bindings).toEqual(['IFoo']);
79
+ });
80
+
81
+ it('rejects when import is used as a value', async () => {
82
+ const source = makeSource({
83
+ 'a.ts': `
84
+ import { Foo } from './b';
85
+ const x: Foo = new Foo();
86
+ `,
87
+ });
88
+ const cands = await findTypeOnlyCandidates({
89
+ edges: [{ from: 'a.ts', to: 'b.ts', specifier: './b' }],
90
+ source,
91
+ modules: [mod('a.ts'), mod('b.ts')],
92
+ });
93
+ expect(cands).toHaveLength(0);
94
+ });
95
+
96
+ it('rejects when used in typeof position (needs runtime value)', async () => {
97
+ const source = makeSource({
98
+ 'a.ts': `
99
+ import { router } from './b';
100
+ type R = typeof router;
101
+ export function use(): R { return router; }
102
+ `,
103
+ });
104
+ const cands = await findTypeOnlyCandidates({
105
+ edges: [{ from: 'a.ts', to: 'b.ts', specifier: './b' }],
106
+ source,
107
+ modules: [mod('a.ts'), mod('b.ts')],
108
+ });
109
+ expect(cands).toHaveLength(0);
110
+ });
111
+
112
+ it('skips already-type-only imports', async () => {
113
+ const source = makeSource({
114
+ 'a.ts': `
115
+ import type { Foo } from './b';
116
+ export function take(x: Foo): void { void x; }
117
+ `,
118
+ });
119
+ const cands = await findTypeOnlyCandidates({
120
+ edges: [{ from: 'a.ts', to: 'b.ts', specifier: './b' }],
121
+ source,
122
+ modules: [mod('a.ts'), mod('b.ts')],
123
+ });
124
+ expect(cands).toHaveLength(0);
125
+ });
126
+
127
+ it('handles named import with mixed type-only specifier (ignores already-typed names)', async () => {
128
+ const source = makeSource({
129
+ 'a.ts': `
130
+ import { type Foo, doIt } from './b';
131
+ doIt();
132
+ export function take(x: Foo): void { void x; }
133
+ `,
134
+ });
135
+ // doIt is a value - mixed usage, NOT all-types
136
+ const cands = await findTypeOnlyCandidates({
137
+ edges: [{ from: 'a.ts', to: 'b.ts', specifier: './b' }],
138
+ source,
139
+ modules: [mod('a.ts'), mod('b.ts')],
140
+ });
141
+ expect(cands).toHaveLength(0);
142
+ });
143
+
144
+ it('extracts script block from .vue file', async () => {
145
+ const source = makeSource({
146
+ 'A.vue': `
147
+ <script setup lang="ts">
148
+ import type {} from 'noop';
149
+ import { Foo } from './b';
150
+ defineProps<{ x: Foo }>();
151
+ </script>
152
+ <template><div /></template>
153
+ `,
154
+ });
155
+ const cands = await findTypeOnlyCandidates({
156
+ edges: [{ from: 'A.vue', to: 'b.ts', specifier: './b' }],
157
+ source,
158
+ modules: [mod('A.vue', 'vue'), mod('b.ts')],
159
+ });
160
+ expect(cands).toHaveLength(1);
161
+ expect(cands[0]?.bindings).toEqual(['Foo']);
162
+ });
163
+ });