@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,288 @@
1
+ // Загрузка path-aliases из tsconfig (с цепочкой `extends`) и Vite-конфига.
2
+ // Вынесено из `analyze()` для переиспользования в `incrementalAnalyze()` —
3
+ // при пере-сканировании одного файла нам нужен тот же резолвер, что и при
4
+ // полном анализе, иначе разрешение `@/*` сломается на ходу.
5
+
6
+ import type { FileSource } from './fileSource';
7
+ import type { ProjectRef } from './types';
8
+ import { parseTsconfigPaths, parseViteAliases, type PathAlias } from './resolve';
9
+
10
+ export async function loadAliases(source: FileSource, project: ProjectRef): Promise<PathAlias[]> {
11
+ const visited = new Set<string>();
12
+ const aliases: PathAlias[] = [];
13
+
14
+ async function walk(rel: string): Promise<void> {
15
+ const norm = posixNormalize(rel);
16
+ if (visited.has(norm)) return;
17
+ visited.add(norm);
18
+ let content: string;
19
+ try {
20
+ content = await source.read(norm);
21
+ } catch {
22
+ return;
23
+ }
24
+ const direct = parseTsconfigPaths(content);
25
+ for (const a of direct) aliases.push(a);
26
+ const extendsList = parseTsconfigExtends(content);
27
+ const baseDir = posixDirname(norm);
28
+ for (const ext of extendsList) {
29
+ const candidates = ext.startsWith('.')
30
+ ? [posixJoin(baseDir, ext), posixJoin(baseDir, `${ext}.json`)]
31
+ : [ext, `${ext}.json`];
32
+ for (const c of candidates) {
33
+ if (await source.exists(c)) {
34
+ await walk(c);
35
+ break;
36
+ }
37
+ }
38
+ }
39
+ }
40
+
41
+ if (project.tsconfigPath) await walk(project.tsconfigPath);
42
+
43
+ for (const a of frameworkDefaultAliases(project.detectedFramework)) aliases.push(a);
44
+ for (const a of await workspacePackageAliases(source)) aliases.push(a);
45
+
46
+ for (const candidate of [
47
+ 'vite.config.ts',
48
+ 'vite.config.js',
49
+ 'vite.config.mts',
50
+ 'vite.config.mjs',
51
+ ]) {
52
+ if (await source.exists(candidate)) {
53
+ try {
54
+ const content = await source.read(candidate);
55
+ for (const a of parseViteAliases(content)) aliases.push(a);
56
+ } catch {
57
+ /* unreadable config - ignore */
58
+ }
59
+ break;
60
+ }
61
+ }
62
+
63
+ return dedupeAliases(aliases);
64
+ }
65
+
66
+ async function workspacePackageAliases(source: FileSource): Promise<PathAlias[]> {
67
+ let files: string[];
68
+ try {
69
+ files = await source.list();
70
+ } catch {
71
+ return [];
72
+ }
73
+ const out: PathAlias[] = [];
74
+ for (const rel of files) {
75
+ if (!rel.endsWith('package.json') || rel === 'package.json') continue;
76
+ let raw: string;
77
+ try {
78
+ raw = await source.read(rel);
79
+ } catch {
80
+ continue;
81
+ }
82
+ const pkg = parsePackageJson(raw);
83
+ if (!pkg?.name) continue;
84
+ const dir = posixDirname(rel);
85
+ const exported = aliasesFromPackageExports(pkg, dir);
86
+ if (exported.length > 0) {
87
+ out.push(...exported.map((alias) => ({ ...alias, prefix: `${pkg.name}${alias.prefix}` })));
88
+ } else {
89
+ out.push({ prefix: pkg.name, targets: [`${dir}/src`, dir] });
90
+ }
91
+ }
92
+ const rootPkg = await readPackageJson(source, 'package.json');
93
+ if (rootPkg) out.push(...aliasesFromPackageImports(rootPkg, ''));
94
+ return out;
95
+ }
96
+
97
+ interface PackageJsonLite {
98
+ name?: string;
99
+ exports?: unknown;
100
+ imports?: unknown;
101
+ }
102
+
103
+ async function readPackageJson(source: FileSource, rel: string): Promise<PackageJsonLite | null> {
104
+ try {
105
+ return parsePackageJson(await source.read(rel));
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function parsePackageJson(raw: string): PackageJsonLite | null {
112
+ try {
113
+ const json = JSON.parse(raw) as { name?: unknown; exports?: unknown; imports?: unknown };
114
+ return {
115
+ ...(typeof json.name === 'string' && json.name.length > 0 ? { name: json.name } : {}),
116
+ ...(json.exports !== undefined ? { exports: json.exports } : {}),
117
+ ...(json.imports !== undefined ? { imports: json.imports } : {}),
118
+ };
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ function aliasesFromPackageExports(pkg: PackageJsonLite, dir: string): PathAlias[] {
125
+ if (pkg.exports === undefined) return [];
126
+ const out: PathAlias[] = [];
127
+ if (typeof pkg.exports === 'string') {
128
+ out.push({ prefix: '', targets: [posixJoin(dir, pkg.exports)], exact: true });
129
+ return out;
130
+ }
131
+ if (!pkg.exports || typeof pkg.exports !== 'object' || Array.isArray(pkg.exports)) return [];
132
+ if (isConditionalExportsObject(pkg.exports)) {
133
+ const targets = pickPackageTargets(pkg.exports);
134
+ const mapped = targets
135
+ .filter((target) => !target.includes('*'))
136
+ .map((target) => posixJoin(dir, target));
137
+ if (mapped.length > 0) out.push({ prefix: '', targets: mapped, exact: true });
138
+ return out;
139
+ }
140
+ for (const [subpath, value] of Object.entries(pkg.exports as Record<string, unknown>)) {
141
+ if (!subpath.startsWith('.')) continue;
142
+ const targets = pickPackageTargets(value);
143
+ if (targets.length === 0) continue;
144
+ const wildcard = wildcardAlias(subpath, targets, dir);
145
+ if (wildcard) {
146
+ out.push(wildcard);
147
+ continue;
148
+ }
149
+ const suffix = subpath === '.' ? '' : subpath.slice(1);
150
+ if (suffix.includes('*')) continue;
151
+ const mapped = targets
152
+ .filter((target) => !target.includes('*'))
153
+ .map((target) => posixJoin(dir, target));
154
+ if (mapped.length === 0) continue;
155
+ out.push({ prefix: suffix, targets: mapped, exact: true });
156
+ }
157
+ return out;
158
+ }
159
+
160
+ function isConditionalExportsObject(value: unknown): boolean {
161
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
162
+ return Object.keys(value).some((key) => ['types', 'import', 'require', 'default'].includes(key));
163
+ }
164
+
165
+ function aliasesFromPackageImports(pkg: PackageJsonLite, dir: string): PathAlias[] {
166
+ if (!pkg.imports || typeof pkg.imports !== 'object' || Array.isArray(pkg.imports)) return [];
167
+ const out: PathAlias[] = [];
168
+ for (const [key, value] of Object.entries(pkg.imports as Record<string, unknown>)) {
169
+ if (!key.startsWith('#')) continue;
170
+ const targets = pickPackageTargets(value);
171
+ const wildcard = wildcardAlias(key, targets, dir);
172
+ if (wildcard) {
173
+ out.push(wildcard);
174
+ continue;
175
+ }
176
+ if (key.includes('*')) continue;
177
+ const mapped = targets
178
+ .filter((target) => !target.includes('*'))
179
+ .map((target) => posixJoin(dir, target));
180
+ if (mapped.length === 0) continue;
181
+ out.push({ prefix: key, targets: mapped, exact: true });
182
+ }
183
+ return out;
184
+ }
185
+
186
+ function wildcardAlias(key: string, targets: readonly string[], dir: string): PathAlias | null {
187
+ if (!key.includes('*')) return null;
188
+ const mapped = targets
189
+ .filter((target) => target.includes('*'))
190
+ .map((target) => posixJoin(dir, beforeStar(stripPackageTargetPrefix(target))));
191
+ if (mapped.length === 0) return null;
192
+ const prefix = beforeStar(key === './*' ? '' : key.startsWith('.') ? key.slice(1) : key);
193
+ if (!prefix) return null;
194
+ return { prefix, targets: mapped };
195
+ }
196
+
197
+ function beforeStar(value: string): string {
198
+ const head = value.slice(0, value.indexOf('*'));
199
+ return head.replace(/\/+$/u, '');
200
+ }
201
+
202
+ function pickPackageTargets(value: unknown): string[] {
203
+ if (typeof value === 'string') return [stripPackageTargetPrefix(value)];
204
+ if (Array.isArray(value)) {
205
+ const out: string[] = [];
206
+ for (const item of value) {
207
+ out.push(...pickPackageTargets(item));
208
+ }
209
+ return dedupeStrings(out);
210
+ }
211
+ if (!value || typeof value !== 'object') return [];
212
+ const conditions = value as Record<string, unknown>;
213
+ const out: string[] = [];
214
+ for (const key of ['types', 'import', 'require', 'default']) {
215
+ out.push(...pickPackageTargets(conditions[key]));
216
+ }
217
+ return dedupeStrings(out);
218
+ }
219
+
220
+ function stripPackageTargetPrefix(target: string): string {
221
+ return target.replace(/^\.\//u, '');
222
+ }
223
+
224
+ function dedupeStrings(values: readonly string[]): string[] {
225
+ return [...new Set(values)];
226
+ }
227
+
228
+ function frameworkDefaultAliases(framework: ProjectRef['detectedFramework']): PathAlias[] {
229
+ if (framework === 'svelte') {
230
+ return [{ prefix: '$lib', targets: ['src/lib'] }];
231
+ }
232
+ if (framework === 'nuxt') {
233
+ return [
234
+ { prefix: '~', targets: [''] },
235
+ { prefix: '@', targets: [''] },
236
+ { prefix: '~~', targets: [''] },
237
+ { prefix: '@@', targets: [''] },
238
+ ];
239
+ }
240
+ return [];
241
+ }
242
+
243
+ function parseTsconfigExtends(content: string): string[] {
244
+ try {
245
+ const stripped = content.replace(/\/\*[\s\S]*?\*\//gu, '').replace(/\/\/[^\n]*/gu, '');
246
+ const json = JSON.parse(stripped) as { extends?: string | string[] };
247
+ if (!json.extends) return [];
248
+ return Array.isArray(json.extends) ? json.extends : [json.extends];
249
+ } catch {
250
+ return [];
251
+ }
252
+ }
253
+
254
+ function dedupeAliases(list: PathAlias[]): PathAlias[] {
255
+ const seen = new Set<string>();
256
+ const out: PathAlias[] = [];
257
+ for (const a of list) {
258
+ const key = `${a.prefix}\u0001${a.exact === true ? 'exact' : 'prefix'}\u0001${a.targets.join('|')}`;
259
+ if (seen.has(key)) continue;
260
+ seen.add(key);
261
+ out.push(a);
262
+ }
263
+ return out.sort((a, b) => b.prefix.length - a.prefix.length || Number(b.exact) - Number(a.exact));
264
+ }
265
+
266
+ function posixNormalize(p: string): string {
267
+ return p.replace(/\\/gu, '/').replace(/\/+/gu, '/');
268
+ }
269
+
270
+ function posixDirname(p: string): string {
271
+ const i = p.lastIndexOf('/');
272
+ return i === -1 ? '' : p.slice(0, i);
273
+ }
274
+
275
+ function posixJoin(a: string, b: string): string {
276
+ const segments = `${a}/${b}`.split('/');
277
+ const out: string[] = [];
278
+ for (const s of segments) {
279
+ if (s === '' || s === '.') continue;
280
+ if (s === '..') {
281
+ if (out.length > 0 && out[out.length - 1] !== '..') out.pop();
282
+ else out.push('..');
283
+ continue;
284
+ }
285
+ out.push(s);
286
+ }
287
+ return out.join('/');
288
+ }
@@ -0,0 +1,345 @@
1
+ import ts from 'typescript';
2
+ import type { FileSource } from './fileSource';
3
+ import type { DetectedFramework, MemoryRiskFinding, MemoryRiskKind, ModuleNode } from './types';
4
+
5
+ export interface DetectMemoryRisksInput {
6
+ source: FileSource;
7
+ modules: readonly ModuleNode[];
8
+ framework: DetectedFramework;
9
+ }
10
+
11
+ interface RiskPattern {
12
+ kind: MemoryRiskKind;
13
+ acquire: string;
14
+ cleanup: string;
15
+ testAcquire(node: ts.Node): boolean;
16
+ testCleanup(node: ts.Node): boolean;
17
+ }
18
+
19
+ interface RiskContext {
20
+ module: ModuleNode;
21
+ framework: DetectedFramework;
22
+ sourceFile: ts.SourceFile;
23
+ }
24
+
25
+ const PATTERNS: readonly RiskPattern[] = [
26
+ {
27
+ kind: 'event-listener-cleanup',
28
+ acquire: 'addEventListener',
29
+ cleanup: 'removeEventListener',
30
+ testAcquire: isNamedCall('addEventListener'),
31
+ testCleanup: isNamedCall('removeEventListener'),
32
+ },
33
+ {
34
+ kind: 'timer-cleanup',
35
+ acquire: 'setInterval',
36
+ cleanup: 'clearInterval',
37
+ testAcquire: isNamedCall('setInterval'),
38
+ testCleanup: isNamedCall('clearInterval'),
39
+ },
40
+ {
41
+ kind: 'timer-cleanup',
42
+ acquire: 'setTimeout',
43
+ cleanup: 'clearTimeout',
44
+ testAcquire: isNamedCall('setTimeout'),
45
+ testCleanup: isNamedCall('clearTimeout'),
46
+ },
47
+ {
48
+ kind: 'observer-cleanup',
49
+ acquire: 'observer',
50
+ cleanup: 'disconnect',
51
+ testAcquire: isObserverCreation,
52
+ testCleanup: isNamedCall('disconnect'),
53
+ },
54
+ {
55
+ kind: 'object-url-cleanup',
56
+ acquire: 'createObjectURL',
57
+ cleanup: 'revokeObjectURL',
58
+ testAcquire: isNamedCall('createObjectURL'),
59
+ testCleanup: isNamedCall('revokeObjectURL'),
60
+ },
61
+ {
62
+ kind: 'subscription-cleanup',
63
+ acquire: 'subscribe',
64
+ cleanup: 'unsubscribe or stop handle',
65
+ testAcquire: isNamedCall('subscribe'),
66
+ testCleanup: isSubscriptionCleanup,
67
+ },
68
+ ];
69
+
70
+ export async function detectMemoryRisks(
71
+ input: DetectMemoryRisksInput,
72
+ ): Promise<MemoryRiskFinding[]> {
73
+ const findings: MemoryRiskFinding[] = [];
74
+ for (const module of input.modules) {
75
+ if (module.runtime === 'server') continue;
76
+ const code = await readSource(input.source, module.id);
77
+ if (code === null) continue;
78
+ const script = scriptContent(module.id, code);
79
+ if (script.trim().length === 0) continue;
80
+ const sourceFile = ts.createSourceFile(
81
+ module.id,
82
+ script,
83
+ ts.ScriptTarget.Latest,
84
+ false,
85
+ scriptKindFor(module.id),
86
+ );
87
+ const context: RiskContext = {
88
+ module,
89
+ framework: input.framework,
90
+ sourceFile,
91
+ };
92
+ findings.push(...detectInModule(context));
93
+ }
94
+ return dedupeFindings(findings);
95
+ }
96
+
97
+ function detectInModule(context: RiskContext): MemoryRiskFinding[] {
98
+ if (context.framework === 'react' || context.framework === 'next') {
99
+ return detectReactRisks(context);
100
+ }
101
+ if (context.framework === 'vue' || context.framework === 'nuxt') {
102
+ return detectVueRisks(context);
103
+ }
104
+ if (context.framework === 'svelte') return detectSvelteRisks(context);
105
+ return detectGenericRisks(context);
106
+ }
107
+
108
+ function detectReactRisks(context: RiskContext): MemoryRiskFinding[] {
109
+ const findings: MemoryRiskFinding[] = [];
110
+ visit(context.sourceFile, (node) => {
111
+ if (!ts.isCallExpression(node) || !isIdentifierCall(node, 'useEffect')) return;
112
+ const callback = node.arguments[0];
113
+ if (!callback || !isFunctionLike(callback)) return;
114
+ const body = callback.body;
115
+ if (!ts.isBlock(body)) return;
116
+ const cleanup = returnedCleanup(body);
117
+ findings.push(...findMissingCleanup(context, body, cleanup, 'high'));
118
+ });
119
+ return findings;
120
+ }
121
+
122
+ function detectVueRisks(context: RiskContext): MemoryRiskFinding[] {
123
+ const findings: MemoryRiskFinding[] = [];
124
+ const unmountedCleanups = collectLifecycleCleanups(context.sourceFile, 'onUnmounted');
125
+ visit(context.sourceFile, (node) => {
126
+ if (!ts.isCallExpression(node) || !isIdentifierCall(node, 'onMounted')) return;
127
+ const callback = node.arguments[0];
128
+ if (!callback || !isFunctionLike(callback)) return;
129
+ const body = callback.body;
130
+ if (!ts.isBlock(body)) return;
131
+ findings.push(...findMissingCleanupInAny(context, body, unmountedCleanups, 'high'));
132
+ });
133
+ return findings;
134
+ }
135
+
136
+ function detectSvelteRisks(context: RiskContext): MemoryRiskFinding[] {
137
+ const findings: MemoryRiskFinding[] = [];
138
+ visit(context.sourceFile, (node) => {
139
+ if (!ts.isCallExpression(node) || !isIdentifierCall(node, 'onMount')) return;
140
+ const callback = node.arguments[0];
141
+ if (!callback || !isFunctionLike(callback)) return;
142
+ const body = callback.body;
143
+ if (!ts.isBlock(body)) return;
144
+ const cleanup = returnedCleanup(body);
145
+ findings.push(...findMissingCleanup(context, body, cleanup, 'high'));
146
+ });
147
+ return findings;
148
+ }
149
+
150
+ function detectGenericRisks(context: RiskContext): MemoryRiskFinding[] {
151
+ return findMissingCleanup(context, context.sourceFile, context.sourceFile, 'medium');
152
+ }
153
+
154
+ function findMissingCleanup(
155
+ context: RiskContext,
156
+ acquireRoot: ts.Node,
157
+ cleanupRoot: ts.Node | undefined,
158
+ confidence: MemoryRiskFinding['confidence'],
159
+ ): MemoryRiskFinding[] {
160
+ const findings: MemoryRiskFinding[] = [];
161
+ for (const pattern of PATTERNS) {
162
+ const acquire = findFirst(acquireRoot, pattern.testAcquire);
163
+ if (!acquire) continue;
164
+ const hasCleanup = cleanupRoot ? hasNode(cleanupRoot, pattern.testCleanup) : false;
165
+ if (hasCleanup) continue;
166
+ findings.push(buildFinding(context, pattern, acquire, confidence));
167
+ }
168
+ return findings;
169
+ }
170
+
171
+ function findMissingCleanupInAny(
172
+ context: RiskContext,
173
+ acquireRoot: ts.Node,
174
+ cleanupRoots: readonly ts.Node[],
175
+ confidence: MemoryRiskFinding['confidence'],
176
+ ): MemoryRiskFinding[] {
177
+ const findings: MemoryRiskFinding[] = [];
178
+ for (const pattern of PATTERNS) {
179
+ const acquire = findFirst(acquireRoot, pattern.testAcquire);
180
+ if (!acquire) continue;
181
+ const hasCleanup = cleanupRoots.some((root) => hasNode(root, pattern.testCleanup));
182
+ if (hasCleanup) continue;
183
+ findings.push(buildFinding(context, pattern, acquire, confidence));
184
+ }
185
+ return findings;
186
+ }
187
+
188
+ function buildFinding(
189
+ context: RiskContext,
190
+ pattern: RiskPattern,
191
+ node: ts.Node,
192
+ confidence: MemoryRiskFinding['confidence'],
193
+ ): MemoryRiskFinding {
194
+ const line =
195
+ context.sourceFile.getLineAndCharacterOfPosition(node.getStart(context.sourceFile)).line + 1;
196
+ return {
197
+ id: `memory:${pattern.kind}:${context.module.id}:${line}`,
198
+ kind: pattern.kind,
199
+ moduleId: context.module.id,
200
+ framework: context.framework,
201
+ severity: 'medium',
202
+ confidence,
203
+ evidence: [
204
+ {
205
+ message: `${pattern.acquire} has no visible ${pattern.cleanup} cleanup`,
206
+ line,
207
+ acquire: pattern.acquire,
208
+ expectedCleanup: pattern.cleanup,
209
+ },
210
+ ],
211
+ remediation: remediationFor(pattern.kind),
212
+ };
213
+ }
214
+
215
+ function returnedCleanup(block: ts.Block): ts.Node | undefined {
216
+ for (const statement of block.statements) {
217
+ if (!ts.isReturnStatement(statement) || !statement.expression) continue;
218
+ return statement.expression;
219
+ }
220
+ return undefined;
221
+ }
222
+
223
+ function collectLifecycleCleanups(sourceFile: ts.SourceFile, name: string): ts.Node[] {
224
+ const nodes: ts.Node[] = [];
225
+ visit(sourceFile, (node) => {
226
+ if (!ts.isCallExpression(node) || !isIdentifierCall(node, name)) return;
227
+ const callback = node.arguments[0];
228
+ if (callback && isFunctionLike(callback)) nodes.push(callback.body);
229
+ });
230
+ return nodes;
231
+ }
232
+
233
+ function visit(node: ts.Node, cb: (node: ts.Node) => void): void {
234
+ cb(node);
235
+ ts.forEachChild(node, (child) => visit(child, cb));
236
+ }
237
+
238
+ function findFirst(root: ts.Node, predicate: (node: ts.Node) => boolean): ts.Node | undefined {
239
+ let found: ts.Node | undefined;
240
+ visit(root, (node) => {
241
+ if (!found && predicate(node)) found = node;
242
+ });
243
+ return found;
244
+ }
245
+
246
+ function hasNode(root: ts.Node, predicate: (node: ts.Node) => boolean): boolean {
247
+ return findFirst(root, predicate) !== undefined;
248
+ }
249
+
250
+ function isFunctionLike(node: ts.Node): node is ts.ArrowFunction | ts.FunctionExpression {
251
+ return ts.isArrowFunction(node) || ts.isFunctionExpression(node);
252
+ }
253
+
254
+ function isNamedCall(name: string): (node: ts.Node) => boolean {
255
+ return (node) => ts.isCallExpression(node) && callName(node.expression) === name;
256
+ }
257
+
258
+ function isIdentifierCall(node: ts.CallExpression, name: string): boolean {
259
+ return ts.isIdentifier(node.expression) && node.expression.text === name;
260
+ }
261
+
262
+ function isObserverCreation(node: ts.Node): boolean {
263
+ if (!ts.isNewExpression(node)) return false;
264
+ if (!ts.isIdentifier(node.expression)) return false;
265
+ return ['IntersectionObserver', 'MutationObserver', 'ResizeObserver'].includes(
266
+ node.expression.text,
267
+ );
268
+ }
269
+
270
+ function isSubscriptionCleanup(node: ts.Node): boolean {
271
+ if (!ts.isCallExpression(node)) return false;
272
+ const name = callName(node.expression);
273
+ return name === 'unsubscribe' || name === 'stop';
274
+ }
275
+
276
+ function callName(expression: ts.Expression): string | null {
277
+ if (ts.isIdentifier(expression)) return expression.text;
278
+ if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
279
+ return null;
280
+ }
281
+
282
+ function remediationFor(kind: MemoryRiskKind): string {
283
+ switch (kind) {
284
+ case 'event-listener-cleanup':
285
+ return 'Remove the listener from the matching component teardown lifecycle.';
286
+ case 'timer-cleanup':
287
+ return 'Keep the timer handle and clear it during teardown.';
288
+ case 'observer-cleanup':
289
+ return 'Disconnect the observer during teardown.';
290
+ case 'object-url-cleanup':
291
+ return 'Revoke created object URLs after the preview or download is no longer needed.';
292
+ case 'subscription-cleanup':
293
+ return 'Store the unsubscribe handle and call it during teardown.';
294
+ }
295
+ }
296
+
297
+ function dedupeFindings(findings: readonly MemoryRiskFinding[]): MemoryRiskFinding[] {
298
+ const seen = new Set<string>();
299
+ const out: MemoryRiskFinding[] = [];
300
+ for (const finding of findings) {
301
+ if (seen.has(finding.id)) continue;
302
+ seen.add(finding.id);
303
+ out.push(finding);
304
+ }
305
+ return out;
306
+ }
307
+
308
+ async function readSource(source: FileSource, moduleId: string): Promise<string | null> {
309
+ try {
310
+ return await source.read(moduleId);
311
+ } catch {
312
+ return null;
313
+ }
314
+ }
315
+
316
+ function scriptContent(moduleId: string, content: string): string {
317
+ if (moduleId.endsWith('.vue') || moduleId.endsWith('.svelte')) {
318
+ const blocks: string[] = [];
319
+ const lower = content.toLowerCase();
320
+ let offset = 0;
321
+ while (offset < content.length) {
322
+ const openStart = lower.indexOf('<script', offset);
323
+ if (openStart === -1) break;
324
+ const openEnd = lower.indexOf('>', openStart + '<script'.length);
325
+ if (openEnd === -1) break;
326
+ const closeStart = lower.indexOf('</script', openEnd + 1);
327
+ if (closeStart === -1) break;
328
+ const closeEnd = lower.indexOf('>', closeStart + '</script'.length);
329
+ const block = content.slice(openEnd + 1, closeStart);
330
+ if (block) blocks.push(block);
331
+ offset = closeEnd === -1 ? closeStart + '</script'.length : closeEnd + 1;
332
+ }
333
+ return blocks.join('\n');
334
+ }
335
+ return content;
336
+ }
337
+
338
+ function scriptKindFor(moduleId: string): ts.ScriptKind {
339
+ if (moduleId.endsWith('.tsx')) return ts.ScriptKind.TSX;
340
+ if (moduleId.endsWith('.jsx')) return ts.ScriptKind.JSX;
341
+ if (moduleId.endsWith('.js') || moduleId.endsWith('.mjs') || moduleId.endsWith('.cjs')) {
342
+ return ts.ScriptKind.JS;
343
+ }
344
+ return ts.ScriptKind.TS;
345
+ }