@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,432 @@
1
+ // Persistent analyzer cache (Node-only).
2
+ //
3
+ // Layout: `<cacheRoot>/<cacheKey>/{manifest.bin, scan.bin, meta.json}`
4
+ // - cacheRoot: `node_modules/.cache/archora/` when present in the
5
+ // project, otherwise `<os-tmp>/archora-cache/<project-hash>/`.
6
+ // - cacheKey: sha1 hash of (archora version + tsconfig + package deps +
7
+ // archora config + absolute rootPath). A change in any of these
8
+ // invalidates the whole cache for the project.
9
+ // - manifest.bin: `v8.serialize`d `{ files: Record<relPath, { mtimeMs, size }> }`
10
+ // - scan.bin: `v8.serialize`d `ScanResult`
11
+ // - meta.json: plain-text `{ version, createdAt, cacheKey, rootPath }` —
12
+ // readable for debugging and `archora cache clear` bookkeeping.
13
+ //
14
+ // Invalidation uses file `(mtimeMs, size)` tuples as the identity token,
15
+ // matching what esbuild/vite/turbo do: touching a file without changing it
16
+ // triggers a re-parse, which is acceptable for a dev tool and dramatically
17
+ // faster than content hashing. A version mismatch in meta.json forces a full
18
+ // rescan (bump `CACHE_FORMAT_VERSION` on any serialized shape change).
19
+
20
+ import { createHash } from 'node:crypto';
21
+ import { promises as fs } from 'node:fs';
22
+ import { tmpdir } from 'node:os';
23
+ import path from 'node:path';
24
+ import { deserialize, serialize } from 'node:v8';
25
+
26
+ import type { FileSource } from '../analyzer/fileSource';
27
+ import type { ScanResult } from '../analyzer/types';
28
+ import { analyze, type AnalyzeOptions } from '../analyzer/index';
29
+ import { incrementalAnalyze } from '../analyzer/incremental';
30
+ import { discoverFiles } from '../analyzer/discover';
31
+
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.
35
+ */
36
+ export const CACHE_FORMAT_VERSION = 2;
37
+
38
+ /**
39
+ * Marker used inside `node_modules/.cache/archora/`. Also the directory
40
+ * name placed under the OS temp fallback root.
41
+ */
42
+ const CACHE_DIR_NAME = 'archora';
43
+
44
+ export interface FileStat {
45
+ /** Posix-style relative path from project root. */
46
+ mtimeMs: number;
47
+ size: number;
48
+ }
49
+
50
+ export interface CacheManifest {
51
+ /** `relPath → {mtimeMs, size}` for every file that participated in the scan. */
52
+ files: Record<string, FileStat>;
53
+ }
54
+
55
+ export interface CacheMeta {
56
+ version: number;
57
+ /** ISO timestamp of the write. */
58
+ createdAt: string;
59
+ cacheKey: string;
60
+ /** Absolute project root the cache was produced for. */
61
+ rootPath: string;
62
+ /** Canonical archora version the cache was produced with (for diagnostics). */
63
+ toolVersion: string;
64
+ }
65
+
66
+ export interface CacheEntry {
67
+ scan: ScanResult;
68
+ manifest: CacheManifest;
69
+ meta: CacheMeta;
70
+ }
71
+
72
+ export interface CacheKeyInputs {
73
+ rootPath: string;
74
+ /** `@archora/core` package version (or equivalent marker). */
75
+ toolVersion: string;
76
+ /** Raw text of project `tsconfig.json`/`tsconfig.base.json`, if any. */
77
+ tsconfigText?: string;
78
+ /** Stringified `dependencies` + `devDependencies` from `package.json`. */
79
+ packageDeps?: string;
80
+ /** Raw text of `.archora.json` / `archora.config.json`, if any. */
81
+ frontScopeConfigText?: string;
82
+ }
83
+
84
+ export interface CacheLocation {
85
+ /** Absolute path to the per-project cache directory (`<root>/<cacheKey>/`). */
86
+ dir: string;
87
+ /** Absolute path to the cache root (parent of `dir`), used by `clearAll`. */
88
+ root: string;
89
+ cacheKey: string;
90
+ }
91
+
92
+ /**
93
+ * Determine where to place the cache for a given project. Prefers
94
+ * `<projectRoot>/node_modules/.cache/archora/<key>` (the industry
95
+ * convention for Node tool caches - auto-ignored by git because the whole
96
+ * `node_modules` tree is). Falls back to `<os-tmp>/archora-cache/<project-hash>/<key>`
97
+ * when no `node_modules` directory exists (bare scripts, Vue/React in a
98
+ * monorepo with package manager other than npm, external project analysis).
99
+ */
100
+ export async function resolveCacheLocation(inputs: CacheKeyInputs): Promise<CacheLocation> {
101
+ const cacheKey = computeCacheKey(inputs);
102
+ const root = await resolveCacheRoot(inputs.rootPath);
103
+ return { dir: path.join(root, cacheKey), root, cacheKey };
104
+ }
105
+
106
+ async function resolveCacheRoot(rootPath: string): Promise<string> {
107
+ const preferred = path.join(rootPath, 'node_modules', '.cache', CACHE_DIR_NAME);
108
+ try {
109
+ const nm = await fs.stat(path.join(rootPath, 'node_modules'));
110
+ if (nm.isDirectory()) return preferred;
111
+ } catch {
112
+ /* no node_modules - fall through */
113
+ }
114
+ const projectHash = sha1(path.resolve(rootPath)).slice(0, 16);
115
+ return path.join(tmpdir(), `${CACHE_DIR_NAME}-cache`, projectHash);
116
+ }
117
+
118
+ /**
119
+ * Stable hash of all inputs that should force full re-analysis on change.
120
+ * Order-sensitive but the fields themselves are taken verbatim, so
121
+ * whitespace-only edits to tsconfig still bust the cache - matching TS/Vite
122
+ * behaviour where config whitespace can affect things like JSON trailing
123
+ * commas that TS treats as errors.
124
+ */
125
+ export function computeCacheKey(inputs: CacheKeyInputs): string {
126
+ const parts = [
127
+ `v${CACHE_FORMAT_VERSION}`,
128
+ `tool:${inputs.toolVersion}`,
129
+ `root:${path.resolve(inputs.rootPath)}`,
130
+ `tsconfig:${inputs.tsconfigText ?? ''}`,
131
+ `deps:${inputs.packageDeps ?? ''}`,
132
+ `fsconfig:${inputs.frontScopeConfigText ?? ''}`,
133
+ ];
134
+ return sha1(parts.join('\u0000')).slice(0, 24);
135
+ }
136
+
137
+ function sha1(input: string): string {
138
+ return createHash('sha1').update(input).digest('hex');
139
+ }
140
+
141
+ /**
142
+ * Stat a list of files under `rootPath` in parallel. Missing files are
143
+ * silently dropped from the result - the caller compares this against the
144
+ * manifest to derive added/removed sets.
145
+ */
146
+ export async function statFiles(
147
+ rootPath: string,
148
+ relPaths: readonly string[],
149
+ ): Promise<Record<string, FileStat>> {
150
+ const out: Record<string, FileStat> = {};
151
+ // Batch to avoid FD exhaustion on huge projects. 128 is comfortable
152
+ // everywhere (macOS default `ulimit -n 256`, Linux 1024+, Windows 8192).
153
+ const BATCH = 128;
154
+ for (let i = 0; i < relPaths.length; i += BATCH) {
155
+ const batch = relPaths.slice(i, i + BATCH);
156
+ await Promise.all(
157
+ batch.map(async (rel) => {
158
+ try {
159
+ const st = await fs.stat(path.join(rootPath, rel));
160
+ if (st.isFile()) out[rel] = { mtimeMs: st.mtimeMs, size: st.size };
161
+ } catch {
162
+ /* missing - drop */
163
+ }
164
+ }),
165
+ );
166
+ }
167
+ return out;
168
+ }
169
+
170
+ export interface DiffResult {
171
+ added: string[];
172
+ removed: string[];
173
+ changed: string[];
174
+ unchanged: number;
175
+ }
176
+
177
+ /**
178
+ * Compare the freshly-stat'd filesystem state against a cached manifest.
179
+ * Returns three disjoint sets the incremental analyzer can consume.
180
+ */
181
+ export function diffAgainstManifest(
182
+ manifest: CacheManifest,
183
+ current: Record<string, FileStat>,
184
+ ): DiffResult {
185
+ const added: string[] = [];
186
+ const changed: string[] = [];
187
+ const removed: string[] = [];
188
+ let unchanged = 0;
189
+ for (const [rel, st] of Object.entries(current)) {
190
+ const prev = manifest.files[rel];
191
+ if (!prev) {
192
+ added.push(rel);
193
+ } else if (prev.mtimeMs !== st.mtimeMs || prev.size !== st.size) {
194
+ changed.push(rel);
195
+ } else {
196
+ unchanged++;
197
+ }
198
+ }
199
+ for (const rel of Object.keys(manifest.files)) {
200
+ if (!(rel in current)) removed.push(rel);
201
+ }
202
+ return { added, removed, changed, unchanged };
203
+ }
204
+
205
+ export async function loadCache(location: CacheLocation): Promise<CacheEntry | null> {
206
+ try {
207
+ const [metaRaw, manifestBuf, scanBuf] = await Promise.all([
208
+ fs.readFile(path.join(location.dir, 'meta.json'), 'utf8'),
209
+ fs.readFile(path.join(location.dir, 'manifest.bin')),
210
+ fs.readFile(path.join(location.dir, 'scan.bin')),
211
+ ]);
212
+ const meta = JSON.parse(metaRaw) as CacheMeta;
213
+ if (meta.version !== CACHE_FORMAT_VERSION) return null;
214
+ if (meta.cacheKey !== location.cacheKey) return null;
215
+ const manifest = deserialize(manifestBuf) as CacheManifest;
216
+ const scan = deserialize(scanBuf) as ScanResult;
217
+ if (!manifest?.files || !scan?.modules) return null;
218
+ return { scan, manifest, meta };
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ export async function saveCache(
225
+ location: CacheLocation,
226
+ scan: ScanResult,
227
+ manifest: CacheManifest,
228
+ toolVersion: string,
229
+ ): Promise<void> {
230
+ await fs.mkdir(location.dir, { recursive: true });
231
+ const meta: CacheMeta = {
232
+ version: CACHE_FORMAT_VERSION,
233
+ createdAt: new Date().toISOString(),
234
+ cacheKey: location.cacheKey,
235
+ rootPath: path.resolve(scan.project.rootPath),
236
+ toolVersion,
237
+ };
238
+ // Write atomically: tmp file then rename. Avoids partial writes being read
239
+ // by a concurrent analyzer (e.g. CLI + desktop app sharing the cache).
240
+ await Promise.all([
241
+ atomicWrite(path.join(location.dir, 'manifest.bin'), serialize(manifest)),
242
+ atomicWrite(path.join(location.dir, 'scan.bin'), serialize(scan)),
243
+ atomicWrite(path.join(location.dir, 'meta.json'), Buffer.from(JSON.stringify(meta, null, 2))),
244
+ ]);
245
+ }
246
+
247
+ async function atomicWrite(target: string, data: Buffer): Promise<void> {
248
+ const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
249
+ await fs.writeFile(tmp, data);
250
+ await fs.rename(tmp, target);
251
+ }
252
+
253
+ /**
254
+ * Remove every entry under the cache root for the given project. Intended
255
+ * for `archora cache clear`. Use `clearAll` to wipe the entire root
256
+ * (cross-project).
257
+ */
258
+ export async function clearProjectCache(location: CacheLocation): Promise<void> {
259
+ await fs.rm(location.root, { recursive: true, force: true });
260
+ }
261
+
262
+ export interface AnalyzeWithCacheOptions extends AnalyzeOptions {
263
+ /** Project root (absolute). Required for cache I/O. */
264
+ rootPath: string;
265
+ /** Frontscope tool version for cache key. */
266
+ toolVersion: string;
267
+ /** Raw text of the project tsconfig (any variant). */
268
+ tsconfigText?: string;
269
+ /** Stringified `dependencies` + `devDependencies` from `package.json`. */
270
+ packageDeps?: string;
271
+ /** Raw text of `.archora.json` / `archora.config.json`. */
272
+ frontScopeConfigText?: string;
273
+ /**
274
+ * Override the auto-resolved cache directory. Useful in tests or for
275
+ * shared/team caches.
276
+ */
277
+ cacheDir?: string;
278
+ }
279
+
280
+ export type CacheOutcome =
281
+ | { kind: 'miss' }
282
+ | { kind: 'fresh'; from: 'cache' }
283
+ | { kind: 'incremental'; changed: number; removed: number }
284
+ | { kind: 'invalidated'; reason: 'added-files' | 'config-change' | 'corrupt-cache' };
285
+
286
+ export interface AnalyzeWithCacheResult {
287
+ scan: ScanResult;
288
+ outcome: CacheOutcome;
289
+ /** Absolute path to the cache entry directory. */
290
+ cacheDir: string;
291
+ }
292
+
293
+ /**
294
+ * Orchestrate `loadCache` → stat-diff → `incrementalAnalyze` (or full
295
+ * `analyze`) → `saveCache`. The wrapper owns Node FS so the analyzer core
296
+ * can stay FileSource-pure.
297
+ *
298
+ * Behaviour:
299
+ * - Cache miss (no entry, version skew, or key change): full `analyze()`,
300
+ * then save.
301
+ * - Cache hit, no filesystem changes: returns the cached `ScanResult`
302
+ * verbatim - the warm path. ~zero work beyond stat'ing N files.
303
+ * - Cache hit with only `changed`/`removed` files: `incrementalAnalyze` with
304
+ * those sets. The analyzer itself bails on tricky cases (config files,
305
+ * glob/prefix imports, template refs) and we re-save afterwards.
306
+ * - Cache hit with `added` files: full `analyze()` (resolve-failed warnings
307
+ * on the previous scan could now resolve to the new files; we don't track
308
+ * that yet). Result is saved.
309
+ *
310
+ * The function never throws on cache I/O: corrupt or unreadable caches fall
311
+ * through to a full scan and overwrite the bad entry on save.
312
+ */
313
+ export async function analyzeWithCache(
314
+ source: FileSource,
315
+ opts: AnalyzeWithCacheOptions,
316
+ ): Promise<AnalyzeWithCacheResult> {
317
+ const keyInputs: CacheKeyInputs = {
318
+ rootPath: opts.rootPath,
319
+ toolVersion: opts.toolVersion,
320
+ ...(opts.tsconfigText !== undefined ? { tsconfigText: opts.tsconfigText } : {}),
321
+ ...(opts.packageDeps !== undefined ? { packageDeps: opts.packageDeps } : {}),
322
+ ...(opts.frontScopeConfigText !== undefined
323
+ ? { frontScopeConfigText: opts.frontScopeConfigText }
324
+ : {}),
325
+ };
326
+ const location = opts.cacheDir
327
+ ? {
328
+ dir: opts.cacheDir,
329
+ root: path.dirname(opts.cacheDir),
330
+ cacheKey: computeCacheKey(keyInputs),
331
+ }
332
+ : await resolveCacheLocation(keyInputs);
333
+
334
+ const analyzeOpts: AnalyzeOptions = {
335
+ ...(opts.topHotZones !== undefined ? { topHotZones: opts.topHotZones } : {}),
336
+ ...(opts.onProgress ? { onProgress: opts.onProgress } : {}),
337
+ ...(opts.discover ? { discover: opts.discover } : {}),
338
+ };
339
+
340
+ const cached = await loadCache(location);
341
+
342
+ // Fresh full scan: no usable cache.
343
+ if (!cached) {
344
+ const scan = await analyze(source, analyzeOpts);
345
+ const manifest = await buildManifestFromScan(opts.rootPath, scan);
346
+ await saveCache(location, scan, manifest, opts.toolVersion);
347
+ return { scan, outcome: { kind: 'miss' }, cacheDir: location.dir };
348
+ }
349
+
350
+ // Discover the current file set. We reuse `discoverFiles` so cache + cold
351
+ // path see exactly the same ignore rules.
352
+ const { files: currentList } = await discoverFiles(source, analyzeOpts.discover);
353
+ const current = await statFiles(opts.rootPath, currentList);
354
+ const diff = diffAgainstManifest(cached.manifest, current);
355
+
356
+ // Warm path: identical filesystem state.
357
+ if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) {
358
+ return { scan: cached.scan, outcome: { kind: 'fresh', from: 'cache' }, cacheDir: location.dir };
359
+ }
360
+
361
+ // Incremental fast-path attempt. `incrementalAnalyze` will fall back to a
362
+ // full scan internally on any condition it can't handle, so we don't need
363
+ // to second-guess it here.
364
+ const next = await incrementalAnalyze({
365
+ prev: cached.scan,
366
+ source,
367
+ changedFiles: diff.changed,
368
+ addedFiles: diff.added,
369
+ removedFiles: diff.removed,
370
+ options: analyzeOpts,
371
+ });
372
+
373
+ const manifest: CacheManifest = { files: current };
374
+ await saveCache(location, next, manifest, opts.toolVersion);
375
+
376
+ // Distinguish incremental from forced-full. The analyzer doesn't tell us
377
+ // which path it took, so we infer: if there were `added` files or if
378
+ // changed/removed include config files, it bailed to full. Otherwise the
379
+ // fast path *probably* ran. Used for diagnostic logging only - functional
380
+ // result is identical.
381
+ if (diff.added.length > 0) {
382
+ return {
383
+ scan: next,
384
+ outcome: { kind: 'invalidated', reason: 'added-files' },
385
+ cacheDir: location.dir,
386
+ };
387
+ }
388
+ return {
389
+ scan: next,
390
+ outcome: { kind: 'incremental', changed: diff.changed.length, removed: diff.removed.length },
391
+ cacheDir: location.dir,
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Build a fresh manifest by stat'ing every module that ended up in the scan.
397
+ * We don't ask the FileSource for its full list again here - cheaper to just
398
+ * stat what `analyze()` already enumerated.
399
+ */
400
+ async function buildManifestFromScan(rootPath: string, scan: ScanResult): Promise<CacheManifest> {
401
+ const rels = scan.modules.map((m) => m.id);
402
+ const files = await statFiles(rootPath, rels);
403
+ return { files };
404
+ }
405
+
406
+ export async function cacheRootSize(root: string): Promise<{ files: number; bytes: number }> {
407
+ let files = 0;
408
+ let bytes = 0;
409
+ async function walk(dir: string): Promise<void> {
410
+ let entries;
411
+ try {
412
+ entries = await fs.readdir(dir, { withFileTypes: true });
413
+ } catch {
414
+ return;
415
+ }
416
+ for (const e of entries) {
417
+ const full = path.join(dir, e.name);
418
+ if (e.isDirectory()) await walk(full);
419
+ else if (e.isFile()) {
420
+ files++;
421
+ try {
422
+ const st = await fs.stat(full);
423
+ bytes += st.size;
424
+ } catch {
425
+ /* ignore */
426
+ }
427
+ }
428
+ }
429
+ }
430
+ await walk(root);
431
+ return { files, bytes };
432
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { analyze } from '../../analyzer';
3
+ import { createInMemoryFileSource } from '../../analyzer/sources/inMemoryFileSource';
4
+ import { applyTypeOnlyFix } from '../applyTypeOnlyFix';
5
+
6
+ /**
7
+ * End-to-end check: a tiny synthetic project with a cycle whose
8
+ * feedback edge is type-only. After applying the fix, the
9
+ * type-only-candidate insight should disappear from the rescan.
10
+ *
11
+ * a.ts -> b.ts (Foo used only as a type)
12
+ * b.ts -> a.ts (Bar used as a value)
13
+ */
14
+ describe('applyTypeOnlyFix integration', () => {
15
+ it('removes the type-only-candidate recommendation after re-scan', async () => {
16
+ const files: Record<string, string> = {
17
+ 'package.json': JSON.stringify({ name: 'syn', version: '0.0.0' }),
18
+ 'tsconfig.json': JSON.stringify({ compilerOptions: { strict: true } }),
19
+ 'a.ts': [
20
+ `import { runBar } from './b';`,
21
+ `export type Shared = { id: number };`,
22
+ `export function trigger(): number { return runBar(); }`,
23
+ ``,
24
+ ].join('\n'),
25
+ 'b.ts': [
26
+ `import { Shared } from './a';`,
27
+ `export function runBar(): number { return 1; }`,
28
+ `export type Wrap = Shared & { kind: 'wrap' };`,
29
+ ``,
30
+ ].join('\n'),
31
+ };
32
+
33
+ const source = createInMemoryFileSource('/syn', files);
34
+ const before = await analyze(source);
35
+ const beforeRec = before.recommendations.find((r) => r.kind === 'type-only-candidate');
36
+ expect(beforeRec, 'expected analyzer to surface a type-only-candidate').toBeTruthy();
37
+ const fromFile = beforeRec!.modules[0]!;
38
+ const specifier = String(beforeRec!.params.specifier);
39
+ const bindings = String(beforeRec!.params.bindings)
40
+ .split(',')
41
+ .map((s) => s.trim());
42
+ // Apply the fix textually on whichever side the analyzer chose,
43
+ // then run a fresh analyze on the patched source.
44
+ const patched = applyTypeOnlyFix({
45
+ filePath: fromFile,
46
+ content: files[fromFile]!,
47
+ language: 'ts',
48
+ specifier,
49
+ bindings,
50
+ });
51
+ expect(patched.patchedContent).toContain(`import type`);
52
+
53
+ const nextFiles = { ...files, [fromFile]: patched.patchedContent };
54
+ const nextSource = createInMemoryFileSource('/syn', nextFiles);
55
+ const after = await analyze(nextSource);
56
+
57
+ const stillThere = after.recommendations.find((r) => r.kind === 'type-only-candidate');
58
+ expect(stillThere, 'type-only-candidate insight should be gone after fix').toBeFalsy();
59
+ // and the cycle itself should be resolved (no SCC of size > 1 left).
60
+ expect(after.cycles.filter((c) => c.modules.length > 1)).toHaveLength(0);
61
+ });
62
+ });
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { applyTypeOnlyFix, ApplyTypeOnlyFixError } from '../applyTypeOnlyFix';
3
+
4
+ describe('applyTypeOnlyFix', () => {
5
+ it('flips a fully type-only named import', () => {
6
+ const src = `import { Foo, Bar } from './b';\nexport function f(x: Foo, y: Bar) { void x; void y; }\n`;
7
+ const r = applyTypeOnlyFix({
8
+ filePath: 'a.ts',
9
+ content: src,
10
+ language: 'ts',
11
+ specifier: './b',
12
+ bindings: ['Foo', 'Bar'],
13
+ });
14
+ expect(r.patchedContent).toBe(
15
+ `import type { Foo, Bar } from './b';\nexport function f(x: Foo, y: Bar) { void x; void y; }\n`,
16
+ );
17
+ expect(r.hunks).toHaveLength(1);
18
+ expect(r.hunks[0]?.before).toBe(`import { Foo, Bar } from './b';`);
19
+ expect(r.hunks[0]?.after).toBe(`import type { Foo, Bar } from './b';`);
20
+ });
21
+
22
+ it('flips a default-only import', () => {
23
+ const src = `import Foo from './b';\nexport type X = Foo;\n`;
24
+ const r = applyTypeOnlyFix({
25
+ filePath: 'a.ts',
26
+ content: src,
27
+ language: 'ts',
28
+ specifier: './b',
29
+ bindings: ['Foo'],
30
+ });
31
+ expect(r.patchedContent).toBe(`import type Foo from './b';\nexport type X = Foo;\n`);
32
+ });
33
+
34
+ it('flips a namespace import', () => {
35
+ const src = `import * as B from './b';\nexport type X = B.Foo;\n`;
36
+ const r = applyTypeOnlyFix({
37
+ filePath: 'a.ts',
38
+ content: src,
39
+ language: 'ts',
40
+ specifier: './b',
41
+ bindings: ['B'],
42
+ });
43
+ expect(r.patchedContent).toBe(`import type * as B from './b';\nexport type X = B.Foo;\n`);
44
+ });
45
+
46
+ it('splits a partially type-only named import', () => {
47
+ const src = `import { Foo, Bar } from './b';\nexport function f(x: Foo) { return Bar(x); }\n`;
48
+ const r = applyTypeOnlyFix({
49
+ filePath: 'a.ts',
50
+ content: src,
51
+ language: 'ts',
52
+ specifier: './b',
53
+ bindings: ['Foo'],
54
+ });
55
+ expect(r.patchedContent).toBe(
56
+ `import { Bar } from './b';\nimport type { Foo } from './b';\nexport function f(x: Foo) { return Bar(x); }\n`,
57
+ );
58
+ });
59
+
60
+ it('preserves leading indentation when splitting (e.g. inside a script block)', () => {
61
+ const src = ` import { Foo, Bar } from './b';\n export function f(x: Foo) { return Bar(x); }\n`;
62
+ const r = applyTypeOnlyFix({
63
+ filePath: 'a.ts',
64
+ content: src,
65
+ language: 'ts',
66
+ specifier: './b',
67
+ bindings: ['Foo'],
68
+ });
69
+ expect(r.patchedContent).toBe(
70
+ ` import { Bar } from './b';\n import type { Foo } from './b';\n export function f(x: Foo) { return Bar(x); }\n`,
71
+ );
72
+ });
73
+
74
+ it('splits mixed default + named when only named is type-only', () => {
75
+ const src = `import Foo, { Bar } from './b';\nFoo();\nexport type X = Bar;\n`;
76
+ const r = applyTypeOnlyFix({
77
+ filePath: 'a.ts',
78
+ content: src,
79
+ language: 'ts',
80
+ specifier: './b',
81
+ bindings: ['Bar'],
82
+ });
83
+ expect(r.patchedContent).toBe(
84
+ `import Foo from './b';\nimport type { Bar } from './b';\nFoo();\nexport type X = Bar;\n`,
85
+ );
86
+ });
87
+
88
+ it('rewrites import inside Vue <script setup>', () => {
89
+ const src = [
90
+ `<template><div /></template>`,
91
+ `<script setup lang="ts">`,
92
+ `import { Foo } from './b';`,
93
+ `defineProps<{ x: Foo }>();`,
94
+ `</script>`,
95
+ ``,
96
+ ].join('\n');
97
+ const r = applyTypeOnlyFix({
98
+ filePath: 'a.vue',
99
+ content: src,
100
+ language: 'vue',
101
+ specifier: './b',
102
+ bindings: ['Foo'],
103
+ });
104
+ expect(r.patchedContent).toContain(`import type { Foo } from './b';`);
105
+ expect(r.patchedContent).not.toContain(`import { Foo } from './b';`);
106
+ // structure preserved
107
+ expect(r.patchedContent).toContain(`<template><div /></template>`);
108
+ expect(r.patchedContent).toContain(`defineProps<{ x: Foo }>();`);
109
+ });
110
+
111
+ it('rewrites import inside Svelte <script>', () => {
112
+ const src = [
113
+ `<script lang="ts">`,
114
+ ` import { Foo } from './b';`,
115
+ ` export let x: Foo;`,
116
+ `</script>`,
117
+ `<div>{x}</div>`,
118
+ ``,
119
+ ].join('\n');
120
+ const r = applyTypeOnlyFix({
121
+ filePath: 'a.svelte',
122
+ content: src,
123
+ language: 'svelte',
124
+ specifier: './b',
125
+ bindings: ['Foo'],
126
+ });
127
+ expect(r.patchedContent).toContain(`import type { Foo } from './b';`);
128
+ expect(r.patchedContent).not.toContain(`import { Foo } from './b';`);
129
+ expect(r.patchedContent).toContain(`<div>{x}</div>`);
130
+ });
131
+
132
+ it('throws already-type-only when import is already a type import', () => {
133
+ const src = `import type { Foo } from './b';\nexport type X = Foo;\n`;
134
+ expect(() =>
135
+ applyTypeOnlyFix({
136
+ filePath: 'a.ts',
137
+ content: src,
138
+ language: 'ts',
139
+ specifier: './b',
140
+ bindings: ['Foo'],
141
+ }),
142
+ ).toThrow(ApplyTypeOnlyFixError);
143
+ });
144
+
145
+ it('throws import-not-found when specifier is missing', () => {
146
+ const src = `import { Foo } from './b';\n`;
147
+ let err: unknown = null;
148
+ try {
149
+ applyTypeOnlyFix({
150
+ filePath: 'a.ts',
151
+ content: src,
152
+ language: 'ts',
153
+ specifier: './c',
154
+ bindings: ['Foo'],
155
+ });
156
+ } catch (e) {
157
+ err = e;
158
+ }
159
+ expect(err).toBeInstanceOf(ApplyTypeOnlyFixError);
160
+ expect((err as ApplyTypeOnlyFixError).code).toBe('import-not-found');
161
+ });
162
+
163
+ it('preserves double-quote style and semicolon-less source', () => {
164
+ const src = `import { Foo, Bar } from "./b"\nexport function f(x: Foo) { return Bar(x) }\n`;
165
+ const r = applyTypeOnlyFix({
166
+ filePath: 'a.ts',
167
+ content: src,
168
+ language: 'ts',
169
+ specifier: './b',
170
+ bindings: ['Foo'],
171
+ });
172
+ expect(r.patchedContent).toBe(
173
+ `import { Bar } from "./b"\nimport type { Foo } from "./b"\nexport function f(x: Foo) { return Bar(x) }\n`,
174
+ );
175
+ });
176
+ });