@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,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
|
+
});
|