@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.
- package/LICENSE +201 -0
- package/README.md +62 -0
- package/package.json +36 -0
- package/src/README.md +4 -0
- package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
- package/src/analyzer/__tests__/_paths.ts +8 -0
- package/src/analyzer/__tests__/analyze.test.ts +522 -0
- package/src/analyzer/__tests__/archDebt.test.ts +111 -0
- package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
- package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
- package/src/analyzer/__tests__/bundle.test.ts +191 -0
- package/src/analyzer/__tests__/classify.test.ts +99 -0
- package/src/analyzer/__tests__/contracts.test.ts +372 -0
- package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
- package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
- package/src/analyzer/__tests__/cycles.test.ts +74 -0
- package/src/analyzer/__tests__/detect.test.ts +62 -0
- package/src/analyzer/__tests__/discover.test.ts +68 -0
- package/src/analyzer/__tests__/displayId.test.ts +30 -0
- package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
- package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
- package/src/analyzer/__tests__/incremental.test.ts +154 -0
- package/src/analyzer/__tests__/layers.test.ts +87 -0
- package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
- package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
- package/src/analyzer/__tests__/metrics.test.ts +59 -0
- package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
- package/src/analyzer/__tests__/parsers.test.ts +187 -0
- package/src/analyzer/__tests__/reactParser.test.ts +93 -0
- package/src/analyzer/__tests__/recommendations.test.ts +171 -0
- package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
- package/src/analyzer/__tests__/resolve.test.ts +294 -0
- package/src/analyzer/__tests__/rsc.test.ts +130 -0
- package/src/analyzer/__tests__/signals.test.ts +316 -0
- package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
- package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
- package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
- package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
- package/src/analyzer/archDebt.ts +68 -0
- package/src/analyzer/asyncLifecycleRisk.ts +234 -0
- package/src/analyzer/buildGraph.ts +683 -0
- package/src/analyzer/bundle/analyzeBundle.ts +147 -0
- package/src/analyzer/bundle/index.ts +12 -0
- package/src/analyzer/bundle/parseStats.ts +152 -0
- package/src/analyzer/bundle/types.ts +85 -0
- package/src/analyzer/classify.ts +54 -0
- package/src/analyzer/contracts.ts +265 -0
- package/src/analyzer/cyclePatterns.ts +138 -0
- package/src/analyzer/cycles.ts +98 -0
- package/src/analyzer/detect.ts +34 -0
- package/src/analyzer/discover.ts +131 -0
- package/src/analyzer/displayId.ts +21 -0
- package/src/analyzer/entryPoints.ts +136 -0
- package/src/analyzer/feedbackArcSet.ts +332 -0
- package/src/analyzer/fileSource.ts +8 -0
- package/src/analyzer/hotZones.ts +17 -0
- package/src/analyzer/incremental.ts +455 -0
- package/src/analyzer/index.ts +444 -0
- package/src/analyzer/layers.ts +183 -0
- package/src/analyzer/loadAliases.ts +288 -0
- package/src/analyzer/memoryRisk.ts +345 -0
- package/src/analyzer/metrics.ts +156 -0
- package/src/analyzer/parsers/index.ts +62 -0
- package/src/analyzer/parsers/reactParser.ts +24 -0
- package/src/analyzer/parsers/svelteParser.ts +46 -0
- package/src/analyzer/parsers/tsParser.ts +364 -0
- package/src/analyzer/parsers/vueParser.ts +109 -0
- package/src/analyzer/recommendations.ts +432 -0
- package/src/analyzer/resolve.ts +315 -0
- package/src/analyzer/rsc.ts +120 -0
- package/src/analyzer/signals.ts +684 -0
- package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
- package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
- package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
- package/src/analyzer/sources/tauriFileSource.ts +68 -0
- package/src/analyzer/suggestContracts.ts +214 -0
- package/src/analyzer/typeOnlyCandidates.ts +233 -0
- package/src/analyzer/types.ts +537 -0
- package/src/cache/__tests__/cache.test.ts +316 -0
- package/src/cache/index.ts +432 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
- package/src/codegen/__tests__/configSnippets.test.ts +230 -0
- package/src/codegen/applyTypeOnlyFix.ts +344 -0
- package/src/codegen/configSnippets.ts +172 -0
- package/src/codegen/initConfig.ts +223 -0
- package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
- package/src/config/frontScopeConfig.ts +830 -0
- package/src/diff/__tests__/diffScans.test.ts +103 -0
- package/src/diff/diffScans.ts +61 -0
- package/src/diff/index.ts +2 -0
- package/src/diff/types.ts +39 -0
- package/src/git/__tests__/computeChurn.test.ts +113 -0
- package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
- package/src/git/__tests__/parseGitLog.test.ts +120 -0
- package/src/git/computeChurn.ts +111 -0
- package/src/git/computeTemporalCoupling.ts +114 -0
- package/src/git/index.ts +24 -0
- package/src/git/parseGitLog.ts +124 -0
- package/src/git/readGitHistory.ts +130 -0
- package/src/git/types.ts +119 -0
- package/src/index.ts +137 -0
- package/src/report/__tests__/buildFixPlan.test.ts +357 -0
- package/src/report/__tests__/buildJsonReport.test.ts +34 -0
- package/src/report/buildFixPlan.ts +481 -0
- package/src/report/buildJsonReport.ts +27 -0
- package/src/search/__tests__/parseQuery.test.ts +67 -0
- package/src/search/__tests__/search.test.ts +172 -0
- package/src/search/index.ts +281 -0
- package/src/search/parseQuery.ts +75 -0
- package/src/views/__tests__/analyzerViews.test.ts +558 -0
- package/src/views/analyzerViews.ts +1294 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
buildDynamicLoaderSnippet,
|
|
4
|
+
buildGeneratedConfigSnippet,
|
|
5
|
+
buildIgnoreSnippet,
|
|
6
|
+
buildLayerOverrideSnippet,
|
|
7
|
+
buildProjectPolicyPresetSnippet,
|
|
8
|
+
} from '../configSnippets';
|
|
9
|
+
import { buildInitialArchoraConfig, buildInitialArchoraConfigJson } from '../initConfig';
|
|
10
|
+
import type { LayerViolation, ScanResult } from '../../analyzer/types';
|
|
11
|
+
|
|
12
|
+
describe('config snippets', () => {
|
|
13
|
+
it('builds a generated policy snippet that round-trips with loadArchoraConfig', () => {
|
|
14
|
+
const text = buildGeneratedConfigSnippet({
|
|
15
|
+
mode: 'classify',
|
|
16
|
+
patterns: ['src/recruit/openapi/**'],
|
|
17
|
+
presets: ['openapi'],
|
|
18
|
+
});
|
|
19
|
+
const parsed = JSON.parse(text) as { analysis: { generated: Record<string, unknown> } };
|
|
20
|
+
expect(parsed.analysis.generated).toEqual({
|
|
21
|
+
mode: 'classify',
|
|
22
|
+
patterns: ['src/recruit/openapi/**'],
|
|
23
|
+
presets: ['openapi'],
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('drops empty patterns/presets in generated snippet', () => {
|
|
28
|
+
const text = buildGeneratedConfigSnippet({ mode: 'exclude' });
|
|
29
|
+
expect(JSON.parse(text)).toEqual({ analysis: { generated: { mode: 'exclude' } } });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('dedupes and trims ignore patterns', () => {
|
|
33
|
+
const text = buildIgnoreSnippet(['src/legacy/**', ' src/legacy/** ', '', 'dist/**']);
|
|
34
|
+
expect(JSON.parse(text)).toEqual({
|
|
35
|
+
ignore: ['src/legacy/**', 'dist/**'],
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('builds layerOverrides from violations, preferring the importer layer that hits the module most often', () => {
|
|
40
|
+
const violations: LayerViolation[] = [
|
|
41
|
+
violation('src/shared/api.ts', 'features'),
|
|
42
|
+
violation('src/shared/api.ts', 'features'),
|
|
43
|
+
violation('src/shared/api.ts', 'app'),
|
|
44
|
+
violation('src/util.ts', 'app'),
|
|
45
|
+
];
|
|
46
|
+
const text = buildLayerOverrideSnippet(violations);
|
|
47
|
+
expect(JSON.parse(text)).toEqual({
|
|
48
|
+
layerOverrides: {
|
|
49
|
+
'src/shared/api.ts': 'features',
|
|
50
|
+
'src/util.ts': 'app',
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('extracts dynamic loader templates from prefix/glob edges', () => {
|
|
56
|
+
const scan: ScanResult = {
|
|
57
|
+
...minimalScan(),
|
|
58
|
+
edges: [
|
|
59
|
+
{
|
|
60
|
+
from: 'src/main.ts',
|
|
61
|
+
to: 'src/mfes/a.ts',
|
|
62
|
+
kind: 'dynamic',
|
|
63
|
+
specifier: './mfes/',
|
|
64
|
+
resolved: true,
|
|
65
|
+
resolutionKind: 'prefix',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
from: 'src/main.ts',
|
|
69
|
+
to: 'src/mfes/b.ts',
|
|
70
|
+
kind: 'dynamic',
|
|
71
|
+
specifier: './mfes/',
|
|
72
|
+
resolved: true,
|
|
73
|
+
resolutionKind: 'prefix',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
from: 'src/x.ts',
|
|
77
|
+
to: 'src/routes/y.ts',
|
|
78
|
+
kind: 'static',
|
|
79
|
+
specifier: './routes/*.ts',
|
|
80
|
+
resolved: true,
|
|
81
|
+
resolutionKind: 'glob',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
from: 'src/x.ts',
|
|
85
|
+
to: 'src/util.ts',
|
|
86
|
+
kind: 'static',
|
|
87
|
+
specifier: './util',
|
|
88
|
+
resolved: true,
|
|
89
|
+
resolutionKind: 'literal',
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
const parsed = JSON.parse(buildDynamicLoaderSnippet(scan)) as {
|
|
94
|
+
dynamicLoaders: Array<Record<string, unknown>>;
|
|
95
|
+
};
|
|
96
|
+
expect(parsed.dynamicLoaders).toHaveLength(2);
|
|
97
|
+
expect(parsed.dynamicLoaders[0]).toMatchObject({
|
|
98
|
+
name: 'dynamic-loader-1',
|
|
99
|
+
resolveAs: './mfes/{0}/index',
|
|
100
|
+
description: 'Observed pattern: ./mfes/',
|
|
101
|
+
});
|
|
102
|
+
expect(parsed.dynamicLoaders[1]).toMatchObject({
|
|
103
|
+
name: 'dynamic-loader-2',
|
|
104
|
+
resolveAs: './routes/{0}.ts',
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns an empty dynamicLoaders array when the scan has no dynamic edges', () => {
|
|
109
|
+
const text = buildDynamicLoaderSnippet(minimalScan());
|
|
110
|
+
expect(JSON.parse(text)).toEqual({ dynamicLoaders: [] });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('builds common .archora.json policy presets', () => {
|
|
114
|
+
const fsd = JSON.parse(buildProjectPolicyPresetSnippet('fsd')) as {
|
|
115
|
+
contracts: { boundaries: Array<Record<string, unknown>> };
|
|
116
|
+
};
|
|
117
|
+
expect(
|
|
118
|
+
fsd.contracts.boundaries.find((rule) => rule['name'] === 'features-isolation'),
|
|
119
|
+
).toMatchObject({
|
|
120
|
+
name: 'features-isolation',
|
|
121
|
+
from: 'src/features/*/**',
|
|
122
|
+
to: 'src/features/*/**',
|
|
123
|
+
mode: 'must-not',
|
|
124
|
+
crossInstance: true,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const workspace = JSON.parse(buildProjectPolicyPresetSnippet('package-workspace')) as {
|
|
128
|
+
contracts: { boundaries: Array<Record<string, unknown>> };
|
|
129
|
+
};
|
|
130
|
+
expect(
|
|
131
|
+
workspace.contracts.boundaries.find((rule) => rule['name'] === 'packages-through-public-api'),
|
|
132
|
+
).toMatchObject({
|
|
133
|
+
name: 'packages-through-public-api',
|
|
134
|
+
from: 'packages/*/src/**',
|
|
135
|
+
to: 'packages/*/src/**',
|
|
136
|
+
mode: 'must-not',
|
|
137
|
+
crossInstance: true,
|
|
138
|
+
});
|
|
139
|
+
expect(JSON.parse(buildProjectPolicyPresetSnippet('generated-openapi'))).toEqual({
|
|
140
|
+
analysis: {
|
|
141
|
+
generated: {
|
|
142
|
+
mode: 'classify',
|
|
143
|
+
presets: ['openapi', 'generated-folder'],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('builds a conservative init config for a Vite app', () => {
|
|
150
|
+
const result = buildInitialArchoraConfig({
|
|
151
|
+
files: ['vite.config.ts', 'src/main.ts', 'src/App.vue'],
|
|
152
|
+
packageJsonText: JSON.stringify({ devDependencies: { vite: '^5.0.0' } }),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(result.detected).toContain('vite');
|
|
156
|
+
expect(result.config.entryPoints).toEqual(['src/main.ts']);
|
|
157
|
+
expect(result.config.ignore).toContain('dist/**');
|
|
158
|
+
expect(result.config.signals).toMatchObject({
|
|
159
|
+
insightLimit: 6,
|
|
160
|
+
minInsightSeverity: 'medium',
|
|
161
|
+
minInsightConfidence: 'medium',
|
|
162
|
+
});
|
|
163
|
+
expect(result.config.contracts).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('adds workspace contracts only when workspace package entries are present', () => {
|
|
167
|
+
const result = buildInitialArchoraConfig({
|
|
168
|
+
files: ['packages/ui/src/index.ts', 'packages/app/src/index.ts'],
|
|
169
|
+
packageJsonText: JSON.stringify({ workspaces: ['packages/*'] }),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(result.detected).toContain('workspace-packages');
|
|
173
|
+
expect(result.config.entryPoints).toEqual([
|
|
174
|
+
'packages/app/src/index.ts',
|
|
175
|
+
'packages/ui/src/index.ts',
|
|
176
|
+
]);
|
|
177
|
+
expect(result.config.contracts?.boundaries?.[0]).toMatchObject({
|
|
178
|
+
name: 'packages-through-public-api',
|
|
179
|
+
severity: 'warning',
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('adds generated policy when generated API files are present', () => {
|
|
184
|
+
const text = buildInitialArchoraConfigJson({
|
|
185
|
+
files: ['src/main.ts', 'src/openapi/petstore.generated.ts'],
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(JSON.parse(text)).toMatchObject({
|
|
189
|
+
analysis: {
|
|
190
|
+
generated: {
|
|
191
|
+
mode: 'classify',
|
|
192
|
+
presets: ['openapi', 'generated-folder'],
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
function violation(to: string, fromLayer: string): LayerViolation {
|
|
200
|
+
return {
|
|
201
|
+
edgeId: `${fromLayer}->${to}`,
|
|
202
|
+
from: `src/${fromLayer}/x.ts`,
|
|
203
|
+
to,
|
|
204
|
+
fromLayer,
|
|
205
|
+
toLayer: 'shared',
|
|
206
|
+
severity: 'error',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function minimalScan(): ScanResult {
|
|
211
|
+
return {
|
|
212
|
+
project: { id: 'p', name: 'p', rootPath: '/p', detectedFramework: 'vue' },
|
|
213
|
+
modules: [],
|
|
214
|
+
edges: [],
|
|
215
|
+
cycles: [],
|
|
216
|
+
metrics: {},
|
|
217
|
+
hotZones: [],
|
|
218
|
+
layerViolations: [],
|
|
219
|
+
archDebt: {
|
|
220
|
+
score: 0,
|
|
221
|
+
grade: 'A',
|
|
222
|
+
breakdown: { cycles: 0, layerViolations: 0, hotZones: 0, coupling: 0 },
|
|
223
|
+
},
|
|
224
|
+
recommendations: [],
|
|
225
|
+
contractViolations: [],
|
|
226
|
+
scannedAt: '2026-05-12T00:00:00.000Z',
|
|
227
|
+
durationMs: 1,
|
|
228
|
+
warnings: [],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure codegen for the `type-only-candidate` insight (see
|
|
5
|
+
* `analyzer/typeOnlyCandidates.ts`). Detection finds an import edge where every
|
|
6
|
+
* usage of the imported binding(s) is in a type position; this module rewrites
|
|
7
|
+
* the import to drop the runtime edge.
|
|
8
|
+
*
|
|
9
|
+
* Two transformations:
|
|
10
|
+
* - all bindings of an import are type-only → flip the declaration to
|
|
11
|
+
* `import type { ... }`.
|
|
12
|
+
* - only some bindings are type-only → split into two declarations (one
|
|
13
|
+
* value, one `import type`) on the same module specifier. We deliberately
|
|
14
|
+
* do NOT use the per-specifier `type` modifier (`import { A, type B }`),
|
|
15
|
+
* because `verbatimModuleSyntax` projects accept either form and a hard
|
|
16
|
+
* split is the lowest-common-denominator that survives older TS configs.
|
|
17
|
+
*
|
|
18
|
+
* Vue/Svelte SFCs: we locate the first `<script>`/`<script setup>` block in
|
|
19
|
+
* raw text and operate on the script body, then splice the patched script
|
|
20
|
+
* back into the SFC at the original offset.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type ApplyTargetLanguage = 'ts' | 'js' | 'vue' | 'svelte';
|
|
24
|
+
|
|
25
|
+
export interface TextHunk {
|
|
26
|
+
/** Inclusive start offset in the original `content`. */
|
|
27
|
+
start: number;
|
|
28
|
+
/** Exclusive end offset in the original `content`. */
|
|
29
|
+
end: number;
|
|
30
|
+
before: string;
|
|
31
|
+
after: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ApplyTypeOnlyFixInput {
|
|
35
|
+
filePath: string;
|
|
36
|
+
content: string;
|
|
37
|
+
language: ApplyTargetLanguage;
|
|
38
|
+
/** Module specifier of the import to rewrite, e.g. `'./b'`. */
|
|
39
|
+
specifier: string;
|
|
40
|
+
/** Names that should move to a type-only import. Subset of the import's bindings. */
|
|
41
|
+
bindings: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ApplyTypeOnlyFixResult {
|
|
45
|
+
/** Full file content after the patch. */
|
|
46
|
+
patchedContent: string;
|
|
47
|
+
/** Single-region textual edit (or empty when nothing changed). */
|
|
48
|
+
hunks: TextHunk[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class ApplyTypeOnlyFixError extends Error {
|
|
52
|
+
readonly code:
|
|
53
|
+
| 'import-not-found'
|
|
54
|
+
| 'already-type-only'
|
|
55
|
+
| 'no-bindings-to-move'
|
|
56
|
+
| 'unsupported-shape'
|
|
57
|
+
| 'no-script-block';
|
|
58
|
+
constructor(code: ApplyTypeOnlyFixError['code'], message: string) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = 'ApplyTypeOnlyFixError';
|
|
61
|
+
this.code = code;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function applyTypeOnlyFix(input: ApplyTypeOnlyFixInput): ApplyTypeOnlyFixResult {
|
|
66
|
+
const { content, language } = input;
|
|
67
|
+
if (language === 'vue' || language === 'svelte') {
|
|
68
|
+
return applyToSfc(input);
|
|
69
|
+
}
|
|
70
|
+
const hunk = computeImportRewrite(content, 0, input);
|
|
71
|
+
return { patchedContent: spliceHunk(content, hunk), hunks: [hunk] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- SFC ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
const SCRIPT_OPEN_RE = /<\s*script\b[^>]*>/iu;
|
|
77
|
+
const SCRIPT_CLOSE_RE = /<\/\s*script\s*>/iu;
|
|
78
|
+
|
|
79
|
+
interface ScriptBlock {
|
|
80
|
+
bodyStart: number;
|
|
81
|
+
bodyEnd: number;
|
|
82
|
+
body: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function findScriptBlocks(sfc: string): ScriptBlock[] {
|
|
86
|
+
const blocks: ScriptBlock[] = [];
|
|
87
|
+
let cursor = 0;
|
|
88
|
+
while (cursor < sfc.length) {
|
|
89
|
+
const open = SCRIPT_OPEN_RE.exec(sfc.slice(cursor));
|
|
90
|
+
if (!open) break;
|
|
91
|
+
const tagStart = cursor + open.index;
|
|
92
|
+
const bodyStart = tagStart + open[0].length;
|
|
93
|
+
const close = SCRIPT_CLOSE_RE.exec(sfc.slice(bodyStart));
|
|
94
|
+
if (!close) break;
|
|
95
|
+
const bodyEnd = bodyStart + close.index;
|
|
96
|
+
blocks.push({ bodyStart, bodyEnd, body: sfc.slice(bodyStart, bodyEnd) });
|
|
97
|
+
cursor = bodyEnd + close[0].length;
|
|
98
|
+
}
|
|
99
|
+
return blocks;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function applyToSfc(input: ApplyTypeOnlyFixInput): ApplyTypeOnlyFixResult {
|
|
103
|
+
const blocks = findScriptBlocks(input.content);
|
|
104
|
+
if (blocks.length === 0) {
|
|
105
|
+
throw new ApplyTypeOnlyFixError('no-script-block', `no <script> block in ${input.filePath}`);
|
|
106
|
+
}
|
|
107
|
+
// Find the block that actually contains the matching import.
|
|
108
|
+
for (const block of blocks) {
|
|
109
|
+
try {
|
|
110
|
+
const hunk = computeImportRewrite(block.body, block.bodyStart, input);
|
|
111
|
+
return { patchedContent: spliceHunk(input.content, hunk), hunks: [hunk] };
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (e instanceof ApplyTypeOnlyFixError && e.code === 'import-not-found') continue;
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
throw new ApplyTypeOnlyFixError(
|
|
118
|
+
'import-not-found',
|
|
119
|
+
`import "${input.specifier}" not found in <script> blocks of ${input.filePath}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- core rewrite ---------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
interface ImportShape {
|
|
126
|
+
defaultName: string | null;
|
|
127
|
+
namespaceName: string | null;
|
|
128
|
+
/** Named bindings that are NOT already `import { type X }` in source. */
|
|
129
|
+
namedValueBindings: string[];
|
|
130
|
+
/** Named bindings already marked `type` per-specifier - left untouched. */
|
|
131
|
+
namedTypeBindings: string[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function computeImportRewrite(
|
|
135
|
+
script: string,
|
|
136
|
+
scriptOffsetInFile: number,
|
|
137
|
+
input: ApplyTypeOnlyFixInput,
|
|
138
|
+
): TextHunk {
|
|
139
|
+
const sf = ts.createSourceFile(
|
|
140
|
+
input.filePath,
|
|
141
|
+
script,
|
|
142
|
+
ts.ScriptTarget.Latest,
|
|
143
|
+
true,
|
|
144
|
+
scriptKindFor(input.filePath, input.language),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
let decl: ts.ImportDeclaration | null = null;
|
|
148
|
+
for (const stmt of sf.statements) {
|
|
149
|
+
if (!ts.isImportDeclaration(stmt)) continue;
|
|
150
|
+
const lit = stmt.moduleSpecifier;
|
|
151
|
+
if (!ts.isStringLiteralLike(lit)) continue;
|
|
152
|
+
if (lit.text !== input.specifier) continue;
|
|
153
|
+
decl = stmt;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
if (!decl) {
|
|
157
|
+
throw new ApplyTypeOnlyFixError(
|
|
158
|
+
'import-not-found',
|
|
159
|
+
`import "${input.specifier}" not found in ${input.filePath}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const clause = decl.importClause;
|
|
163
|
+
if (!clause) {
|
|
164
|
+
throw new ApplyTypeOnlyFixError(
|
|
165
|
+
'unsupported-shape',
|
|
166
|
+
`side-effect import has no bindings: ${input.specifier}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (clause.isTypeOnly) {
|
|
170
|
+
throw new ApplyTypeOnlyFixError(
|
|
171
|
+
'already-type-only',
|
|
172
|
+
`import "${input.specifier}" is already type-only`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const shape = readShape(clause);
|
|
177
|
+
const moveSet = new Set(input.bindings);
|
|
178
|
+
|
|
179
|
+
// Sanity: every name we're asked to move must exist in the shape.
|
|
180
|
+
const allNames = new Set<string>();
|
|
181
|
+
if (shape.defaultName) allNames.add(shape.defaultName);
|
|
182
|
+
if (shape.namespaceName) allNames.add(shape.namespaceName);
|
|
183
|
+
for (const n of shape.namedValueBindings) allNames.add(n);
|
|
184
|
+
const missing = [...moveSet].filter((n) => !allNames.has(n));
|
|
185
|
+
if (missing.length > 0) {
|
|
186
|
+
throw new ApplyTypeOnlyFixError(
|
|
187
|
+
'no-bindings-to-move',
|
|
188
|
+
`bindings not present in import: ${missing.join(', ')}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Mixed default + namespace is a TS rarity (`import D, * as N`); we don't
|
|
193
|
+
// try to be clever. Bail rather than emit something that may not parse.
|
|
194
|
+
if (shape.defaultName && shape.namespaceName) {
|
|
195
|
+
throw new ApplyTypeOnlyFixError(
|
|
196
|
+
'unsupported-shape',
|
|
197
|
+
`combined default+namespace import is unsupported: ${input.specifier}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const start = decl.getStart(sf);
|
|
202
|
+
const end = decl.getEnd();
|
|
203
|
+
const before = script.slice(start, end);
|
|
204
|
+
const lineStart = script.lastIndexOf('\n', start - 1) + 1;
|
|
205
|
+
const leading = script.slice(lineStart, start).match(/^[\t ]*/u)?.[0] ?? '';
|
|
206
|
+
const quote = quoteOf(decl.moduleSpecifier.getText(sf));
|
|
207
|
+
const semi = before.trimEnd().endsWith(';') ? ';' : '';
|
|
208
|
+
|
|
209
|
+
const after = renderSplit(shape, moveSet, input.specifier, quote, semi, leading);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
start: scriptOffsetInFile + start,
|
|
213
|
+
end: scriptOffsetInFile + end,
|
|
214
|
+
before,
|
|
215
|
+
after,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function readShape(clause: ts.ImportClause): ImportShape {
|
|
220
|
+
const out: ImportShape = {
|
|
221
|
+
defaultName: clause.name ? clause.name.text : null,
|
|
222
|
+
namespaceName: null,
|
|
223
|
+
namedValueBindings: [],
|
|
224
|
+
namedTypeBindings: [],
|
|
225
|
+
};
|
|
226
|
+
if (clause.namedBindings) {
|
|
227
|
+
if (ts.isNamespaceImport(clause.namedBindings)) {
|
|
228
|
+
out.namespaceName = clause.namedBindings.name.text;
|
|
229
|
+
} else if (ts.isNamedImports(clause.namedBindings)) {
|
|
230
|
+
for (const el of clause.namedBindings.elements) {
|
|
231
|
+
const name = el.name.text;
|
|
232
|
+
if (el.isTypeOnly) out.namedTypeBindings.push(name);
|
|
233
|
+
else out.namedValueBindings.push(name);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function renderSplit(
|
|
241
|
+
shape: ImportShape,
|
|
242
|
+
moveSet: Set<string>,
|
|
243
|
+
specifier: string,
|
|
244
|
+
quote: string,
|
|
245
|
+
semi: string,
|
|
246
|
+
leading: string,
|
|
247
|
+
): string {
|
|
248
|
+
// Partition: stay (value side), move (type side).
|
|
249
|
+
const stayDefault =
|
|
250
|
+
shape.defaultName && !moveSet.has(shape.defaultName) ? shape.defaultName : null;
|
|
251
|
+
const moveDefault =
|
|
252
|
+
shape.defaultName && moveSet.has(shape.defaultName) ? shape.defaultName : null;
|
|
253
|
+
const stayNs =
|
|
254
|
+
shape.namespaceName && !moveSet.has(shape.namespaceName) ? shape.namespaceName : null;
|
|
255
|
+
const moveNs =
|
|
256
|
+
shape.namespaceName && moveSet.has(shape.namespaceName) ? shape.namespaceName : null;
|
|
257
|
+
const stayNamed = shape.namedValueBindings.filter((n) => !moveSet.has(n));
|
|
258
|
+
const moveNamed = shape.namedValueBindings.filter((n) => moveSet.has(n));
|
|
259
|
+
|
|
260
|
+
// Existing per-specifier `type`-named bindings are preserved as-is on the
|
|
261
|
+
// value side (TS allows mixing `import { type T, V }`); they were already
|
|
262
|
+
// type-only so we don't move them.
|
|
263
|
+
const stayNamedWithExisting = [...stayNamed, ...shape.namedTypeBindings.map((n) => `type ${n}`)];
|
|
264
|
+
|
|
265
|
+
const stmts: string[] = [];
|
|
266
|
+
// Value-side statements
|
|
267
|
+
if (stayDefault || stayNs || stayNamedWithExisting.length > 0) {
|
|
268
|
+
stmts.push(
|
|
269
|
+
...renderSide(stayDefault, stayNs, stayNamedWithExisting, false, specifier, quote, semi),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
// Type-side statements
|
|
273
|
+
if (moveDefault || moveNs || moveNamed.length > 0) {
|
|
274
|
+
stmts.push(...renderSide(moveDefault, moveNs, moveNamed, true, specifier, quote, semi));
|
|
275
|
+
}
|
|
276
|
+
if (stmts.length === 0) {
|
|
277
|
+
// Pathological: nothing to keep, nothing to move. Re-emit declaration as-is would be
|
|
278
|
+
// a no-op; surface as no-bindings rather than silently corrupting the file.
|
|
279
|
+
throw new ApplyTypeOnlyFixError('no-bindings-to-move', `nothing to keep or move`);
|
|
280
|
+
}
|
|
281
|
+
return stmts.join(`\n${leading}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Render one side (value or type) of the split. `import type` does not allow
|
|
286
|
+
* default + named in the same statement, so we emit two when needed.
|
|
287
|
+
*/
|
|
288
|
+
function renderSide(
|
|
289
|
+
defaultName: string | null,
|
|
290
|
+
namespaceName: string | null,
|
|
291
|
+
named: string[],
|
|
292
|
+
asType: boolean,
|
|
293
|
+
specifier: string,
|
|
294
|
+
quote: string,
|
|
295
|
+
semi: string,
|
|
296
|
+
): string[] {
|
|
297
|
+
const kw = asType ? 'import type' : 'import';
|
|
298
|
+
const tail = ` from ${quote}${specifier}${quote}${semi}`;
|
|
299
|
+
const out: string[] = [];
|
|
300
|
+
|
|
301
|
+
// namespace: must be alone (TS forbids default + namespace)
|
|
302
|
+
if (namespaceName) {
|
|
303
|
+
out.push(`${kw} * as ${namespaceName}${tail}`);
|
|
304
|
+
}
|
|
305
|
+
if (asType) {
|
|
306
|
+
// `import type` - default and named cannot mix. Emit separately.
|
|
307
|
+
if (defaultName) out.push(`${kw} ${defaultName}${tail}`);
|
|
308
|
+
if (named.length > 0) out.push(`${kw} { ${named.join(', ')} }${tail}`);
|
|
309
|
+
} else {
|
|
310
|
+
// value side - default and named can mix in one statement.
|
|
311
|
+
if (defaultName && named.length > 0) {
|
|
312
|
+
out.push(`${kw} ${defaultName}, { ${named.join(', ')} }${tail}`);
|
|
313
|
+
} else if (defaultName) {
|
|
314
|
+
out.push(`${kw} ${defaultName}${tail}`);
|
|
315
|
+
} else if (named.length > 0) {
|
|
316
|
+
out.push(`${kw} { ${named.join(', ')} }${tail}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function quoteOf(rawSpecifier: string): string {
|
|
323
|
+
const ch = rawSpecifier.charAt(0);
|
|
324
|
+
return ch === '"' || ch === "'" || ch === '`' ? ch : "'";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function spliceHunk(content: string, hunk: TextHunk): string {
|
|
328
|
+
return content.slice(0, hunk.start) + hunk.after + content.slice(hunk.end);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const KIND_BY_EXT: Record<string, ts.ScriptKind> = {
|
|
332
|
+
ts: ts.ScriptKind.TS,
|
|
333
|
+
tsx: ts.ScriptKind.TSX,
|
|
334
|
+
js: ts.ScriptKind.JS,
|
|
335
|
+
jsx: ts.ScriptKind.JSX,
|
|
336
|
+
mjs: ts.ScriptKind.JS,
|
|
337
|
+
cjs: ts.ScriptKind.JS,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
function scriptKindFor(filePath: string, language: ApplyTargetLanguage): ts.ScriptKind {
|
|
341
|
+
if (language === 'vue' || language === 'svelte') return ts.ScriptKind.TS;
|
|
342
|
+
const ext = filePath.slice(filePath.lastIndexOf('.') + 1).toLowerCase();
|
|
343
|
+
return KIND_BY_EXT[ext] ?? ts.ScriptKind.TS;
|
|
344
|
+
}
|