@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,177 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createParserRegistry, isParseFailure } from '../parsers';
3
+ import { analyze } from '../index';
4
+ import { createInMemoryFileSource } from '../sources/inMemoryFileSource';
5
+ import type { ParsedFile } from '../types';
6
+
7
+ const parseVue = (relPath: string, content: string): ParsedFile => {
8
+ const registry = createParserRegistry({ framework: 'vue' });
9
+ const r = registry.parse({ relPath, content });
10
+ if (isParseFailure(r)) throw new Error(`parse failed: ${r.reason}`);
11
+ return r;
12
+ };
13
+
14
+ describe('vueParser: templateRefs extraction', () => {
15
+ it('extracts PascalCase component tags from <template>', () => {
16
+ const sfc = `<script setup lang="ts">
17
+ const x = 1;
18
+ </script>
19
+ <template>
20
+ <AppHeader />
21
+ <div><MyButton>click</MyButton></div>
22
+ </template>`;
23
+ const file = parseVue('App.vue', sfc);
24
+ expect(file.templateRefs).toEqual(expect.arrayContaining(['AppHeader', 'MyButton']));
25
+ });
26
+
27
+ it('normalizes kebab-case tags to PascalCase', () => {
28
+ const sfc = `<script setup lang="ts"></script>
29
+ <template>
30
+ <my-fancy-button />
31
+ </template>`;
32
+ const file = parseVue('A.vue', sfc);
33
+ expect(file.templateRefs).toContain('MyFancyButton');
34
+ });
35
+
36
+ it('excludes Vue / Vue Router built-in tags', () => {
37
+ const sfc = `<script setup lang="ts"></script>
38
+ <template>
39
+ <Transition><div /></Transition>
40
+ <KeepAlive><RouterView /></KeepAlive>
41
+ <Suspense><Teleport to="body"><RouterLink to="/x">x</RouterLink></Teleport></Suspense>
42
+ <NuxtLink to="/y" />
43
+ <ClientOnly />
44
+ <UserCard />
45
+ </template>`;
46
+ const file = parseVue('A.vue', sfc);
47
+ expect(file.templateRefs).toEqual(['UserCard']);
48
+ });
49
+
50
+ it('excludes plain HTML elements (compiler-sfc tagType filter)', () => {
51
+ const sfc = `<script setup lang="ts"></script>
52
+ <template>
53
+ <div><span><button>x</button></span></div>
54
+ <header><nav><ul><li>x</li></ul></nav></header>
55
+ </template>`;
56
+ const file = parseVue('A.vue', sfc);
57
+ expect(file.templateRefs ?? []).toEqual([]);
58
+ });
59
+
60
+ it('omits templateRefs entirely on .vue files without a template', () => {
61
+ const sfc = `<script setup lang="ts">
62
+ export const x = 1;
63
+ </script>`;
64
+ const file = parseVue('A.vue', sfc);
65
+ expect(file.templateRefs).toBeUndefined();
66
+ });
67
+
68
+ it('deduplicates multiple usages of the same tag', () => {
69
+ const sfc = `<script setup lang="ts"></script>
70
+ <template>
71
+ <UserCard /><UserCard /><UserCard />
72
+ </template>`;
73
+ const file = parseVue('A.vue', sfc);
74
+ expect(file.templateRefs).toEqual(['UserCard']);
75
+ });
76
+
77
+ it('walks nested children', () => {
78
+ const sfc = `<script setup lang="ts"></script>
79
+ <template>
80
+ <div>
81
+ <section>
82
+ <article>
83
+ <DeepCard />
84
+ </article>
85
+ </section>
86
+ </div>
87
+ </template>`;
88
+ const file = parseVue('A.vue', sfc);
89
+ expect(file.templateRefs).toContain('DeepCard');
90
+ });
91
+ });
92
+
93
+ describe('buildGraph: unplugin-vue-components auto-import resolution', () => {
94
+ it('emits auto-import edges for src/components/*.vue referenced only in template', async () => {
95
+ const src = createInMemoryFileSource('/p', {
96
+ 'package.json': JSON.stringify({ dependencies: { vue: '^3.0.0' } }),
97
+ 'tsconfig.json': '{}',
98
+ 'src/main.ts': `import App from './App.vue';
99
+ const app = App;`,
100
+ 'src/App.vue': `<script setup lang="ts"></script>
101
+ <template><AppHeader /><AppFooter /></template>`,
102
+ 'src/components/AppHeader.vue': `<template><header /></template>`,
103
+ 'src/components/AppFooter.vue': `<template><footer /></template>`,
104
+ });
105
+ const result = await analyze(src);
106
+ const fromApp = result.edges.filter((e) => e.from === 'src/App.vue');
107
+ const autoImports = fromApp.filter((e) => e.kind === 'auto-import');
108
+ expect(autoImports.map((e) => ({ to: e.to, specifier: e.specifier }))).toEqual(
109
+ expect.arrayContaining([
110
+ { to: 'src/components/AppHeader.vue', specifier: 'AppHeader' },
111
+ { to: 'src/components/AppFooter.vue', specifier: 'AppFooter' },
112
+ ]),
113
+ );
114
+ });
115
+
116
+ it('does NOT emit auto-import edge when the script already imports the component', async () => {
117
+ const src = createInMemoryFileSource('/p', {
118
+ 'package.json': JSON.stringify({ dependencies: { vue: '^3.0.0' } }),
119
+ 'tsconfig.json': '{}',
120
+ 'src/App.vue': `<script setup lang="ts">
121
+ import AppHeader from './components/AppHeader.vue';
122
+ </script>
123
+ <template><AppHeader /></template>`,
124
+ 'src/components/AppHeader.vue': `<template><header /></template>`,
125
+ });
126
+ const result = await analyze(src);
127
+ const edges = result.edges.filter(
128
+ (e) => e.from === 'src/App.vue' && e.to === 'src/components/AppHeader.vue',
129
+ );
130
+ expect(edges).toHaveLength(1);
131
+ expect(edges[0]?.kind).toBe('static');
132
+ });
133
+
134
+ it('does NOT match components outside src/components/ (default unplugin convention)', async () => {
135
+ const src = createInMemoryFileSource('/p', {
136
+ 'package.json': JSON.stringify({ dependencies: { vue: '^3.0.0' } }),
137
+ 'tsconfig.json': '{}',
138
+ 'src/App.vue': `<script setup lang="ts"></script>
139
+ <template><HomeView /></template>`,
140
+ 'src/views/HomeView.vue': `<template><main /></template>`,
141
+ });
142
+ const result = await analyze(src);
143
+ const autoImports = result.edges.filter((e) => e.kind === 'auto-import');
144
+ expect(autoImports).toEqual([]);
145
+ });
146
+
147
+ it('does not emit a self-edge when a component template references itself', async () => {
148
+ const src = createInMemoryFileSource('/p', {
149
+ 'package.json': JSON.stringify({ dependencies: { vue: '^3.0.0' } }),
150
+ 'tsconfig.json': '{}',
151
+ 'src/components/Tree.vue': `<script setup lang="ts">
152
+ defineProps<{ depth: number }>();
153
+ </script>
154
+ <template><div><Tree v-if="depth" :depth="depth - 1" /></div></template>`,
155
+ });
156
+ const result = await analyze(src);
157
+ const selfEdges = result.edges.filter(
158
+ (e) => e.from === 'src/components/Tree.vue' && e.to === 'src/components/Tree.vue',
159
+ );
160
+ expect(selfEdges).toEqual([]);
161
+ });
162
+
163
+ it('matches kebab-case template usage to the PascalCase file in registry', async () => {
164
+ const src = createInMemoryFileSource('/p', {
165
+ 'package.json': JSON.stringify({ dependencies: { vue: '^3.0.0' } }),
166
+ 'tsconfig.json': '{}',
167
+ 'src/App.vue': `<script setup lang="ts"></script>
168
+ <template><my-button /></template>`,
169
+ 'src/components/MyButton.vue': `<template><button /></template>`,
170
+ });
171
+ const result = await analyze(src);
172
+ const autoImports = result.edges.filter(
173
+ (e) => e.from === 'src/App.vue' && e.kind === 'auto-import',
174
+ );
175
+ expect(autoImports.map((e) => e.to)).toEqual(['src/components/MyButton.vue']);
176
+ });
177
+ });
@@ -0,0 +1,68 @@
1
+ import type { ArchDebt, Cycle, LayerViolation, ModuleMetrics, ModuleNode } from './types';
2
+
3
+ interface Inputs {
4
+ modules: ModuleNode[];
5
+ cycles: Cycle[];
6
+ layerViolations: LayerViolation[];
7
+ metrics: Record<string, ModuleMetrics>;
8
+ hotZoneCount: number;
9
+ }
10
+
11
+ // composite arch-debt 0..100 (higher = worse).
12
+ // weights: cycles 35 / layers 30 / coupling 20 / hot zones 15. each saturates.
13
+ export function computeArchDebt(inputs: Inputs): ArchDebt {
14
+ const realModules = inputs.modules.filter((m) => !m.isInfra);
15
+ const moduleCount = Math.max(1, realModules.length);
16
+
17
+ const cycleWeight = inputs.cycles.reduce((acc, c) => acc + (c.severity === 'direct' ? 2 : 1), 0);
18
+ const cycleScore = saturate(cycleWeight / Math.max(1, moduleCount * 0.05));
19
+
20
+ const violationWeight = inputs.layerViolations.reduce(
21
+ (acc, v) => acc + (v.severity === 'error' ? 3 : 1),
22
+ 0,
23
+ );
24
+ const layerScore = saturate(violationWeight / Math.max(1, moduleCount * 0.03));
25
+
26
+ const hotScore = saturate((inputs.hotZoneCount * 10) / moduleCount);
27
+
28
+ let instSum = 0;
29
+ let instCount = 0;
30
+ for (const m of realModules) {
31
+ const metrics = inputs.metrics[m.id];
32
+ if (!metrics) continue;
33
+ instSum += metrics.instability;
34
+ instCount++;
35
+ }
36
+ const couplingScore = instCount > 0 ? instSum / instCount : 0;
37
+
38
+ const score = clamp(
39
+ 100 * (cycleScore * 0.35 + layerScore * 0.3 + hotScore * 0.15 + couplingScore * 0.2),
40
+ );
41
+
42
+ return {
43
+ score: Math.round(score),
44
+ grade: gradeOf(score),
45
+ breakdown: {
46
+ cycles: Math.round(cycleScore * 100),
47
+ layerViolations: Math.round(layerScore * 100),
48
+ hotZones: Math.round(hotScore * 100),
49
+ coupling: Math.round(couplingScore * 100),
50
+ },
51
+ };
52
+ }
53
+
54
+ function saturate(x: number): number {
55
+ return 1 - 1 / (1 + Math.max(0, x));
56
+ }
57
+
58
+ function clamp(x: number): number {
59
+ return Math.max(0, Math.min(100, x));
60
+ }
61
+
62
+ function gradeOf(score: number): ArchDebt['grade'] {
63
+ if (score < 15) return 'A';
64
+ if (score < 30) return 'B';
65
+ if (score < 50) return 'C';
66
+ if (score < 70) return 'D';
67
+ return 'F';
68
+ }
@@ -0,0 +1,234 @@
1
+ import ts from 'typescript';
2
+ import type { FileSource } from './fileSource';
3
+ import type { AsyncLifecycleRiskFinding, DetectedFramework, ModuleNode } from './types';
4
+
5
+ export interface DetectAsyncLifecycleRisksInput {
6
+ source: FileSource;
7
+ modules: readonly ModuleNode[];
8
+ framework: DetectedFramework;
9
+ }
10
+
11
+ interface RiskContext {
12
+ module: ModuleNode;
13
+ framework: DetectedFramework;
14
+ sourceFile: ts.SourceFile;
15
+ }
16
+
17
+ export async function detectAsyncLifecycleRisks(
18
+ input: DetectAsyncLifecycleRisksInput,
19
+ ): Promise<AsyncLifecycleRiskFinding[]> {
20
+ const findings: AsyncLifecycleRiskFinding[] = [];
21
+ for (const module of input.modules) {
22
+ if (module.runtime === 'server') continue;
23
+ const code = await readSource(input.source, module.id);
24
+ if (code === null) continue;
25
+ const script = scriptContent(module.id, code);
26
+ if (script.trim().length === 0) continue;
27
+ const sourceFile = ts.createSourceFile(
28
+ module.id,
29
+ script,
30
+ ts.ScriptTarget.Latest,
31
+ false,
32
+ scriptKindFor(module.id),
33
+ );
34
+ const context = {
35
+ module,
36
+ framework: input.framework,
37
+ sourceFile,
38
+ };
39
+ findings.push(...detectInModule(context));
40
+ }
41
+ return dedupeFindings(findings);
42
+ }
43
+
44
+ function detectInModule(context: RiskContext): AsyncLifecycleRiskFinding[] {
45
+ if (context.framework === 'react' || context.framework === 'next') {
46
+ return detectLifecycleCall(context, 'useEffect', 'returned cleanup');
47
+ }
48
+ if (context.framework === 'vue' || context.framework === 'nuxt') {
49
+ const cleanupRoots = collectLifecycleCleanups(context.sourceFile, 'onUnmounted');
50
+ return detectLifecycleCall(context, 'onMounted', 'onUnmounted cleanup', cleanupRoots);
51
+ }
52
+ if (context.framework === 'svelte') {
53
+ return detectLifecycleCall(context, 'onMount', 'returned cleanup');
54
+ }
55
+ return [];
56
+ }
57
+
58
+ function detectLifecycleCall(
59
+ context: RiskContext,
60
+ lifecycleName: string,
61
+ cleanupLabel: string,
62
+ externalCleanupRoots: readonly ts.Node[] = [],
63
+ ): AsyncLifecycleRiskFinding[] {
64
+ const findings: AsyncLifecycleRiskFinding[] = [];
65
+ visit(context.sourceFile, (node) => {
66
+ if (!ts.isCallExpression(node) || !isIdentifierCall(node, lifecycleName)) return;
67
+ const callback = node.arguments[0];
68
+ if (!callback || !isFunctionLike(callback)) return;
69
+ const body = callback.body;
70
+ if (!ts.isBlock(body)) return;
71
+ if (!hasAsyncWork(body)) return;
72
+ if (hasAbortOrStaleGuard(body, externalCleanupRoots)) return;
73
+ findings.push(buildFinding(context, body, cleanupLabel));
74
+ });
75
+ return findings;
76
+ }
77
+
78
+ function buildFinding(
79
+ context: RiskContext,
80
+ node: ts.Node,
81
+ cleanupLabel: string,
82
+ ): AsyncLifecycleRiskFinding {
83
+ const line =
84
+ context.sourceFile.getLineAndCharacterOfPosition(node.getStart(context.sourceFile)).line + 1;
85
+ return {
86
+ id: `async-lifecycle:async-effect-cleanup:${context.module.id}:${line}`,
87
+ kind: 'async-effect-cleanup',
88
+ moduleId: context.module.id,
89
+ framework: context.framework,
90
+ severity: 'medium',
91
+ confidence: 'high',
92
+ evidence: [
93
+ {
94
+ message: `async lifecycle work has no visible abort, stale guard, or ${cleanupLabel}`,
95
+ line,
96
+ asyncSource: 'fetch',
97
+ expectedGuard: 'AbortController or stale guard cleanup',
98
+ },
99
+ ],
100
+ remediation:
101
+ 'Add AbortController, a stale-result guard, or lifecycle cleanup before updating state.',
102
+ };
103
+ }
104
+
105
+ function hasAsyncWork(root: ts.Node): boolean {
106
+ return hasNode(root, (node) => {
107
+ if (ts.isAwaitExpression(node)) return true;
108
+ if (!ts.isCallExpression(node)) return false;
109
+ const name = callName(node.expression);
110
+ return name === 'fetch' || name === 'then' || name === 'catch' || name === 'finally';
111
+ });
112
+ }
113
+
114
+ function hasAbortOrStaleGuard(body: ts.Block, externalCleanupRoots: readonly ts.Node[]): boolean {
115
+ const roots = [body, returnedCleanup(body), ...externalCleanupRoots].filter(
116
+ (node): node is ts.Node => Boolean(node),
117
+ );
118
+ return (
119
+ roots.some((root) =>
120
+ hasNode(root, (node) => {
121
+ if (ts.isNewExpression(node) && callName(node.expression) === 'AbortController') {
122
+ return true;
123
+ }
124
+ if (!ts.isCallExpression(node)) return false;
125
+ const name = callName(node.expression);
126
+ return name === 'abort' || name === 'cancel' || name === 'unsubscribe' || name === 'stop';
127
+ }),
128
+ ) || hasStaleGuard(body)
129
+ );
130
+ }
131
+
132
+ function hasStaleGuard(root: ts.Node): boolean {
133
+ return hasNode(root, (node) => {
134
+ if (!ts.isIdentifier(node)) return false;
135
+ return /^(cancelled|canceled|stale|ignore|ignored|mounted|isMounted)$/u.test(node.text);
136
+ });
137
+ }
138
+
139
+ function returnedCleanup(block: ts.Block): ts.Node | undefined {
140
+ for (const statement of block.statements) {
141
+ if (!ts.isReturnStatement(statement) || !statement.expression) continue;
142
+ return statement.expression;
143
+ }
144
+ return undefined;
145
+ }
146
+
147
+ function collectLifecycleCleanups(sourceFile: ts.SourceFile, name: string): ts.Node[] {
148
+ const nodes: ts.Node[] = [];
149
+ visit(sourceFile, (node) => {
150
+ if (!ts.isCallExpression(node) || !isIdentifierCall(node, name)) return;
151
+ const callback = node.arguments[0];
152
+ if (callback && isFunctionLike(callback)) nodes.push(callback.body);
153
+ });
154
+ return nodes;
155
+ }
156
+
157
+ function visit(node: ts.Node, cb: (node: ts.Node) => void): void {
158
+ cb(node);
159
+ ts.forEachChild(node, (child) => visit(child, cb));
160
+ }
161
+
162
+ function hasNode(root: ts.Node, predicate: (node: ts.Node) => boolean): boolean {
163
+ let found = false;
164
+ visit(root, (node) => {
165
+ if (!found && predicate(node)) found = true;
166
+ });
167
+ return found;
168
+ }
169
+
170
+ function isFunctionLike(node: ts.Node): node is ts.ArrowFunction | ts.FunctionExpression {
171
+ return ts.isArrowFunction(node) || ts.isFunctionExpression(node);
172
+ }
173
+
174
+ function isIdentifierCall(node: ts.CallExpression, name: string): boolean {
175
+ return ts.isIdentifier(node.expression) && node.expression.text === name;
176
+ }
177
+
178
+ function callName(expression: ts.Expression): string | null {
179
+ if (ts.isIdentifier(expression)) return expression.text;
180
+ if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
181
+ return null;
182
+ }
183
+
184
+ function dedupeFindings(
185
+ findings: readonly AsyncLifecycleRiskFinding[],
186
+ ): AsyncLifecycleRiskFinding[] {
187
+ const seen = new Set<string>();
188
+ const out: AsyncLifecycleRiskFinding[] = [];
189
+ for (const finding of findings) {
190
+ if (seen.has(finding.id)) continue;
191
+ seen.add(finding.id);
192
+ out.push(finding);
193
+ }
194
+ return out;
195
+ }
196
+
197
+ async function readSource(source: FileSource, moduleId: string): Promise<string | null> {
198
+ try {
199
+ return await source.read(moduleId);
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ function scriptContent(moduleId: string, content: string): string {
206
+ if (moduleId.endsWith('.vue') || moduleId.endsWith('.svelte')) {
207
+ const blocks: string[] = [];
208
+ const lower = content.toLowerCase();
209
+ let offset = 0;
210
+ while (offset < content.length) {
211
+ const openStart = lower.indexOf('<script', offset);
212
+ if (openStart === -1) break;
213
+ const openEnd = lower.indexOf('>', openStart + '<script'.length);
214
+ if (openEnd === -1) break;
215
+ const closeStart = lower.indexOf('</script', openEnd + 1);
216
+ if (closeStart === -1) break;
217
+ const closeEnd = lower.indexOf('>', closeStart + '</script'.length);
218
+ const block = content.slice(openEnd + 1, closeStart);
219
+ if (block) blocks.push(block);
220
+ offset = closeEnd === -1 ? closeStart + '</script'.length : closeEnd + 1;
221
+ }
222
+ return blocks.join('\n');
223
+ }
224
+ return content;
225
+ }
226
+
227
+ function scriptKindFor(moduleId: string): ts.ScriptKind {
228
+ if (moduleId.endsWith('.tsx')) return ts.ScriptKind.TSX;
229
+ if (moduleId.endsWith('.jsx')) return ts.ScriptKind.JSX;
230
+ if (moduleId.endsWith('.js') || moduleId.endsWith('.mjs') || moduleId.endsWith('.cjs')) {
231
+ return ts.ScriptKind.JS;
232
+ }
233
+ return ts.ScriptKind.TS;
234
+ }