Repository: microsoft/qsharp
Pull request #1676 (2024-06-27 12:04:46)

Package dependency resolution
We now resolve dependencies from `qsharp.json`.

Example manifest:

```json
{
  "dependencies": {
    "MyLocalDep": {
      "path": "../some-local-folder"
    },
    "MyRemoteDep": {
      "github": {
        "owner": "some-github-user",
        "repo": "some-github-repo",
        "ref": "commit-or-tag",
      }
    }
  }
}
```



📦 Packages are recursively resolved and their sources added to the current compilation.

📢 Any errors that occur during package resolution is reported through the language service and extension commands.

📝 For remote code, a special text content provider is added so we can show GitHub content as read-only in the editor during debugging and code navigation.


github-actions[bot]: _Change in memory usage detected by benchmark._
## Memory Report for 0ae2e23deb343650d6885913c4e6c0b506e0d2a3

| Test                        | This Branch | On Main  | Difference |
|-----------------------------|-------------|----------| ---------- |
| compile core + standard lib | 16689324 bytes | 16688908 bytes | 416 bytes
github-actions[bot]: ## Benchmark for 0ae2e23
  <details>
    <summary>Click to view benchmark</summary>

| Test | Base         | PR               | % |
|------|--------------|------------------|---|
| Array append evaluation | 327.4±3.37µs | 330.7±4.07µs | +1.01% |
| Array literal evaluation | 169.1±0.94µs | 169.1±1.52µs | 0.00% |
| Array update evaluation | 409.8±5.91µs | 409.5±4.89µs | -0.07% |
| Core + Standard library compilation | 21.3±0.91ms | 21.8±0.82ms | +2.35% |
| Deutsch-Jozsa evaluation | 5.2±0.07ms | 5.2±0.05ms | 0.00% |
| Large file parity evaluation | **34.4±0.11ms** | 34.6±0.56ms | **+0.58%** |
| Large input file compilation | 13.2±0.40ms | **12.8±0.39ms** | **-3.03%** |
| Large input file compilation (interpreter) | 50.7±1.50ms | 50.5±1.43ms | -0.39% |
| Large nested iteration | **31.9±0.15ms** | 33.7±0.39ms | **+5.64%** |
| Perform Runtime Capabilities Analysis (RCA) on Deutsch-Jozsa sample | 1566.5±85.41µs | 1565.6±100.64µs | -0.06% |
| Perform Runtime Capabilities Analysis (RCA) on large file sample | 8.1±0.15ms | **7.9±0.11ms** | **-2.47%** |
| Perform Runtime Capabilities Analysis (RCA) on teleport sample | 1415.8±57.66µs | 1431.9±95.45µs | +1.14% |
| Perform Runtime Capabilities Analysis (RCA) on the core and std libraries | 28.3±0.31ms | 28.2±0.31ms | -0.35% |
| Teleport evaluation | 91.6±4.34µs | 92.5±7.62µs | +0.98% |

  </details>
  
github-actions[bot]: _Change in memory usage detected by benchmark._
## Memory Report for 8bdcf2c19ce2d46c974a3b36e371c44f462528f1

| Test                        | This Branch | On Main  | Difference |
|-----------------------------|-------------|----------| ---------- |
| compile core + standard lib | 16689324 bytes | 16688908 bytes | 416 bytes
github-actions[bot]: ## Benchmark for 8bdcf2c
  <details>
    <summary>Click to view benchmark</summary>

| Test | Base         | PR               | % |
|------|--------------|------------------|---|
| Array append evaluation | 330.3±2.02µs | 328.4±4.76µs | -0.58% |
| Array literal evaluation | 190.7±4.01µs | **171.3±2.08µs** | **-10.17%** |
| Array update evaluation | 411.3±1.81µs | 409.7±12.29µs | -0.39% |
| Core + Standard library compilation | 20.3±0.58ms | 20.2±0.52ms | -0.49% |
| Deutsch-Jozsa evaluation | 5.2±0.05ms | 5.2±0.06ms | 0.00% |
| Large file parity evaluation | 34.3±0.11ms | 34.4±0.28ms | +0.29% |
| Large input file compilation | 12.3±0.50ms | 12.6±0.31ms | +2.44% |
| Large input file compilation (interpreter) | 49.3±1.16ms | 49.5±1.49ms | +0.41% |
| Large nested iteration | 32.1±0.26ms | 32.0±1.30ms | -0.31% |
| Perform Runtime Capabilities Analysis (RCA) on Deutsch-Jozsa sample | 1554.2±49.29µs | 1563.7±48.64µs | +0.61% |
| Perform Runtime Capabilities Analysis (RCA) on large file sample | **7.8±0.08ms** | 7.9±0.13ms | **+1.28%** |
| Perform Runtime Capabilities Analysis (RCA) on teleport sample | 1410.6±34.80µs | 1437.3±122.83µs | +1.89% |
| Perform Runtime Capabilities Analysis (RCA) on the core and std libraries | 28.0±0.25ms | 28.0±0.27ms | 0.00% |
| Teleport evaluation | 91.3±3.68µs | 91.3±5.55µs | 0.00% |

  </details>
  

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 0a9aa7c5..d3f651ba 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1190,10 +1190,13 @@ dependencies = [
  "async-trait",
  "expect-test",
  "futures",
+ "log",
  "miette",
+ "qsc_data_structures",
  "qsc_linter",
  "qsc_project",
  "regex-lite",
+ "rustc-hash",
  "serde",
  "serde_json",
  "thiserror",
@@ -1288,6 +1291,7 @@ dependencies = [
  "rustc-hash",
  "serde",
  "serde_json",
+ "thiserror",
  "tokio",
 ]
 
diff --git a/compiler/qsc/src/bin/qsc.rs b/compiler/qsc/src/bin/qsc.rs
index 868b5aba..0ca64203 100644
--- a/compiler/qsc/src/bin/qsc.rs
+++ b/compiler/qsc/src/bin/qsc.rs
@@ -17,7 +17,7 @@ use qsc_frontend::{
 use qsc_hir::hir::Package;
 use qsc_partial_eval::ProgramEntry;
 use qsc_passes::PackageType;
-use qsc_project::{FileSystem, Manifest, StdFs};
+use qsc_project::{FileSystem, StdFs};
 use std::{
     concat, fs,
     io::{self, Read},
@@ -122,16 +122,28 @@ fn main() -> miette::Result<ExitCode> {
 
     if sources.is_empty() {
         let fs = StdFs;
-        let manifest = Manifest::load(cli.qsharp_json)?;
-        if let Some(manifest) = manifest {
-            let project = fs.load_project(&manifest)?;
-            let mut project_sources = project.sources;
+        if let Some(qsharp_json) = cli.qsharp_json {
+            if let Some(dir) = qsharp_json.parent() {
+                let project = match fs.load_project(dir, None) {
+                    Ok(project) => project,
+                    Err(errs) => {
+                        for e in errs {
+                            eprintln!("{e:?}");
+                        }
+                        return Ok(ExitCode::FAILURE);
+                    }
+                };
 
-            sources.append(&mut project_sources);
+                let (mut project_sources, language_features) =
+                    project.package_graph_sources.into_sources_temporary();
 
-            features.merge(LanguageFeatures::from_iter(
-                manifest.manifest.language_features,
-            ));
+                sources.append(&mut project_sources);
+
+                features.merge(LanguageFeatures::from_iter(language_features));
+            } else {
+                eprintln!("{} must have a parent directory", qsharp_json.display());
+                return Ok(ExitCode::FAILURE);
+            }
         }
     }
 
diff --git a/compiler/qsc/src/bin/qsi.rs b/compiler/qsc/src/bin/qsi.rs
index 6e100444..8d802da8 100644
--- a/compiler/qsc/src/bin/qsi.rs
+++ b/compiler/qsc/src/bin/qsi.rs
@@ -16,7 +16,7 @@ use qsc_eval::{
 };
 use qsc_frontend::compile::{SourceContents, SourceMap, SourceName};
 use qsc_passes::PackageType;
-use qsc_project::{FileSystem, Manifest, StdFs};
+use qsc_project::{FileSystem, StdFs};
 use std::{
     fs,
     io::{self, prelude::BufRead, Write},
@@ -93,18 +93,31 @@ fn main() -> miette::Result<ExitCode> {
 
     if sources.is_empty() {
         let fs = StdFs;
-        let manifest = Manifest::load(cli.qsharp_json)?;
-        if let Some(manifest) = manifest {
-            let project = fs.load_project(&manifest)?;
-            let mut project_sources = project.sources;
-
-            sources.append(&mut project_sources);
-
-            features.merge(LanguageFeatures::from_iter(
-                manifest.manifest.language_features,
-            ));
+        if let Some(qsharp_json) = cli.qsharp_json {
+            if let Some(dir) = qsharp_json.parent() {
+                let project = match fs.load_project(dir, None) {
+                    Ok(project) => project,
+                    Err(errs) => {
+                        for e in errs {
+                            eprintln!("{e:?}");
+                        }
+                        return Ok(ExitCode::FAILURE);
+                    }
+                };
+
+                let (mut project_sources, language_features) =
+                    project.package_graph_sources.into_sources_temporary();
+
+                sources.append(&mut project_sources);
+
+                features.merge(LanguageFeatures::from_iter(language_features));
+            } else {
+                eprintln!("{} must have a parent directory", qsharp_json.display());
+                return Ok(ExitCode::FAILURE);
+            }
         }
     }
+
     if cli.exec {
         let mut interpreter = match (if cli.debug {
             Interpreter::new_with_debug
diff --git a/compiler/qsc/src/interpret.rs b/compiler/qsc/src/interpret.rs
index 0361bb5d..f1c76bee 100644
--- a/compiler/qsc/src/interpret.rs
+++ b/compiler/qsc/src/interpret.rs
@@ -811,7 +811,6 @@ impl Debugger {
                         frame.span,
                         map_fir_package_to_hir(frame.id.package),
                         self.interpreter.compiler.package_store(),
-                        map_fir_package_to_hir(self.interpreter.source_package),
                         self.position_encoding,
                     ),
                 }
diff --git a/compiler/qsc/src/lib.rs b/compiler/qsc/src/lib.rs
index 1eada66b..488d971c 100644
--- a/compiler/qsc/src/lib.rs
+++ b/compiler/qsc/src/lib.rs
@@ -30,7 +30,9 @@ pub mod ast {
 }
 
 pub mod project {
-    pub use qsc_project::{DirEntry, EntryType, FileSystem, Manifest, ManifestDescriptor};
+    pub use qsc_project::{
+        DirEntry, EntryType, Error, FileSystem, Manifest, ManifestDescriptor, PackageCache,
+    };
 }
 
 pub use qsc_data_structures::{
diff --git a/compiler/qsc/src/location.rs b/compiler/qsc/src/location.rs
index dd3bde2b..df6ab29c 100644
--- a/compiler/qsc/src/location.rs
+++ b/compiler/qsc/src/location.rs
@@ -10,8 +10,6 @@ use qsc_data_structures::{
 use qsc_frontend::compile::PackageStore;
 use qsc_hir::hir::PackageId;
 
-pub const QSHARP_LIBRARY_URI_SCHEME: &str = "qsharp-library-source";
-
 /// Describes a location in source code in terms of a source name and [`Range`].
 #[derive(Debug, PartialEq, Clone)]
 pub struct Location {
@@ -31,7 +29,6 @@ impl Location {
         span: Span,
         package_id: PackageId,
         package_store: &PackageStore,
-        user_package_id: PackageId,
         position_encoding: Encoding,
     ) -> Self {
         let source = package_store
@@ -41,16 +38,8 @@ impl Location {
             .find_by_offset(span.lo)
             .expect("source should exist for offset");
 
-        let source_name = if package_id == user_package_id {
-            source.name.clone()
-        } else {
-            // Currently the only supported external packages are our library packages,
-            // URI's to which need to include our custom library scheme.
-            format!("{}:{}", QSHARP_LIBRARY_URI_SCHEME, source.name).into()
-        };
-
         Location {
-            source: source_name,
+            source: source.name.clone(),
             range: Range::from_span(position_encoding, &source.contents, &(span - source.offset)),
         }
     }
@@ -72,13 +61,12 @@ mod tests {
 
     #[test]
     fn from_std_span() {
-        let (store, std_package_id, user_package_id) = compile_package();
+        let (store, std_package_id, _) = compile_package();
 
         let location = Location::from(
             Span { lo: 0, hi: 1 },
             std_package_id,
             &store,
-            user_package_id,
             Encoding::Utf8,
         );
 
@@ -102,13 +90,12 @@ mod tests {
 
     #[test]
     fn from_core_span() {
-        let (store, _, user_package_id) = compile_package();
+        let (store, _, _) = compile_package();
 
         let location = Location::from(
             Span { lo: 0, hi: 1 },
             PackageId::CORE,
             &store,
-            user_package_id,
             Encoding::Utf8,
         );
 
@@ -149,7 +136,6 @@ mod tests {
             },
             user_package_id,
             &store,
-            user_package_id,
             Encoding::Utf8,
         );
 
@@ -179,7 +165,6 @@ mod tests {
             Span { lo: 1000, hi: 2000 },
             user_package_id,
             &store,
-            user_package_id,
             Encoding::Utf8,
         );
 
@@ -210,7 +195,6 @@ mod tests {
             Span { lo: 0, hi: 2000 },
             user_package_id,
             &store,
-            user_package_id,
             Encoding::Utf8,
         );
 
diff --git a/compiler/qsc_data_structures/src/language_features.rs b/compiler/qsc_data_structures/src/language_features.rs
index f3d5105c..d86b4fec 100644
--- a/compiler/qsc_data_structures/src/language_features.rs
+++ b/compiler/qsc_data_structures/src/language_features.rs
@@ -38,3 +38,13 @@ where
         })
     }
 }
+
+impl From<LanguageFeatures> for Vec<String> {
+    fn from(features: LanguageFeatures) -> Self {
+        let mut result = Vec::new();
+        if features.contains(LanguageFeatures::V2PreviewSyntax) {
+            result.push("v2-preview-syntax".to_string());
+        }
+        result
+    }
+}
diff --git a/compiler/qsc_project/Cargo.toml b/compiler/qsc_project/Cargo.toml
index 4352b0fa..0e1f8d00 100644
--- a/compiler/qsc_project/Cargo.toml
+++ b/compiler/qsc_project/Cargo.toml
@@ -17,7 +17,10 @@ miette = { workspace = true }
 regex-lite = { workspace = true }
 async-trait = { workspace = true }
 qsc_linter = { path = "../qsc_linter" }
+qsc_data_structures = { path = "../qsc_data_structures" }
+rustc-hash = { workspace = true }
 futures = { workspace = true }
+log = { workspace = true }
 
 [dev-dependencies]
 expect-test = { workspace = true }
diff --git a/compiler/qsc_project/src/error.rs b/compiler/qsc_project/src/error.rs
index 30943a06..4ff08568 100644
--- a/compiler/qsc_project/src/error.rs
+++ b/compiler/qsc_project/src/error.rs
@@ -4,8 +4,9 @@
 use miette::Diagnostic;
 use thiserror::Error;
 
+#[cfg(feature = "fs")]
 #[derive(Error, Debug, Diagnostic)]
-pub enum Error {
+pub enum StdFsError {
     #[error("found a qsharp.json file, but it was invalid: {0}")]
     SerdeJson(#[from] serde_json::Error),
     #[error(transparent)]
diff --git a/compiler/qsc_project/src/fs.rs b/compiler/qsc_project/src/fs.rs
index ba33fbae..87d91a63 100644
--- a/compiler/qsc_project/src/fs.rs
+++ b/compiler/qsc_project/src/fs.rs
@@ -50,7 +50,7 @@ impl DirEntry for PathBuf {
 }
 
 impl DirEntry for StdEntry {
-    type Error = crate::Error;
+    type Error = crate::StdFsError;
     fn entry_type(&self) -> Result<EntryType, Self::Error> {
         Ok(self.file_type()?.into())
     }
@@ -86,10 +86,10 @@ impl FileSystem for StdFs {
     }
 
     fn list_directory(&self, path: &Path) -> miette::Result<Vec<StdEntry>> {
-        let listing = std::fs::read_dir(path).map_err(crate::Error::from)?;
+        let listing = std::fs::read_dir(path).map_err(crate::StdFsError::from)?;
         Ok(listing
             .collect::<Result<_, _>>()
-            .map_err(crate::Error::from)?)
+            .map_err(crate::StdFsError::from)?)
     }
 
     fn resolve_path(&self, base: &Path, path: &Path) -> miette::Result<PathBuf> {
@@ -120,4 +120,17 @@ impl FileSystem for StdFs {
         }
         Ok(normalized)
     }
+
+    fn fetch_github(
+        &self,
+        _owner: &str,
+        _repo: &str,
+        r#ref: &str,
+        _path: &str,
+    ) -> miette::Result<Arc<str>> {
+        let _ = r#ref;
+        Err(miette::Error::msg(
+            "github references not supported for this file system",
+        ))
+    }
 }
diff --git a/compiler/qsc_project/src/js.rs b/compiler/qsc_project/src/js.rs
index 00303aff..5c76ef9c 100644
--- a/compiler/qsc_project/src/js.rs
+++ b/compiler/qsc_project/src/js.rs
@@ -27,9 +27,16 @@ impl DirEntry for JSFileEntry {
 /// Trait for interacting with a project host in JavaScript.
 #[async_trait(?Send)]
 pub trait JSProjectHost {
-    async fn read_file(&self, uri: &str) -> (Arc<str>, Arc<str>);
+    async fn read_file(&self, uri: &str) -> miette::Result<(Arc<str>, Arc<str>)>;
     async fn list_directory(&self, dir_uri: &str) -> Vec<JSFileEntry>;
     async fn resolve_path(&self, base: &str, path: &str) -> Option<Arc<str>>;
+    async fn fetch_github(
+        &self,
+        owner: &str,
+        repo: &str,
+        r#ref: &str,
+        path: &str,
+    ) -> miette::Result<Arc<str>>;
     async fn find_manifest_directory(&self, doc_uri: &str) -> Option<Arc<str>>;
 }
 
@@ -45,7 +52,7 @@ where
         &self,
         path: &std::path::Path,
     ) -> miette::Result<(std::sync::Arc<str>, std::sync::Arc<str>)> {
-        return Ok(self.read_file(&path.to_string_lossy()).await);
+        self.read_file(&path.to_string_lossy()).await
     }
 
     async fn list_directory(&self, path: &std::path::Path) -> miette::Result<Vec<Self::Entry>> {
@@ -63,4 +70,14 @@ where
             .ok_or(Error::msg("Path could not be resolved"))?;
         return Ok(PathBuf::from(res.to_string()));
     }
+
+    async fn fetch_github(
+        &self,
+        owner: &str,
+        repo: &str,
+        r#ref: &str,
+        path: &str,
+    ) -> miette::Result<std::sync::Arc<str>> {
+        self.fetch_github(owner, repo, r#ref, path).await
+    }
 }
diff --git a/compiler/qsc_project/src/lib.rs b/compiler/qsc_project/src/lib.rs
index f8b2d346..5fe2d9ba 100644
--- a/compiler/qsc_project/src/lib.rs
+++ b/compiler/qsc_project/src/lib.rs
@@ -15,10 +15,13 @@ mod js;
 mod manifest;
 mod project;
 
-pub use error::Error;
+pub use error::StdFsError;
 #[cfg(feature = "fs")]
 pub use fs::StdFs;
 pub use js::{JSFileEntry, JSProjectHost};
-pub use manifest::{Manifest, ManifestDescriptor, MANIFEST_FILE_NAME};
+pub use manifest::{Manifest, ManifestDescriptor, PackageRef, MANIFEST_FILE_NAME};
 pub use project::FileSystemAsync;
-pub use project::{DirEntry, EntryType, FileSystem, Project};
+pub use project::{
+    key_for_package_ref, package_ref_from_key, DirEntry, EntryType, Error, FileSystem,
+    PackageCache, PackageGraphSources, PackageInfo, Project,
+};
diff --git a/compiler/qsc_project/src/manifest.rs b/compiler/qsc_project/src/manifest.rs
index 96147c38..ba3b1cae 100644
--- a/compiler/qsc_project/src/manifest.rs
+++ b/compiler/qsc_project/src/manifest.rs
@@ -2,7 +2,7 @@
 // Licensed under the MIT License.
 
 #[cfg(feature = "fs")]
-use crate::Error;
+use crate::StdFsError;
 #[cfg(feature = "fs")]
 use std::{
     env::current_dir,
@@ -10,6 +10,7 @@ use std::{
 };
 
 pub use qsc_linter::LintConfig;
+use rustc_hash::FxHashMap;
 use serde::{Deserialize, Serialize};
 use std::path::PathBuf;
 
@@ -25,6 +26,25 @@ pub struct Manifest {
     pub language_features: Vec<String>,
     #[serde(default)]
     pub lints: Vec<LintConfig>,
+    #[serde(default)]
+    pub dependencies: FxHashMap<String, PackageRef>,
+    #[serde(default)]
+    pub files: Vec<String>,
+}
+
+#[derive(Deserialize, Serialize, Debug, Clone)]
+#[serde(untagged)]
+pub enum PackageRef {
+    GitHub { github: GitHubRef },
+    Path { path: String },
+}
+
+#[derive(Deserialize, Serialize, Debug, Clone)]
+pub struct GitHubRef {
+    pub owner: String,
+    pub repo: String,
+    pub r#ref: String,
+    pub path: Option<String>,
 }
 
 /// Describes the contents and location of a Q# manifest file.
@@ -44,7 +64,7 @@ impl Manifest {
     /// name.
     pub fn load(
         manifest_path: Option<PathBuf>,
-    ) -> std::result::Result<Option<ManifestDescriptor>, Error> {
+    ) -> std::result::Result<Option<ManifestDescriptor>, StdFsError> {
         let dir = match manifest_path {
             Some(path) => path,
             None => current_dir()?,
@@ -56,7 +76,9 @@ impl Manifest {
     /// Returns [None] if no manifest named [`MANIFEST_FILE_NAME`] is found.
     /// Returns an error if a manifest is found, but is not parsable into the
     /// expected format.
-    pub fn load_from_path(path: PathBuf) -> std::result::Result<Option<ManifestDescriptor>, Error> {
+    pub fn load_from_path(
+        path: PathBuf,
+    ) -> std::result::Result<Option<ManifestDescriptor>, StdFsError> {
         // if the given path points to a file, change it to point to the parent folder.
         // This lets consumers pass in either the path directly to the manifest file, or the path
         // to the folder containing the manifest file.
diff --git a/compiler/qsc_project/src/project.rs b/compiler/qsc_project/src/project.rs
index a658b0a4..6ceee8e7 100644
--- a/compiler/qsc_project/src/project.rs
+++ b/compiler/qsc_project/src/project.rs
@@ -1,23 +1,62 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
-use crate::{Manifest, ManifestDescriptor};
+use crate::{manifest::GitHubRef, Manifest, ManifestDescriptor, PackageRef};
 use async_trait::async_trait;
 use futures::FutureExt;
+use miette::Diagnostic;
+use qsc_data_structures::language_features::LanguageFeatures;
+use qsc_linter::LintConfig;
+use rustc_hash::FxHashMap;
 use std::{
+    cell::RefCell,
     path::{Path, PathBuf},
     sync::Arc,
 };
+use thiserror::Error;
 
-/// Describes a Q# project
+/// Describes a Q# project with all its sources and dependencies resolved.
 #[derive(Debug)]
 pub struct Project {
     /// Friendly name, typically based on project directory name
+    /// Not guaranteed to be unique. Don't use as a key.
     pub name: Arc<str>,
-    /// Full path to the project's `qsharp.json` file
-    pub manifest_path: Arc<str>,
-    pub sources: Vec<(Arc<str>, Arc<str>)>,
-    pub manifest: crate::Manifest,
+    /// A path that represents the whole project.
+    /// Typically the `qsharp.json` path for projects, or the document path for single files.
+    pub path: Arc<str>,
+    /// The package graph, including all sources and per-package
+    /// configuration settings.
+    pub package_graph_sources: PackageGraphSources,
+    /// Lint configuration for the project, typically comes from the root `qsharp.json`.
+    pub lints: Vec<LintConfig>,
+    /// Any errors encountered while loading the project.
+    pub errors: Vec<Error>,
+}
+
+impl Project {
+    #[must_use]
+    /// Given a source file, creates a project that contains just that file and
+    /// default configuration options.
+    pub fn from_single_file(name: Arc<str>, contents: Arc<str>) -> Self {
+        let display_name = PathBuf::from(name.as_ref())
+            .file_name()
+            .map_or_else(|| name.clone(), |f| f.to_string_lossy().into());
+
+        Self {
+            package_graph_sources: PackageGraphSources {
+                root: PackageInfo {
+                    sources: vec![(name.clone(), contents)],
+                    language_features: LanguageFeatures::default(),
+                    dependencies: FxHashMap::default(),
+                },
+                packages: FxHashMap::default(),
+            },
+            path: name,
+            name: display_name,
+            lints: Vec::default(),
+            errors: Vec::default(),
+        }
+    }
 }
 
 /// This enum represents a filesystem object type. It is analogous to [`std::fs::FileType`].
@@ -49,6 +88,65 @@ pub trait DirEntry {
     }
 }
 
+/// Errors that can occur during project loading and dependency resolution.
+#[derive(Clone, Debug, Diagnostic, Error)]
+pub enum Error {
+    #[error("No `src` directory found for project")]
+    #[diagnostic(code("Qsc.Project.NoSrcDir"))]
+    NoSrcDir { path: String },
+
+    #[error("Failed to parse manifest: {error}")]
+    #[diagnostic(code("Qsc.Project.ManifestParse"))]
+    ManifestParse { path: String, error: String },
+
+    #[error("Failed to parse manifest for GitHub dependency {repo}/{owner} : {error}")]
+    #[diagnostic(code("Qsc.Project.GitHubManifestParse"))]
+    GitHubManifestParse {
+        path: String,
+        owner: String,
+        repo: String,
+        error: String,
+    },
+
+    #[error("Circular dependency detected between {0} and {1}")]
+    #[diagnostic(code("Qsc.Project.CircularDependency"))]
+    Circular(String, String),
+
+    #[error("GitHub dependency {0} contains a local dependency {1}, which is not supported")]
+    #[diagnostic(code("Qsc.Project.GitHubToLocal"))]
+    GitHubToLocal(String, String),
+
+    #[error("File system error: {about_path}: {error}")]
+    #[diagnostic(code("Qsc.Project.FileSystem"))]
+    FileSystem { about_path: String, error: String },
+
+    #[error("Error fetching from GitHub: {0}")]
+    #[diagnostic(code("Qsc.Project.GitHub"))]
+    GitHub(String),
+}
+
+impl Error {
+    /// Returns the document path that the error should be associated with when reporting.
+    #[must_use]
+    pub fn path(&self) -> Option<&String> {
+        match self {
+            Error::GitHubManifestParse { path, .. }
+            | Error::NoSrcDir { path }
+            | Error::ManifestParse { path, .. } => Some(path),
+            // Note we don't return the path for `FileSystem` errors,
+            // since for most errors such as "file not found", it's more meaningful
+            // to report the error for the manifest that was *referencing* the file,
+            // rather than for the file itself that provoked the I/O error.
+            Error::FileSystem { .. }
+            | Error::GitHubToLocal(_, _)
+            | Error::Circular(_, _)
+            | Error::GitHub(_) => None,
+        }
+    }
+}
+
+type ProjectResult<T> = Result<T, Error>;
+
 /// This trait is used to abstract filesystem logic with regards to Q# projects.
 /// A Q# project requires some multi-file structure, but that may not actually be
 /// an OS filesystem. It could be a virtual filesystem on vscode.dev, or perhaps a
@@ -68,12 +166,27 @@ pub trait FileSystemAsync {
     /// the path, i.e. replace '..', '.', and redundant separators.
     async fn resolve_path(&self, base: &Path, path: &Path) -> miette::Result<PathBuf>;
 
+    /// Given a path to a file hosted on GitHub, fetches it from GitHub and returns the contents.
+    async fn fetch_github(
+        &self,
+        owner: &str,
+        repo: &str,
+        r#ref: &str,
+        path: &str,
+    ) -> miette::Result<Arc<str>>;
+
     /// Given an initial path, fetch files matching <initial_path>/**/*.qs
     async fn collect_project_sources(
         &self,
         initial_path: &Path,
-    ) -> miette::Result<Vec<Self::Entry>> {
-        let listing = self.list_directory(initial_path).await?;
+    ) -> ProjectResult<Vec<Self::Entry>> {
+        let listing = self
+            .list_directory(initial_path)
+            .await
+            .map_err(|e| Error::FileSystem {
+                about_path: initial_path.to_string_lossy().to_string(),
+                error: e.to_string(),
+            })?;
         if let Some(src_dir) = listing.into_iter().find(|x| {
             let Ok(entry_type) = x.entry_type() else {
                 return false;
@@ -82,17 +195,23 @@ pub trait FileSystemAsync {
         }) {
             self.collect_project_sources_inner(&src_dir.path()).await
         } else {
-            Err(miette::ErrReport::msg(
-                "No `src` directory found for project.",
-            ))
+            Err(Error::NoSrcDir {
+                path: initial_path.to_string_lossy().to_string(),
+            })
         }
     }
 
     async fn collect_project_sources_inner(
         &self,
         initial_path: &Path,
-    ) -> miette::Result<Vec<Self::Entry>> {
-        let listing = self.list_directory(initial_path).await?;
+    ) -> ProjectResult<Vec<Self::Entry>> {
+        let listing = self
+            .list_directory(initial_path)
+            .await
+            .map_err(|e| Error::FileSystem {
+                about_path: initial_path.to_string_lossy().to_string(),
+                error: e.to_string(),
+            })?;
         let mut files = vec![];
         for item in filter_hidden_files(listing.into_iter()) {
             match item.entry_type() {
@@ -106,54 +225,321 @@ pub trait FileSystemAsync {
         Ok(files)
     }
 
-    /// Given a [ManifestDescriptor], load project sources.
-    async fn load_project(&self, manifest: &ManifestDescriptor) -> miette::Result<Project> {
-        let project_path = manifest.manifest_dir.clone();
-        let qs_files = self.collect_project_sources(&project_path).await?;
+    /// Given a directory, loads the project sources
+    /// and the sources for all its dependencies.
+    async fn load_project(
+        &self,
+        directory: &Path,
+        global_cache: Option<&RefCell<PackageCache>>,
+    ) -> Result<Project, Vec<Error>> {
+        let manifest = self
+            .parse_manifest_in_dir(directory)
+            .await
+            .map_err(|e| vec![e])?;
 
-        let qs_files = qs_files.into_iter().map(|file| file.path());
+        let root = self
+            .read_local_manifest_and_sources(directory)
+            .await
+            .map_err(|e| vec![e])?;
 
-        let mut sources = Vec::with_capacity(qs_files.len());
-        for path in qs_files {
-            sources.push(self.read_file(&path).await?);
-        }
+        let mut errors = vec![];
+        let mut packages = FxHashMap::default();
+        let mut stack = vec![];
+
+        let root_path = directory.to_string_lossy().to_string();
+        let root_ref = PackageRef::Path { path: root_path };
+
+        self.collect_deps(
+            key_for_package_ref(&root_ref),
+            &root,
+            global_cache.unwrap_or(&RefCell::new(FxHashMap::default())),
+            &mut stack,
+            &mut packages,
+            &mut errors,
+            &root_ref,
+        )
+        .await;
+
+        let name = directory
+            .file_name()
+            .map(|f| f.to_string_lossy().into())
+            .unwrap_or(format!("Q# project at {}", directory.display()))
+            .into();
 
         let manifest_path = self
-            .resolve_path(&manifest.manifest_dir, Path::new("qsharp.json"))
-            .await?;
+            .resolve_path(directory, Path::new("qsharp.json"))
+            .await
+            .map_err(|e| {
+                vec![Error::FileSystem {
+                    about_path: directory.to_string_lossy().to_string(),
+                    error: e.to_string(),
+                }]
+            })?
+            .to_string_lossy()
+            .into();
 
         Ok(Project {
-            name: manifest
-                .manifest_dir
-                .file_name()
-                .map(|f| f.to_string_lossy().into())
-                .unwrap_or(format!("Q# project at {}", manifest.manifest_dir.display()).into()),
-            manifest_path: manifest_path.to_string_lossy().into(),
-            manifest: manifest.manifest.clone(),
-            sources,
+            package_graph_sources: PackageGraphSources { root, packages },
+            lints: manifest.lints,
+            errors,
+            name,
+            path: manifest_path,
         })
     }
 
-    /// Given a directory path, parse the manifest and load the project sources.
-    async fn load_project_in_dir(&self, directory: &Path) -> miette::Result<Project> {
+    /// Given a directory, attemps to parse a `qsharp.json` in that directory
+    /// according to the manifest schema.
+    async fn parse_manifest_in_dir(&self, directory: &Path) -> ProjectResult<Manifest> {
+        let manifest_path = self
+            .resolve_path(directory, Path::new("qsharp.json"))
+            .await
+            .map_err(|e| Error::FileSystem {
+                about_path: directory.to_string_lossy().to_string(),
+                error: e.to_string(),
+            })?;
+        let (_, manifest_content) =
+            self.read_file(&manifest_path)
+                .await
+                .map_err(|e| Error::FileSystem {
+                    about_path: manifest_path.to_string_lossy().to_string(),
+                    error: e.to_string(),
+                })?;
+        let manifest = serde_json::from_str::<Manifest>(&manifest_content).map_err(|e| {
+            Error::ManifestParse {
+                path: manifest_path.to_string_lossy().to_string(),
+                error: e.to_string(),
+            }
+        })?;
+        Ok(manifest)
+    }
+
+    /// Load the sources for a single package at the given directory. Also load its
+    /// dependency information but don't recurse into dependencies yet.
+    async fn read_local_manifest_and_sources(
+        &self,
+        directory: &Path,
+    ) -> ProjectResult<PackageInfo> {
         let manifest = self.parse_manifest_in_dir(directory).await?;
 
-        self.load_project(&ManifestDescriptor {
+        let manifest = ManifestDescriptor {
             manifest_dir: directory.to_path_buf(),
             manifest,
+        };
+
+        let project_path = manifest.manifest_dir.clone();
+
+        // If the `files` field exists in the manifest, prefer that.
+        // Otherwise, collect all files in the project directory.
+        let qs_files: Vec<PathBuf> = if manifest.manifest.files.is_empty() {
+            let qs_files = self.collect_project_sources(&project_path).await?;
+            qs_files.into_iter().map(|file| file.path()).collect()
+        } else {
+            let mut v = vec![];
+            for file in manifest.manifest.files {
+                v.push(
+                    self.resolve_path(&project_path, Path::new(&file))
+                        .await
+                        .map_err(|e| Error::FileSystem {
+                            about_path: project_path.to_string_lossy().to_string(),
+                            error: e.to_string(),
+                        })?,
+                );
+            }
+            v
+        };
+
+        let mut sources = Vec::with_capacity(qs_files.len());
+        for path in qs_files {
+            sources.push(self.read_file(&path).await.map_err(|e| Error::FileSystem {
+                about_path: path.to_string_lossy().to_string(),
+                error: e.to_string(),
+            })?);
+        }
+
+        let mut dependencies = FxHashMap::default();
+
+        // For any local dependencies, convert relative paths to absolute,
+        // so that multiple references to the same package, from different packages,
+        // get merged correctly.
+        for (alias, mut dep) in manifest.manifest.dependencies {
+            if let PackageRef::Path { path: dep_path } = &mut dep {
+                *dep_path = self
+                    .resolve_path(&project_path, &PathBuf::from(dep_path.clone()))
+                    .await
+                    .map_err(|e| Error::FileSystem {
+                        about_path: project_path.to_string_lossy().to_string(),
+                        error: e.to_string(),
+                    })?
+                    .to_string_lossy()
+                    .into();
+            }
+            dependencies.insert(alias.into(), key_for_package_ref(&dep));
+        }
+
+        Ok(PackageInfo {
+            sources,
+            language_features: LanguageFeatures::from_iter(&manifest.manifest.language_features),
+            dependencies,
         })
-        .await
     }
 
-    async fn parse_manifest_in_dir(&self, directory: &Path) -> Result<Manifest, miette::Error> {
-        let manifest_path = self
-            .resolve_path(directory, Path::new("qsharp.json"))
-            .await?;
-        let (_, manifest_content) = self.read_file(&manifest_path).await?;
+    /// Load the sources for a single package at the given GitHub ref. Also load its
+    /// dependency information but don't recurse into dependencies yet.
+    async fn read_github_manifest_and_sources(
+        &self,
+        dep: &GitHubRef,
+    ) -> ProjectResult<PackageInfo> {
+        let path_trimmed_seps = dep
+            .path
+            .as_ref()
+            .map(|p| {
+                p.strip_suffix('/')
+                    .unwrap_or(p)
+                    .strip_prefix('/')
+                    .unwrap_or(p)
+            })
+            .unwrap_or_default();
+
+        let manifest_path = format!("{path_trimmed_seps}/qsharp.json",);
+        let manifest_content = self
+            .fetch_github(&dep.owner, &dep.repo, &dep.r#ref, &manifest_path)
+            .await
+            .map_err(|e| Error::GitHub(e.to_string()))?;
+
         let manifest = serde_json::from_str::<Manifest>(&manifest_content).map_err(|e| {
-            miette::ErrReport::msg(format!("Failed to parse `qsharp.json` file: {e}"))
+            Error::GitHubManifestParse {
+                path: format!(
+                    "qsharp-github-source:{}/{}/{}/{path_trimmed_seps}/qsharp.json",
+                    dep.owner, dep.repo, dep.r#ref
+                ),
+                owner: dep.owner.clone(),
+                repo: dep.repo.clone(),
+                error: e.to_string(),
+            }
         })?;
-        Ok(manifest)
+
+        // Look at the files field in the manifest to find the sources.
+        let mut sources = vec![];
+        for file in &manifest.files {
+            let path = format!("{path_trimmed_seps}/{file}");
+            let contents = self
+                .fetch_github(&dep.owner, &dep.repo, &dep.r#ref, &path)
+                .await
+                .map_err(|e| Error::GitHub(e.to_string()))?;
+
+            // Use the well-known URI scheme for the generated GitHub source paths.
+            // This will allow the editor to recognize these URIs as GitHub sources
+            // and open them using cached contents.
+            sources.push((
+                format!(
+                    "qsharp-github-source:{}/{}/{}{path}",
+                    dep.owner, dep.repo, dep.r#ref
+                )
+                .into(),
+                contents,
+            ));
+        }
+
+        Ok(PackageInfo {
+            sources,
+            language_features: LanguageFeatures::from_iter(&manifest.language_features),
+            dependencies: manifest
+                .dependencies
+                .into_iter()
+                .map(|(k, v)| (k.into(), key_for_package_ref(&v)))
+                .collect(),
+        })
+    }
+
+    /// Load the sources and dependency information for a single package,
+    /// using a previously cached version if available.
+    async fn read_manifest_and_sources(
+        &self,
+        global_cache: &RefCell<PackageCache>,
+        key: PackageKey,
+        this_pkg: &PackageRef,
+    ) -> ProjectResult<PackageInfo> {
+        match this_pkg {
+            PackageRef::GitHub { github } => {
+                {
+                    let cache = global_cache.borrow();
+                    if let Some(cached) = cache.get(&key) {
+                        return cached.clone();
+                    }
+                }
+
+                let result = self.read_github_manifest_and_sources(github).await;
+
+                let mut cache = global_cache.borrow_mut();
+                cache.insert(key, result.clone());
+
+                result
+            }
+            PackageRef::Path { path } => {
+                // Local dependencies are not cached at the moment, to make the multi-project
+                // editing experience as intuitive as possible. This may change if we start
+                // hitting perf issues, but careful consideration is needed into when to
+                // invalidate the cache.
+                self.read_local_manifest_and_sources(PathBuf::from(path.clone()).as_path())
+                    .await
+            }
+        }
+    }
+
+    /// Recursive method to load sources for all dependencies and their
+    /// dependencies, etc.
+    #[allow(clippy::too_many_arguments)]
+    async fn collect_deps(
+        &self,
+        key: Arc<str>,
+        pkg: &PackageInfo,
+        global_cache: &RefCell<PackageCache>,
+        stack: &mut Vec<PackageKey>,
+        packages: &mut FxHashMap<PackageKey, PackageInfo>,
+        errors: &mut Vec<Error>,
+        this_pkg: &PackageRef,
+    ) {
+        stack.push(key.clone());
+
+        for (alias, dep_key) in &pkg.dependencies {
+            if stack.contains(dep_key) {
+                errors.push(Error::Circular(key.to_string(), dep_key.to_string()));
+                continue;
+            }
+
+            let dependency = package_ref_from_key(dep_key);
+            if matches!(dependency, PackageRef::Path { .. })
+                && matches!(this_pkg, PackageRef::GitHub { .. })
+            {
+                errors.push(Error::GitHubToLocal(key.to_string(), alias.to_string()));
+                continue;
+            }
+
+            let dep_result = self
+                .read_manifest_and_sources(global_cache, dep_key.clone(), &dependency)
+                .await;
+
+            match dep_result {
+                Ok(pkg) => {
+                    self.collect_deps(
+                        dep_key.clone(),
+                        &pkg,
+                        global_cache,
+                        stack,
+                        packages,
+                        errors,
+                        &dependency,
+                    )
+                    .await;
+                    packages.insert(dep_key.clone(), pkg);
+                }
+                Err(e) => {
+                    errors.push(e);
+                }
+            };
+        }
+
+        stack.pop();
     }
 }
 
@@ -164,6 +550,65 @@ fn filter_hidden_files<Entry: DirEntry>(
     listing.filter(|x| !x.entry_name().starts_with('.'))
 }
 
+/// We're using JSON to generate a key for the packages,
+/// but something more readable would also be okay as long as we can
+/// guarantee uniqueness.
+#[must_use]
+pub fn key_for_package_ref(dep: &PackageRef) -> PackageKey {
+    serde_json::to_string(dep)
+        .expect("dependency should be serializable")
+        .into()
+}
+
+#[must_use]
+pub fn package_ref_from_key(key: &PackageKey) -> PackageRef {
+    serde_json::from_str(key).expect("dependency should be deserializable")
+}
+
+/// A `PackageKey` is a global unique identifier for a package, and it's
+/// simply generated from the dependency information in the manifest
+/// (local or GitHub path).
+type PackageKey = Arc<str>;
+
+/// A `PackageAlias` is the name that a package uses to refer to one of its
+/// dependencies. The same package can live under different aliases in the
+/// same package graph.
+type PackageAlias = Arc<str>;
+
+/// Long-lived cache that can optionally be used for loading packages.
+pub type PackageCache = FxHashMap<PackageKey, ProjectResult<PackageInfo>>;
+
+type Sources = Vec<(Arc<str>, Arc<str>)>;
+
+#[derive(Clone, Debug)]
+pub struct PackageInfo {
+    pub sources: Sources,
+    pub language_features: LanguageFeatures,
+    pub dependencies: FxHashMap<PackageAlias, PackageKey>,
+}
+
+#[derive(Clone, Debug)]
+pub struct PackageGraphSources {
+    pub root: PackageInfo,
+    pub packages: FxHashMap<PackageKey, PackageInfo>,
+}
+
+impl PackageGraphSources {
+    /// Temporary implementation which just concatenates all sources.
+    /// This will be replaced soon to build and return all the dependencies
+    /// along with the root package sources.
+    /// TODO(packages): delete this method and use the package graph instead
+    #[must_use]
+    pub fn into_sources_temporary(self) -> (Sources, LanguageFeatures) {
+        let mut sources = self.root.sources;
+        for mut pkg in self.packages.into_values() {
+            sources.append(&mut pkg.sources);
+        }
+
+        (sources, self.root.language_features)
+    }
+}
+
 /// This trait is used to abstract filesystem logic with regards to Q# projects.
 /// A Q# project requires some multi-file structure, but that may not actually be
 /// an OS filesystem. It could be a virtual filesystem on vscode.dev, or perhaps a
@@ -179,8 +624,19 @@ pub trait FileSystem {
 
     fn resolve_path(&self, base: &Path, path: &Path) -> miette::Result<PathBuf>;
 
-    /// Given a [`ManifestDescriptor`], load project sources.
-    fn load_project(&self, manifest: &ManifestDescriptor) -> miette::Result<Project> {
+    fn fetch_github(
+        &self,
+        owner: &str,
+        repo: &str,
+        r#ref: &str,
+        path: &str,
+    ) -> miette::Result<Arc<str>>;
+
+    fn load_project(
+        &self,
+        directory: &Path,
+        global_cache: Option<&RefCell<PackageCache>>,
+    ) -> Result<Project, Vec<Error>> {
         // Rather than rewriting all the async code in the project loader,
         // we call the async implementation here, doing some tricks to make it
         // run synchronously.
@@ -192,7 +648,8 @@ pub trait FileSystem {
         // because we just passed in our synchronous FS functions to the project loader.
         // Proceed with caution if you make the `FileSystemAsync` implementation any
         // more complex.
-        FutureExt::now_or_never(fs.load_project(manifest)).expect("load_project should never await")
+        FutureExt::now_or_never(fs.load_project(directory, global_cache))
+            .expect("load_project should never await")
     }
 }
 
@@ -215,10 +672,22 @@ where
     async fn read_file(&self, path: &Path) -> miette::Result<(Arc<str>, Arc<str>)> {
         self.fs.read_file(path)
     }
+
     async fn list_directory(&self, path: &Path) -> miette::Result<Vec<Self::Entry>> {
         self.fs.list_directory(path)
     }
+
     async fn resolve_path(&self, base: &Path, path: &Path) -> miette::Result<PathBuf> {
         self.fs.resolve_path(base, path)
     }
+
+    async fn fetch_github(
+        &self,
+        owner: &str,
+        repo: &str,
+        r#ref: &str,
+        path: &str,
+    ) -> miette::Result<Arc<str>> {
+        self.fs.fetch_github(owner, repo, r#ref, path)
+    }
 }
diff --git a/language_service/Cargo.toml b/language_service/Cargo.toml
index bb83478d..e602cd3d 100644
--- a/language_service/Cargo.toml
+++ b/language_service/Cargo.toml
@@ -25,6 +25,7 @@ qsc = { path = "../compiler/qsc" }
 qsc_linter = { path = "../compiler/qsc_linter" }
 qsc_project = { path = "../compiler/qsc_project" }
 rustc-hash = { workspace = true }
+thiserror = { workspace = true }
 
 [lints]
 workspace = true
diff --git a/language_service/src/code_action.rs b/language_service/src/code_action.rs
index 0452fc6c..7eaf61d3 100644
--- a/language_service/src/code_action.rs
+++ b/language_service/src/code_action.rs
@@ -35,7 +35,7 @@ fn quick_fixes(
 
     // get relevant diagnostics
     let diagnostics = compilation
-        .errors
+        .compile_errors
         .iter()
         .filter(|error| is_error_relevant(error, span));
 
diff --git a/language_service/src/compilation.rs b/language_service/src/compilation.rs
index 85972116..bbcecd69 100644
--- a/language_service/src/compilation.rs
+++ b/language_service/src/compilation.rs
@@ -3,14 +3,13 @@
 
 use log::trace;
 use qsc::{
-    ast,
-    compile::{self, Error},
+    ast, compile,
     display::Lookup,
     error::WithSource,
     hir::{self, PackageId},
     incremental::Compiler,
     line_column::{Encoding, Position, Range},
-    resolve,
+    project, resolve,
     target::Profile,
     CompileUnit, LanguageFeatures, PackageStore, PackageType, PassContext, SourceMap, Span,
 };
@@ -26,7 +25,8 @@ pub(crate) struct Compilation {
     /// The `PackageId` of the user package. User code
     /// is non-library code, i.e. all code except the std and core libs.
     pub user_package_id: PackageId,
-    pub errors: Vec<Error>,
+    pub project_errors: Vec<project::Error>,
+    pub compile_errors: Vec<compile::Error>,
     pub kind: CompilationKind,
 }
 
@@ -50,6 +50,7 @@ impl Compilation {
         target_profile: Profile,
         language_features: LanguageFeatures,
         lints_config: &[LintConfig],
+        project_errors: Vec<project::Error>,
     ) -> Self {
         if sources.len() == 1 {
             trace!("compiling single-file document {}", sources[0].0);
@@ -63,7 +64,7 @@ impl Compilation {
         let std_package_id =
             package_store.insert(compile::std(&package_store, target_profile.into()));
 
-        let (unit, mut errors) = compile::compile(
+        let (unit, mut compile_errors) = compile::compile(
             &package_store,
             &[std_package_id],
             source_map,
@@ -78,20 +79,21 @@ impl Compilation {
             .expect("expected to find user package");
 
         run_fir_passes(
-            &mut errors,
+            &mut compile_errors,
             target_profile,
             &package_store,
             package_id,
             unit,
         );
 
-        run_linter_passes(&mut errors, &package_store, unit, lints_config);
+        run_linter_passes(&mut compile_errors, &package_store, unit, lints_config);
 
         Self {
             package_store,
             user_package_id: package_id,
-            errors,
+            compile_errors,
             kind: CompilationKind::OpenProject,
+            project_errors,
         }
     }
 
@@ -146,8 +148,9 @@ impl Compilation {
         Self {
             package_store,
             user_package_id: package_id,
-            errors,
+            compile_errors: errors,
             kind: CompilationKind::Notebook,
+            project_errors: Vec::new(),
         }
     }
 
@@ -248,14 +251,16 @@ impl Compilation {
                 target_profile,
                 language_features,
                 lints_config,
+                Vec::new(), // project errors will stay the same
             ),
             CompilationKind::Notebook => {
                 Self::new_notebook(sources, target_profile, language_features, lints_config)
             }
         };
+
         self.package_store = new.package_store;
         self.user_package_id = new.user_package_id;
-        self.errors = new.errors;
+        self.compile_errors = new.compile_errors;
     }
 }
 
diff --git a/language_service/src/protocol.rs b/language_service/src/protocol.rs
index d66d5fae..328b705a 100644
--- a/language_service/src/protocol.rs
+++ b/language_service/src/protocol.rs
@@ -1,11 +1,11 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
+use miette::Diagnostic;
 use qsc::line_column::Range;
-use qsc::{
-    compile::Error, linter::LintConfig, project::Manifest, target::Profile, LanguageFeatures,
-    PackageType,
-};
+use qsc::{compile, project};
+use qsc::{linter::LintConfig, project::Manifest, target::Profile, LanguageFeatures, PackageType};
+use thiserror::Error;
 
 /// A change to the workspace configuration
 #[derive(Clone, Debug, Default)]
@@ -16,11 +16,21 @@ pub struct WorkspaceConfigurationUpdate {
     pub lints_config: Option<Vec<LintConfig>>,
 }
 
+#[derive(Clone, Debug, Diagnostic, Error)]
+pub enum ErrorKind {
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    Compile(#[from] compile::Error),
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    Project(#[from] project::Error),
+}
+
 #[derive(Debug)]
 pub struct DiagnosticUpdate {
     pub uri: String,
     pub version: Option<u32>,
-    pub errors: Vec<Error>,
+    pub errors: Vec<ErrorKind>,
 }
 
 #[derive(Debug)]
diff --git a/language_service/src/qsc_utils.rs b/language_service/src/qsc_utils.rs
index e45fb530..32cb0137 100644
--- a/language_service/src/qsc_utils.rs
+++ b/language_service/src/qsc_utils.rs
@@ -34,7 +34,6 @@ pub(crate) fn into_location(
         span,
         package_id,
         &compilation.package_store,
-        compilation.user_package_id,
         position_encoding,
     )
 }
diff --git a/language_service/src/state.rs b/language_service/src/state.rs
index 15dae811..aeeaee90 100644
--- a/language_service/src/state.rs
+++ b/language_service/src/state.rs
@@ -6,12 +6,13 @@ mod tests;
 
 use super::compilation::Compilation;
 use super::protocol::{DiagnosticUpdate, NotebookMetadata};
-use crate::protocol::WorkspaceConfigurationUpdate;
-use log::{error, trace};
+use crate::protocol::{ErrorKind, WorkspaceConfigurationUpdate};
+use log::{debug, trace};
 use miette::Diagnostic;
-use qsc::{compile::Error, target::Profile, LanguageFeatures, PackageType};
+use qsc::{compile, project};
+use qsc::{target::Profile, LanguageFeatures, PackageType};
 use qsc_linter::LintConfig;
-use qsc_project::{FileSystemAsync, JSProjectHost, Manifest, Project};
+use qsc_project::{FileSystemAsync, JSProjectHost, PackageCache, Project};
 use rustc_hash::{FxHashMap, FxHashSet};
 use std::path::PathBuf;
 use std::{cell::RefCell, fmt::Debug, mem::take, rc::Rc, sync::Arc};
@@ -101,6 +102,7 @@ pub(super) struct CompilationStateUpdater<'a> {
     /// Callback which will receive diagnostics (compilation errors)
     /// whenever a (re-)compilation occurs.
     diagnostics_receiver: Box<dyn Fn(DiagnosticUpdate) + 'a>,
+    cache: RefCell<PackageCache>,
     /// Functions to interact with the host filesystem for project system operations.
     project_host: Box<dyn JSProjectHost>,
 }
@@ -116,6 +118,7 @@ impl<'a> CompilationStateUpdater<'a> {
             configuration: Configuration::default(),
             documents_with_errors: FxHashSet::default(),
             diagnostics_receiver: Box::new(diagnostics_receiver),
+            cache: RefCell::default(),
             project_host: Box::new(project_host),
         }
     }
@@ -136,27 +139,16 @@ impl<'a> CompilationStateUpdater<'a> {
         let doc_uri: Arc<str> = Arc::from(uri);
         let text: Arc<str> = Arc::from(text);
 
-        let project = self.load_manifest(&doc_uri).await;
-
-        let (compilation_uri, sources, language_features, lints_config) =
-            if let Some(project) = project {
-                (
-                    project.manifest_path,
-                    project.sources,
-                    Some(LanguageFeatures::from_iter(
-                        project.manifest.language_features,
-                    )),
-                    project.manifest.lints,
-                )
-            } else {
-                // If we are in single file mode, use the file's path as the compilation identifier.
-                (
-                    doc_uri.clone(),
-                    vec![(doc_uri.clone(), text.clone())],
-                    None,
-                    Vec::default(),
-                )
-            };
+        let project = match self.load_manifest(&doc_uri).await {
+            Ok(Some(p)) => p,
+            Ok(None) => Project::from_single_file(doc_uri.clone(), text.clone()),
+            Err(errors) => Project {
+                errors,
+                ..Project::from_single_file(doc_uri.clone(), text.clone())
+            },
+        };
+
+        let compilation_uri = project.path.clone();
 
         let prev_compilation_uri = self.with_state_mut(|state| {
             state
@@ -180,12 +172,7 @@ impl<'a> CompilationStateUpdater<'a> {
             }
         }
 
-        self.insert_buffer_aware_compilation(
-            sources,
-            &compilation_uri,
-            language_features,
-            lints_config,
-        );
+        self.insert_buffer_aware_compilation(project);
 
         self.publish_diagnostics();
     }
@@ -193,49 +180,57 @@ impl<'a> CompilationStateUpdater<'a> {
     /// Attempts to resolve a manifest for the given document uri.
     /// If a manifest is found, returns the manifest uri along
     /// with the sources for the project
-    async fn load_manifest(&self, doc_uri: &Arc<str>) -> Option<Project> {
+    async fn load_manifest(
+        &self,
+        doc_uri: &Arc<str>,
+    ) -> Result<Option<Project>, Vec<project::Error>> {
         let dir = self.project_host.find_manifest_directory(doc_uri).await;
 
         if let Some(dir) = dir {
             let dir = PathBuf::from(dir.to_string());
-            let res = self.project_host.load_project_in_dir(&dir).await;
+            let res = self
+                .project_host
+                .load_project(&dir, Some(&self.cache))
+                .await;
             match res {
-                Ok(proj) => Some(proj),
+                Ok(proj) => Ok(Some(proj)),
                 Err(e) => {
-                    error!("failed to load manifest: {e:?}, defaulting to single-file mode");
-                    None
+                    debug!("failed to load manifest: {e:?}, defaulting to single-file mode");
+                    Err(e)
                 }
             }
         } else {
             trace!("Running in single file mode");
-            None
+            Ok(None)
         }
     }
 
-    /// This function takes a vector of sources and creates a compilation out of them.
+    /// This function takes a `LoadedProject` and creates a compilation out of them.
     /// It checks currently open documents and uses those buffers instead of any
     /// sources provided in the vector, effectively prioritizing open document contents
     /// over fs contents.
-    fn insert_buffer_aware_compilation(
-        &mut self,
-        mut sources: Vec<(Arc<str>, Arc<str>)>,
-        compilation_uri: &Arc<str>,
-        language_features: Option<LanguageFeatures>,
-        lints_config: Vec<LintConfig>,
-    ) {
+    fn insert_buffer_aware_compilation(&mut self, mut loaded_project: Project) {
         self.with_state_mut(|state| {
             // replace source with one from memory if it exists
             // this is what prioritizes open buffers over what exists on the fs for a
             // given document
-            for (ref l_uri, ref mut source) in &mut sources {
+            for (ref l_uri, ref mut source) in
+                &mut loaded_project.package_graph_sources.root.sources
+            {
                 if let Some(doc) = state.open_documents.get(l_uri) {
                     trace!("{l_uri} is open, using source from open document");
                     *source = doc.latest_str_content.clone();
                 }
             }
 
+            let (sources, language_features) = loaded_project
+                .package_graph_sources
+                .into_sources_temporary();
+            let compilation_uri = loaded_project.path;
+            let lints_config = loaded_project.lints;
+
             let compilation_overrides = PartialConfiguration {
-                language_features,
+                language_features: Some(language_features),
                 lints_config,
                 ..PartialConfiguration::default()
             };
@@ -248,6 +243,7 @@ impl<'a> CompilationStateUpdater<'a> {
                 configuration.target_profile,
                 configuration.language_features,
                 &configuration.lints_config,
+                loaded_project.errors,
             );
 
             state.compilations.insert(
@@ -266,24 +262,8 @@ impl<'a> CompilationStateUpdater<'a> {
             // If the project is still open, update it so that it
             // uses the disk contents instead of the open buffer contents
             // for this document
-            if let Some(Project {
-                sources,
-                manifest_path: compilation_uri,
-                manifest:
-                    Manifest {
-                        language_features,
-                        lints: lints_config,
-                        ..
-                    },
-                ..
-            }) = project
-            {
-                self.insert_buffer_aware_compilation(
-                    sources,
-                    &compilation_uri,
-                    Some(LanguageFeatures::from_iter(language_features)),
-                    lints_config,
-                );
+            if let Ok(Some(project)) = project {
+                self.insert_buffer_aware_compilation(project);
             }
         }
 
@@ -405,7 +385,12 @@ impl<'a> CompilationStateUpdater<'a> {
         self.with_state(|state| {
             for (compilation_uri, compilation) in &state.compilations {
                 trace!("publishing diagnostics for {compilation_uri}");
-                for (uri, errors) in map_errors_to_docs(compilation_uri, &compilation.0.errors) {
+
+                for (uri, errors) in map_errors_to_docs(
+                    compilation_uri,
+                    &compilation.0.compile_errors,
+                    &compilation.0.project_errors,
+                ) {
                     if !docs_with_errors.insert(uri.clone()) {
                         // We already published diagnostics for this document for
                         // a different compilation.
@@ -428,7 +413,12 @@ impl<'a> CompilationStateUpdater<'a> {
         self.documents_with_errors = docs_with_errors;
     }
 
-    fn publish_diagnostics_for_doc(&self, state: &CompilationState, uri: &str, errors: Vec<Error>) {
+    fn publish_diagnostics_for_doc(
+        &self,
+        state: &CompilationState,
+        uri: &str,
+        errors: Vec<ErrorKind>,
+    ) {
         let version = state.open_documents.get(uri).map(|d| d.version);
         trace!(
             "publishing diagnostics for {uri} {version:?}): {} errors",
@@ -539,11 +529,12 @@ impl CompilationState {
 
 fn map_errors_to_docs(
     compilation_uri: &Arc<str>,
-    errors: &Vec<Error>,
-) -> FxHashMap<Arc<str>, Vec<Error>> {
+    compile_errors: &Vec<compile::Error>,
+    project_errors: &Vec<project::Error>,
+) -> FxHashMap<Arc<str>, Vec<ErrorKind>> {
     let mut map = FxHashMap::default();
 
-    for err in errors {
+    for err in compile_errors {
         // Use the compilation_uri as a location for span-less errors
         let doc = err
             .labels()
@@ -557,7 +548,17 @@ fn map_errors_to_docs(
 
         map.entry(doc.clone())
             .or_insert_with(Vec::new)
-            .push(err.clone());
+            .push(ErrorKind::from(err.clone()));
+    }
+
+    for err in project_errors {
+        let doc = err
+            .path()
+            .map_or(compilation_uri.clone(), |path| path.to_string().into());
+
+        map.entry(doc.clone())
+            .or_insert_with(Vec::new)
+            .push(ErrorKind::from(err.clone()));
     }
 
     map
diff --git a/library/src/lib.rs b/library/src/lib.rs
index e7ce12ac..2f516e13 100644
--- a/library/src/lib.rs
+++ b/library/src/lib.rs
@@ -8,38 +8,80 @@ mod tests;
 // files of the same name. This comes in during debugging when we need
 // to load a core/std file from the library.
 pub const CORE_LIB: &[(&str, &str)] = &[
-    ("core/core.qs", include_str!("../core/core.qs")),
-    ("core/qir.qs", include_str!("../core/qir.qs")),
+    (
+        "qsharp-library-source:core/core.qs",
+        include_str!("../core/core.qs"),
+    ),
+    (
+        "qsharp-library-source:core/qir.qs",
+        include_str!("../core/qir.qs"),
+    ),
 ];
 
 pub const STD_LIB: &[(&str, &str)] = &[
-    ("arrays.qs", include_str!("../std/arrays.qs")),
-    ("canon.qs", include_str!("../std/canon.qs")),
-    ("convert.qs", include_str!("../std/convert.qs")),
-    ("core.qs", include_str!("../std/core.qs")),
-    ("diagnostics.qs", include_str!("../std/diagnostics.qs")),
-    ("internal.qs", include_str!("../std/internal.qs")),
-    ("intrinsic.qs", include_str!("../std/intrinsic.qs")),
-    ("logical.qs", include_str!("../std/logical.qs")),
-    ("math.qs", include_str!("../std/math.qs")),
-    ("measurement.qs", include_str!("../std/measurement.qs")),
-    ("qir.qs", include_str!("../std/qir.qs")),
-    ("random.qs", include_str!("../std/random.qs")),
-    ("re.qs", include_str!("../std/re.qs")),
-    (
-        "unstable_arithmetic.qs",
+    (
+        "qsharp-library-source:arrays.qs",
+        include_str!("../std/arrays.qs"),
+    ),
+    (
+        "qsharp-library-source:canon.qs",
+        include_str!("../std/canon.qs"),
+    ),
+    (
+        "qsharp-library-source:convert.qs",
+        include_str!("../std/convert.qs"),
+    ),
+    (
+        "qsharp-library-source:core.qs",
+        include_str!("../std/core.qs"),
+    ),
+    (
+        "qsharp-library-source:diagnostics.qs",
+        include_str!("../std/diagnostics.qs"),
+    ),
+    (
+        "qsharp-library-source:internal.qs",
+        include_str!("../std/internal.qs"),
+    ),
+    (
+        "qsharp-library-source:intrinsic.qs",
+        include_str!("../std/intrinsic.qs"),
+    ),
+    (
+        "qsharp-library-source:logical.qs",
+        include_str!("../std/logical.qs"),
+    ),
+    (
+        "qsharp-library-source:math.qs",
+        include_str!("../std/math.qs"),
+    ),
+    (
+        "qsharp-library-source:measurement.qs",
+        include_str!("../std/measurement.qs"),
+    ),
+    (
+        "qsharp-library-source:qir.qs",
+        include_str!("../std/qir.qs"),
+    ),
+    (
+        "qsharp-library-source:random.qs",
+        include_str!("../std/random.qs"),
+    ),
+    ("qsharp-library-source:re.qs", include_str!("../std/re.qs")),
+    (
+        "qsharp-library-source:unstable_arithmetic.qs",
         include_str!("../std/unstable_arithmetic.qs"),
     ),
     (
-        "unstable_arithmetic_internal.qs",
+        "qsharp-library-source:unstable_arithmetic_internal.qs",
         include_str!("../std/unstable_arithmetic_internal.qs"),
     ),
     (
-        "unstable_state_preparation.qs",
+        "qsharp-library-source:unstable_state_preparation.qs",
         include_str!("../std/unstable_state_preparation.qs"),
     ),
     (
-        "unstable_table_lookup.qs",
+        "qsharp-library-source:unstable_table_lookup.qs",
         include_str!("../std/unstable_table_lookup.qs"),
     ),
 ];
diff --git a/npm/qsharp/src/browser.ts b/npm/qsharp/src/browser.ts
index 136c31d5..258d8017 100644
--- a/npm/qsharp/src/browser.ts
+++ b/npm/qsharp/src/browser.ts
@@ -23,12 +23,13 @@ import {
   ILanguageServiceWorker,
   QSharpLanguageService,
   languageServiceProtocol,
+  qsharpGithubUriScheme,
   qsharpLibraryUriScheme,
 } from "./language-service/language-service.js";
 import { LogLevel, log } from "./log.js";
 import { createProxy } from "./workers/browser.js";
 
-export { qsharpLibraryUriScheme };
+export { qsharpGithubUriScheme, qsharpLibraryUriScheme };
 
 // Create once. A module is stateless and can be efficiently passed to WebWorkers.
 let wasmModule: WebAssembly.Module | null = null;
@@ -172,7 +173,7 @@ export type {
 export { type Dump, type ShotResult } from "./compiler/common.js";
 export { type CompilerState, type ProgramConfig } from "./compiler/compiler.js";
 export { QscEventTarget } from "./compiler/events.js";
-export { type LanguageServiceEvent } from "./language-service/language-service.js";
+export type { LanguageServiceEvent } from "./language-service/language-service.js";
 export { default as samples } from "./samples.generated.js";
 export { log, type LogLevel, type TargetProfile };
 export type {
diff --git a/npm/qsharp/src/compiler/compiler.ts b/npm/qsharp/src/compiler/compiler.ts
index d6e9aa80..a66e8c06 100644
--- a/npm/qsharp/src/compiler/compiler.ts
+++ b/npm/qsharp/src/compiler/compiler.ts
@@ -5,6 +5,7 @@ import { type Circuit as CircuitData } from "@microsoft/quantum-viz.js/lib/circu
 import {
   IDocFile,
   IOperationInfo,
+  IPackageGraphSources,
   IProgramConfig as wasmIProgramConfig,
   TargetProfile,
   type VSDiagnostic,
@@ -74,11 +75,18 @@ export interface ICompiler {
  * Type definition for the configuration of a program.
  * If adding new properties, make them optional to maintain backward compatibility.
  */
-export type ProgramConfig = {
-  /** An array of source objects, each containing a name and contents. */
-  sources: [string, string][];
-  /** An array of language features to be opted in to in this compilation. */
-  languageFeatures?: string[];
+export type ProgramConfig = (
+  | {
+      /** An array of source objects, each containing a name and contents. */
+      sources: [string, string][];
+      /** An array of language features to be opted in to in this compilation. */
+      languageFeatures?: string[];
+    }
+  | {
+      /** Sources from all resolved dependencies, along with their languageFeatures configuration */
+      packageGraphSources: IPackageGraphSources;
+    }
+) & {
   /** Target compilation profile. */
   profile?: TargetProfile;
 };
@@ -109,6 +117,7 @@ export class Compiler implements ICompiler {
         readFile: async () => null,
         listDirectory: async () => [],
         resolvePath: async () => null,
+        fetchGithub: async () => "",
         findManifestDirectory: async () => null,
       },
     );
@@ -220,11 +229,25 @@ export function toWasmProgramConfig(
   program: ProgramConfig,
   defaultProfile: TargetProfile,
 ): Required<wasmIProgramConfig> {
-  return {
-    sources: program.sources,
-    languageFeatures: program.languageFeatures || [],
-    profile: program.profile || defaultProfile,
-  };
+  let packageGraphSources: IPackageGraphSources;
+
+  if ("sources" in program) {
+    // The simpler type is used, where there are no dependencies and only a list
+    // of sources is passed in.
+    packageGraphSources = {
+      root: {
+        sources: program.sources,
+        languageFeatures: program.languageFeatures || [],
+        dependencies: {},
+      },
+      packages: {},
+    };
+  } else {
+    // A full package graph is passed in.
+    packageGraphSources = program.packageGraphSources;
+  }
+
+  return { packageGraphSources, profile: program.profile || defaultProfile };
 }
 
 export function onCompilerEvent(msg: string, eventTarget: IQscEventTarget) {
diff --git a/npm/qsharp/src/language-service/language-service.ts b/npm/qsharp/src/language-service/language-service.ts
index 1e6ff7ce..3b6f6967 100644
--- a/npm/qsharp/src/language-service/language-service.ts
+++ b/npm/qsharp/src/language-service/language-service.ts
@@ -101,6 +101,7 @@ export interface ILanguageService {
 }
 
 export const qsharpLibraryUriScheme = "qsharp-library-source";
+export const qsharpGithubUriScheme = "qsharp-github-source";
 
 export type ILanguageServiceWorker = ILanguageService & IServiceProxy;
 
@@ -117,6 +118,7 @@ export class QSharpLanguageService implements ILanguageService {
       readFile: async () => null,
       listDirectory: async () => [],
       resolvePath: async () => null,
+      fetchGithub: async () => "",
       findManifestDirectory: async () => null,
     },
   ) {
diff --git a/pip/qsharp/_http.py b/pip/qsharp/_http.py
new file mode 100644
index 00000000..eb984e31
--- /dev/null
+++ b/pip/qsharp/_http.py
@@ -0,0 +1,11 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+
+def fetch_github(owner: str, repo: str, ref: str, path: str) -> str:
+    import urllib
+
+    path_no_leading_slash = path[1:] if path.startswith("/") else path
+    url = f"https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path_no_leading_slash}"
+    return urllib.request.urlopen(url).read().decode("utf-8")
+
diff --git a/pip/qsharp/_native.pyi b/pip/qsharp/_native.pyi
index a38da645..1deabfc4 100644
--- a/pip/qsharp/_native.pyi
+++ b/pip/qsharp/_native.pyi
@@ -42,7 +42,7 @@ class Interpreter:
         self,
         target_profile: TargetProfile,
         language_features: Optional[List[str]],
-        manifest_descriptor: Optional[Dict[str, str]],
+        project_root: Optional[str],
         read_file: Callable[[str], str],
         list_directory: Callable[[str], str],
         resolve_path: Callable[[str, str], str],
@@ -51,7 +51,7 @@ class Interpreter:
         Initializes the Q# interpreter.
 
         :param target_profile: The target profile to use for the interpreter.
-        :param manifest_descriptor: A dictionary that represents the manifest descriptor
+        :param project_root: A directory that contains a `qsharp.json` manifest.
         :param read_file: A function that reads a file from the file system.
         :param list_directory: A function that lists the contents of a directory.
         :param resolve_path: A function that joins path segments and normalizes the resulting path.
diff --git a/pip/qsharp/_qsharp.py b/pip/qsharp/_qsharp.py
index 14b70332..c6b90b0c 100644
--- a/pip/qsharp/_qsharp.py
+++ b/pip/qsharp/_qsharp.py
@@ -76,6 +76,7 @@ def init(
         It must contain a qsharp.json project manifest.
     """
     from ._fs import read_file, list_directory, exists, join, resolve
+    from ._http import fetch_github
 
     global _interpreter
 
@@ -91,7 +92,6 @@ def init(
             )
 
     manifest_contents = None
-    manifest_descriptor = None
     if project_root is not None:
         qsharp_json = join(project_root, "qsharp.json")
         if not exists(qsharp_json):
@@ -99,12 +99,8 @@ def init(
                 f"{qsharp_json} not found. qsharp.json should exist at the project root and be a valid JSON file."
             )
 
-        manifest_descriptor = {}
-        manifest_descriptor["manifest_dir"] = project_root
-
         try:
             (_, manifest_contents) = read_file(qsharp_json)
-            manifest_descriptor["manifest"] = manifest_contents
         except Exception as e:
             raise QSharpError(
                 f"Error reading {qsharp_json}. qsharp.json should exist at the project root and be a valid JSON file."
@@ -113,10 +109,11 @@ def init(
     _interpreter = Interpreter(
         target_profile,
         language_features,
-        manifest_descriptor,
+        project_root,
         read_file,
         list_directory,
         resolve,
+        fetch_github,
     )
 
     # Return the configuration information to provide a hint to the
diff --git a/pip/src/fs.rs b/pip/src/fs.rs
index bf2c50ec..b3e4936c 100644
--- a/pip/src/fs.rs
+++ b/pip/src/fs.rs
@@ -19,6 +19,7 @@ pub(crate) fn file_system(
     read_file: PyObject,
     list_directory: PyObject,
     resolve_path: PyObject,
+    fetch_github: PyObject,
 ) -> impl FileSystem + '_ {
     Py {
         py,
@@ -26,6 +27,7 @@ pub(crate) fn file_system(
             read_file,
             list_directory,
             resolve_path,
+            fetch_github,
         },
     }
 }
@@ -34,6 +36,7 @@ struct FsHooks {
     read_file: PyObject,
     list_directory: PyObject,
     resolve_path: PyObject,
+    fetch_github: PyObject,
 }
 
 #[derive(Debug)]
@@ -80,6 +83,24 @@ impl FileSystem for Py<'_> {
         resolve_path(self.py, &self.fs_hooks.resolve_path, base, path)
             .map_err(|e| diagnostic_from(self.py, &e))
     }
+
+    fn fetch_github(
+        &self,
+        owner: &str,
+        repo: &str,
+        r#ref: &str,
+        path: &str,
+    ) -> miette::Result<Arc<str>> {
+        fetch_github(
+            self.py,
+            &self.fs_hooks.fetch_github,
+            owner,
+            repo,
+            r#ref,
+            path,
+        )
+        .map_err(|e| diagnostic_from(self.py, &e))
+    }
 }
 
 fn read_file(py: Python, read_file: &PyObject, path: &Path) -> PyResult<(Arc<str>, Arc<str>)> {
@@ -133,6 +154,23 @@ fn resolve_path(
     ))
 }
 
+fn fetch_github(
+    py: Python,
+    fetch_github: &PyObject,
+    owner: &str,
+    repo: &str,
+    r#ref: &str,
+    path: &str,
+) -> PyResult<Arc<str>> {
+    let fetch_github_result =
+        fetch_github.call1(py, PyTuple::new(py, [owner, repo, r#ref, path]))?;
+
+    Ok(fetch_github_result
+        .downcast::<PyString>(py)?
+        .to_string()
+        .into())
+}
+
 fn get_tuple_string(tuple: &PyTuple, index: usize) -> PyResult<Arc<str>> {
     Ok(tuple
         .get_item(index)?
diff --git a/pip/src/interpreter.rs b/pip/src/interpreter.rs
index 8d5f791f..32f9a6aa 100644
--- a/pip/src/interpreter.rs
+++ b/pip/src/interpreter.rs
@@ -5,7 +5,7 @@ use crate::{
     displayable_output::{DisplayableOutput, DisplayableState},
     fs::file_system,
 };
-use miette::Report;
+use miette::{Diagnostic, Report};
 use num_bigint::BigUint;
 use num_complex::Complex64;
 use pyo3::{
@@ -13,7 +13,7 @@ use pyo3::{
     exceptions::PyException,
     prelude::*,
     pyclass::CompareOp,
-    types::{PyComplex, PyDict, PyList, PyString, PyTuple},
+    types::{PyComplex, PyDict, PyList, PyTuple},
 };
 use qsc::{
     fir,
@@ -22,12 +22,12 @@ use qsc::{
         output::{Error, Receiver},
         CircuitEntryPoint, Value,
     },
-    project::{FileSystem, Manifest, ManifestDescriptor},
+    project::{FileSystem, PackageCache},
     target::Profile,
     LanguageFeatures, PackageType, SourceMap,
 };
 use resource_estimator::{self as re, estimate_expr};
-use std::fmt::Write;
+use std::{cell::RefCell, fmt::Write, path::PathBuf, rc::Rc};
 
 #[pymodule]
 fn _native(py: Python, m: &PyModule) -> PyResult<()> {
@@ -74,35 +74,12 @@ pub(crate) struct Interpreter {
     pub(crate) interpreter: interpret::Interpreter,
 }
 
-pub(crate) struct PyManifestDescriptor(ManifestDescriptor);
-
-impl FromPyObject<'_> for PyManifestDescriptor {
-    fn extract(ob: &PyAny) -> PyResult<Self> {
-        let dict = ob.downcast::<PyDict>()?;
-        let manifest_dir = get_dict_opt_string(dict, "manifest_dir")?.ok_or(
-            PyException::new_err("missing key `manifest_dir` in manifest descriptor"),
-        )?;
-
-        let manifest = get_dict_opt_string(dict, "manifest")?.ok_or(PyException::new_err(
-            "missing key `manifest` in manifest descriptor",
-        ))?;
-
-        let manifest = serde_json::from_str::<Manifest>(&manifest).map_err(|_| {
-            PyErr::new::<PyException, _>(format!(
-                "Error parsing {manifest_dir}/qsharp.json . Manifest should be a valid JSON file."
-            ))
-        })?;
-
-        Ok(Self(ManifestDescriptor {
-            manifest,
-            manifest_dir: manifest_dir.into(),
-        }))
-    }
-}
+thread_local! { static PACKAGE_CACHE: Rc<RefCell<PackageCache>> = Rc::default(); }
 
 #[pymethods]
 /// A Q# interpreter.
 impl Interpreter {
+    #[allow(clippy::too_many_arguments)]
     #[allow(clippy::needless_pass_by_value)]
     #[new]
     /// Initializes a new Q# interpreter.
@@ -110,34 +87,42 @@ impl Interpreter {
         py: Python,
         target: TargetProfile,
         language_features: Option<Vec<String>>,
-        manifest_descriptor: Option<PyManifestDescriptor>,
+        project_root: Option<String>,
         read_file: Option<PyObject>,
         list_directory: Option<PyObject>,
         resolve_path: Option<PyObject>,
+        fetch_github: Option<PyObject>,
     ) -> PyResult<Self> {
         let target = match target {
             TargetProfile::Adaptive_RI => Profile::AdaptiveRI,
             TargetProfile::Base => Profile::Base,
             TargetProfile::Unrestricted => Profile::Unrestricted,
         };
-        // If no features were passed in as an argument, use the features from the manifest.
-        // this way we prefer the features from the argument over those from the manifest.
-        let language_features: Vec<String> = match (language_features, &manifest_descriptor) {
-            (Some(language_features), _) => language_features,
-            (_, Some(manifest_descriptor)) => {
-                manifest_descriptor.0.manifest.language_features.clone()
-            }
-            (None, None) => vec![],
-        };
 
-        let sources = if let Some(manifest_descriptor) = manifest_descriptor {
-            if let (Some(read_file), Some(list_directory), Some(resolve_path)) =
-                (read_file, list_directory, resolve_path)
+        let mut language_features =
+            LanguageFeatures::from_iter(language_features.unwrap_or_default());
+
+        let package_cache = PACKAGE_CACHE.with(Clone::clone);
+
+        let sources = if let Some(project_root) = project_root {
+            if let (Some(read_file), Some(list_directory), Some(resolve_path), Some(fetch_github)) =
+                (read_file, list_directory, resolve_path, fetch_github)
             {
-                let project = file_system(py, read_file, list_directory, resolve_path)
-                    .load_project(&manifest_descriptor.0)
-                    .map_py_err()?;
-                SourceMap::new(project.sources, None)
+                let project =
+                    file_system(py, read_file, list_directory, resolve_path, fetch_github)
+                        .load_project(&PathBuf::from(project_root), Some(&package_cache))
+                        .map_err(IntoPyErr::into_py_err)?;
+
+                if !project.errors.is_empty() {
+                    return Err(project.errors.into_py_err());
+                }
+
+                let (sources, project_features) =
+                    project.package_graph_sources.into_sources_temporary();
+
+                language_features.merge(LanguageFeatures::from_iter(project_features));
+
+                SourceMap::new(sources, None)
             } else {
                 panic!("file system hooks should have been passed in with a manifest descriptor")
             }
@@ -145,8 +130,6 @@ impl Interpreter {
             SourceMap::default()
         };
 
-        let language_features = LanguageFeatures::from_iter(language_features);
-
         match interpret::Interpreter::new(
             true,
             sources,
@@ -580,22 +563,6 @@ impl Circuit {
     }
 }
 
-trait MapPyErr<T, E> {
-    fn map_py_err(self) -> core::result::Result<T, PyErr>;
-}
-
-impl<T, E> MapPyErr<T, E> for core::result::Result<T, E>
-where
-    E: IntoPyErr,
-{
-    fn map_py_err(self) -> core::result::Result<T, PyErr>
-    where
-        E: IntoPyErr,
-    {
-        self.map_err(IntoPyErr::into_py_err)
-    }
-}
-
 trait IntoPyErr {
     fn into_py_err(self) -> PyErr;
 }
@@ -606,20 +573,16 @@ impl IntoPyErr for Report {
     }
 }
 
-impl IntoPyErr for Vec<interpret::Error> {
+impl<E> IntoPyErr for Vec<E>
+where
+    E: Diagnostic + Send + Sync + 'static,
+{
     fn into_py_err(self) -> PyErr {
         let mut message = String::new();
-        for error in self {
-            writeln!(message, "{error}").expect("string should be writable");
+        for diag in self {
+            let report = Report::new(diag);
+            writeln!(message, "{report:?}").expect("string should be writable");
         }
         PyException::new_err(message)
     }
 }
-
-fn get_dict_opt_string(dict: &PyDict, key: &str) -> PyResult<Option<String>> {
-    let value = dict.get_item(key)?;
-    Ok(match value {
-        Some(item) => Some(item.downcast::<PyString>()?.to_string_lossy().into()),
-        None => None,
-    })
-}
diff --git a/playground/src/editor.tsx b/playground/src/editor.tsx
index 3f7be00e..c23bbeca 100644
--- a/playground/src/editor.tsx
+++ b/playground/src/editor.tsx
@@ -138,10 +138,10 @@ export function Editor(props: {
     if (code == null) return;
 
     const config = {
-      sources: [["code", code]],
+      sources: [["code", code]] as [string, string][],
       languageFeatures: [],
       profile: profile,
-    } as ProgramConfig;
+    };
 
     if (props.activeTab === "ast-tab") {
       props.setAst(
diff --git a/vscode/qsharp.schema.json b/vscode/qsharp.schema.json
index 767b2c59..6f3c9e6a 100644
--- a/vscode/qsharp.schema.json
+++ b/vscode/qsharp.schema.json
@@ -33,6 +33,56 @@
           }
         }
       }
+    },
+    "dependencies": {
+      "title": "Dependencies",
+      "type": "object",
+      "additionalProperties": {
+        "oneOf": [
+          {
+            "type": "object",
+            "properties": {
+              "path": {
+                "type": "string"
+              }
+            },
+            "required": ["path"],
+            "additionalProperties": false
+          },
+          {
+            "type": "object",
+            "properties": {
+              "github": {
+                "type": "object",
+                "properties": {
+                  "owner": {
+                    "type": "string"
+                  },
+                  "repo": {
+                    "type": "string"
+                  },
+                  "ref": {
+                    "type": "string"
+                  },
+                  "path": {
+                    "type": "string"
+                  }
+                },
+                "required": ["owner", "repo", "ref"],
+                "additionalProperties": false
+              }
+            },
+            "required": ["github"],
+            "additionalProperties": false
+          }
+        ]
+      }
+    }
+  },
+  "files": {
+    "type": "array",
+    "items": {
+      "type": "string"
     }
   }
 }
diff --git a/vscode/src/debugger/session.ts b/vscode/src/debugger/session.ts
index 8e8ad92e..95ab2625 100644
--- a/vscode/src/debugger/session.ts
+++ b/vscode/src/debugger/session.ts
@@ -92,7 +92,8 @@ export class QscDebugSession extends LoggingDebugSession {
     this.setDebuggerLinesStartAt1(false);
     this.setDebuggerColumnsStartAt1(false);
 
-    for (const source of program.sources) {
+    const allKnownSources = getAllSources(program);
+    for (const source of allKnownSources) {
       const uri = vscode.Uri.parse(source[0], true);
 
       // In Debug Protocol requests, the VS Code debug adapter client
@@ -115,7 +116,8 @@ export class QscDebugSession extends LoggingDebugSession {
     );
 
     if (failureMessage == "") {
-      for (const [path, _contents] of this.program.sources) {
+      for (const [path, _contents] of this.program.packageGraphSources.root
+        .sources) {
         const locations = await this.debugService.getBreakpoints(path);
         log.trace(`init breakpointLocations: %O`, locations);
         const mapped = locations.map((location) => {
@@ -960,3 +962,11 @@ export class QscDebugSession extends LoggingDebugSession {
     }
   }
 }
+
+function getAllSources(program: FullProgramConfig) {
+  return program.packageGraphSources.root.sources.concat(
+    Object.values(program.packageGraphSources.packages).flatMap(
+      (p) => p.sources,
+    ),
+  );
+}
diff --git a/vscode/src/diagnostics.ts b/vscode/src/diagnostics.ts
index 864fda46..3ca1a140 100644
--- a/vscode/src/diagnostics.ts
+++ b/vscode/src/diagnostics.ts
@@ -169,7 +169,7 @@ function startCommandDiagnostics(): vscode.Disposable[] {
   );
 
   const dismissActionProvider = vscode.languages.registerCodeActionsProvider(
-    qsharpLanguageId,
+    [qsharpLanguageId, { language: "json", pattern: "**/qsharp.json" }],
     {
       provideCodeActions(doc, range, context): vscode.CodeAction[] | undefined {
         const commandErrors = context.diagnostics.filter((d) =>
diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts
index 79cf6d3f..9f70588e 100644
--- a/vscode/src/extension.ts
+++ b/vscode/src/extension.ts
@@ -7,12 +7,13 @@ import {
   getLibrarySourceContent,
   loadWasmModule,
   log,
+  qsharpGithubUriScheme,
   qsharpLibraryUriScheme,
 } from "qsharp-lang";
 import * as vscode from "vscode";
 import { initAzureWorkspaces } from "./azure/commands.js";
-import { createCodeLensProvider } from "./codeLens.js";
 import { createCodeActionsProvider } from "./codeActions.js";
+import { createCodeLensProvider } from "./codeLens.js";
 import {
   isQsharpDocument,
   isQsharpNotebookCell,
@@ -20,9 +21,11 @@ import {
 } from "./common.js";
 import { createCompletionItemProvider } from "./completion";
 import { getTarget } from "./config";
+import { initProjectCreator } from "./createProject.js";
 import { activateDebugger } from "./debugger/activate";
 import { createDefinitionProvider } from "./definition";
 import { startCheckingQSharp } from "./diagnostics";
+import { createFormattingProvider } from "./format.js";
 import { createHoverProvider } from "./hover";
 import {
   Logging,
@@ -36,16 +39,18 @@ import {
   registerQSharpNotebookHandlers,
 } from "./notebook.js";
 import {
+  fetchGithubRaw,
   findManifestDirectory,
+  getGithubSourceContent,
   listDirectory,
   readFile,
   resolvePath,
+  setGithubEndpoint,
 } from "./projectSystem.js";
 import { initCodegen } from "./qirGeneration.js";
 import { createReferenceProvider } from "./references.js";
 import { createRenameProvider } from "./rename.js";
 import { createSignatureHelpProvider } from "./signature.js";
-import { createFormattingProvider } from "./format.js";
 import { activateTargetProfileStatusBarItem } from "./statusbar.js";
 import {
   EventType,
@@ -54,12 +59,11 @@ import {
   sendTelemetryEvent,
 } from "./telemetry.js";
 import { registerWebViewCommands } from "./webviewPanel.js";
-import { initProjectCreator } from "./createProject.js";
 
 export async function activate(
   context: vscode.ExtensionContext,
 ): Promise<ExtensionApi> {
-  const api: ExtensionApi = {};
+  const api: ExtensionApi = { setGithubEndpoint };
 
   if (context.extensionMode === vscode.ExtensionMode.Test) {
     // Don't log to the output window in tests, forward to a listener instead
@@ -81,6 +85,17 @@ export async function activate(
     ),
   );
 
+  context.subscriptions.push(
+    vscode.workspace.registerTextDocumentContentProvider(
+      qsharpGithubUriScheme,
+      {
+        provideTextDocumentContent(uri) {
+          return getGithubSourceContent(uri);
+        },
+      },
+    ),
+  );
+
   context.subscriptions.push(...activateTargetProfileStatusBarItem());
 
   context.subscriptions.push(
@@ -105,6 +120,7 @@ export async function activate(
 export interface ExtensionApi {
   // Only available in test mode. Allows listening to extension log events.
   logging?: Logging;
+  setGithubEndpoint: (endpoint: string) => void;
 }
 
 function registerDocumentUpdateHandlers(languageService: ILanguageService) {
@@ -347,6 +363,7 @@ async function loadLanguageService(baseUri: vscode.Uri) {
     readFile,
     listDirectory,
     resolvePath: async (a, b) => resolvePath(a, b),
+    fetchGithub: fetchGithubRaw,
   });
   await updateLanguageServiceProfile(languageService);
   const end = performance.now();
@@ -372,12 +389,8 @@ export class QsTextDocumentContentProvider
   implements vscode.TextDocumentContentProvider
 {
   onDidChange?: vscode.Event<vscode.Uri> | undefined;
-  provideTextDocumentContent(
-    uri: vscode.Uri,
-    // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    token: vscode.CancellationToken,
-  ): vscode.ProviderResult<string> {
-    return getLibrarySourceContent(uri.path);
+  provideTextDocumentContent(uri: vscode.Uri): vscode.ProviderResult<string> {
+    return getLibrarySourceContent(uri.toString());
   }
 }
 
diff --git a/vscode/src/projectSystem.ts b/vscode/src/projectSystem.ts
index e43e7c6b..86e796c0 100644
--- a/vscode/src/projectSystem.ts
+++ b/vscode/src/projectSystem.ts
@@ -5,11 +5,12 @@ import {
   IProjectConfig,
   getProjectLoader,
   log,
+  qsharpGithubUriScheme,
   ProjectLoader,
 } from "qsharp-lang";
 import * as vscode from "vscode";
 import { URI, Utils } from "vscode-uri";
-import { updateQSharpJsonDiagnostics } from "./diagnostics";
+import { invokeAndReportCommandDiagnostics } from "./diagnostics";
 
 /** Returns the manifest document if one is found
  * returns null otherwise
@@ -132,14 +133,14 @@ async function readFileUri(
         uri: uri,
       };
     });
-  } catch (_err) {
+  } catch (err) {
     // `readFile` should throw the below if the file is not found
     if (
-      !(_err instanceof vscode.FileSystemError && _err.code === "FileNotFound")
+      !(err instanceof vscode.FileSystemError && err.code === "FileNotFound")
     ) {
-      log.error("Unexpected error trying to read file", _err);
+      log.error("Unexpected error trying to read file", err);
     }
-    return null;
+    throw err;
   }
 }
 
@@ -172,27 +173,17 @@ export async function loadProject(
       findManifestDirectory,
       readFile,
       listDirectory,
+      fetchGithub: fetchGithubRaw,
       resolvePath: async (a, b) => resolvePath(a, b),
     });
   }
 
-  // Clear diagnostics for this project
-  updateQSharpJsonDiagnostics(manifestDocument.manifest);
-
-  let project;
-  try {
-    project = await projectLoader.load_project(
-      manifestDocument.directory.toString(),
-    );
-  } catch (e: any) {
-    updateQSharpJsonDiagnostics(
-      manifestDocument.manifest,
-      e.message ||
-        "Failed to parse Q# manifest. For a minimal Q# project manifest, try: {}",
-    );
-
-    throw e;
-  }
+  const project = invokeAndReportCommandDiagnostics(
+    async () =>
+      await projectLoader!.load_project_with_deps(
+        manifestDocument.directory.toString(),
+      ),
+  );
 
   return project;
 }
@@ -205,8 +196,17 @@ async function singleFileProject(
   return {
     projectName: Utils.basename(documentUri),
     projectUri: documentUri.toString(),
-    sources: [[documentUri.toString(), file.getText()]] as [string, string][],
-    languageFeatures: [],
+    packageGraphSources: {
+      root: {
+        sources: [[documentUri.toString(), file.getText()]] as [
+          string,
+          string,
+        ][],
+        languageFeatures: [],
+        dependencies: {},
+      },
+      packages: {},
+    },
     lints: [],
   };
 }
@@ -219,3 +219,64 @@ export function resolvePath(base: string, relative: string): string | null {
     return null;
   }
 }
+
+let githubEndpoint = "https://raw.githubusercontent.com";
+export function setGithubEndpoint(endpoint: string) {
+  githubEndpoint = endpoint;
+}
+
+export function getGithubSourceContent(uri: URI): string | undefined {
+  const key = uri.toString();
+  return knownGitHubSources.get(key);
+}
+
+const knownGitHubSources = new Map<string, string>();
+
+/**
+ * Makes a request to the GitHub raw content service to retrieve a file.
+ */
+export async function fetchGithubRaw(
+  owner: string,
+  repo: string,
+  ref: string,
+  path: string,
+): Promise<string> {
+  const pathNoLeadingSlash = path.startsWith("/") ? path.slice(1) : path;
+
+  const uri = `${githubEndpoint}/${owner}/${repo}/${ref}/${pathNoLeadingSlash}`;
+  log.info(`making request to ${uri}`);
+  const response = await fetch(uri);
+  if (!response.ok) {
+    log.warn(
+      `fetchGithubRaw: ${owner}/${repo}/${ref}/${path} -> ${response.status} ${response.statusText}`,
+    );
+    throw new Error(
+      `Request to ${uri} failed with status ${response.status} ${response.statusText ? ": " + response.statusText : ""}`,
+    );
+  }
+
+  let text;
+  try {
+    text = await response.text();
+
+    knownGitHubSources.set(
+      URI.from({
+        scheme: qsharpGithubUriScheme,
+        path: `${owner}/${repo}/${ref}/${pathNoLeadingSlash}`,
+      }).toString(),
+      text,
+    );
+  } catch (e) {
+    if (e instanceof Error) {
+      log.warn(
+        `fetchGithubRaw: ${owner}/${repo}/${ref}/${path} -> ${e.message}`,
+      );
+      throw new Error(
+        `Request to ${uri} did not return text content: ${e.message}`,
+      );
+    }
+    throw new Error(`Request to ${uri} did not return text content`);
+  }
+
+  return text;
+}
diff --git a/wasm/src/diagnostic.rs b/wasm/src/diagnostic.rs
index e76739da..b5c2e6dc 100644
--- a/wasm/src/diagnostic.rs
+++ b/wasm/src/diagnostic.rs
@@ -6,7 +6,7 @@ use crate::{
     serializable_type,
 };
 use miette::{Diagnostic, LabeledSpan, Severity};
-use qsc::{self, error::WithSource, interpret, SourceName, Span};
+use qsc::{self, error::WithSource, interpret, project, SourceName, Span};
 use serde::{Deserialize, Serialize};
 use std::{fmt::Write, iter};
 use wasm_bindgen::prelude::*;
@@ -47,6 +47,8 @@ serializable_type! {
 }
 
 serializable_type! {
+    /// Representation of an error that can be used to display a squiggle
+    /// in the editor or to populate the Problems view in VS Code.
     QSharpError,
     {
         document: String,
@@ -58,7 +60,8 @@ serializable_type! {
         document: string;
         diagnostic: VSDiagnostic;
         stack?: string;
-    }"#
+    }"#,
+    IQSharpError
 }
 
 impl VSDiagnostic {
@@ -80,6 +83,14 @@ impl VSDiagnostic {
         Self::new(labels, source_name, err)
     }
 
+    /// Creates a [`VSDiagnostic`] from a language service error.
+    pub(crate) fn from_ls_error(source_name: &str, err: &qsls::protocol::ErrorKind) -> Self {
+        match err {
+            qsls::protocol::ErrorKind::Compile(e) => Self::from_compile_error(source_name, e),
+            qsls::protocol::ErrorKind::Project(e) => Self::new(Vec::new(), source_name, e),
+        }
+    }
+
     /// Creates a [`VSDiagnostic`] using the information from a [`miette::Diagnostic`].
     /// The error message, code and severity are straightforwardly generated,
     /// while mapping label spans is a little trickier.
@@ -203,10 +214,8 @@ where
     }
 }
 
-/// Returns an array of tuples where:
-/// - the first element of the tuple is the document URI, or <project> if the error doesn't have a span
-/// - the second element of the tuple is a [`VSDiagnostic`] that represents the error
-/// - the third element is the stack trace
+/// Converts interpreter errors into the error type that is suitable for
+/// display as squiggles in the editor, and in the Problems view in VS Code
 pub fn interpret_errors_into_qsharp_errors(errs: &[interpret::Error]) -> Vec<QSharpError> {
     let default_uri = "<project>";
     errs.iter()
@@ -234,6 +243,25 @@ pub fn interpret_errors_into_qsharp_errors(errs: &[interpret::Error]) -> Vec<QSh
         .collect()
 }
 
+pub fn project_errors_into_qsharp_errors(
+    project_dir: &str,
+    errs: &[project::Error],
+) -> Vec<QSharpError> {
+    errs.iter()
+        .map(|err| {
+            let doc_uri = err.path().map_or(project_dir, |p| p.as_str());
+
+            let vsdiagnostic = VSDiagnostic::new(Vec::default(), doc_uri, err);
+
+            QSharpError {
+                document: doc_uri.to_string(),
+                diagnostic: vsdiagnostic,
+                stack: None,
+            }
+        })
+        .collect()
+}
+
 fn interpret_error_labels(err: &interpret::Error) -> Vec<Label> {
     match err {
         interpret::Error::Eval(e) => error_labels(e.error()),
diff --git a/wasm/src/language_service.rs b/wasm/src/language_service.rs
index 5cb12df2..984a17f5 100644
--- a/wasm/src/language_service.rs
+++ b/wasm/src/language_service.rs
@@ -46,7 +46,7 @@ impl LanguageService {
             let diags = update
                 .errors
                 .iter()
-                .map(|err| VSDiagnostic::from_compile_error(&update.uri, err))
+                .map(|err| VSDiagnostic::from_ls_error(&update.uri, err))
                 .collect::<Vec<_>>();
             let _ = diagnostics_callback
                 .call3(
diff --git a/wasm/src/project_system.rs b/wasm/src/project_system.rs
index b8f01b9e..f8a18785 100644
--- a/wasm/src/project_system.rs
+++ b/wasm/src/project_system.rs
@@ -1,12 +1,14 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
-use crate::serializable_type;
+use crate::{diagnostic::project_errors_into_qsharp_errors, serializable_type};
 use async_trait::async_trait;
-use qsc::linter::LintConfig;
-use qsc_project::{EntryType, FileSystemAsync, JSFileEntry, JSProjectHost};
+use miette::Report;
+use qsc::{linter::LintConfig, LanguageFeatures};
+use qsc_project::{EntryType, FileSystemAsync, JSFileEntry, JSProjectHost, PackageCache};
+use rustc_hash::FxHashMap;
 use serde::{Deserialize, Serialize};
-use std::{iter::FromIterator, path::PathBuf, str::FromStr, sync::Arc};
+use std::{cell::RefCell, iter::FromIterator, rc::Rc, str::FromStr, sync::Arc};
 use wasm_bindgen::prelude::*;
 
 #[wasm_bindgen(typescript_custom_section)]
@@ -15,6 +17,7 @@ export interface IProjectHost {
     readFile(uri: string): Promise<string | null>;
     listDirectory(uri: string): Promise<[string, number][]>;
     resolvePath(base: string, path: string): Promise<string | null>;
+    fetchGithub(owner: string, repo: string, ref: string, path: string): Promise<string>;
     findManifestDirectory(docUri: string): Promise<string | null>;
 }
 
@@ -23,8 +26,7 @@ export interface IProjectHost {
  * but with all the properties required and filled in with defaults where necessary.
  */
 export interface IProgramConfig {
-    sources: [string, string][];
-    languageFeatures: string[];
+    packageGraphSources: IPackageGraphSources;
     profile: TargetProfile;
 }
 "#;
@@ -35,8 +37,8 @@ extern "C" {
     pub type ProjectHost;
 
     // Methods of `IProjectHost``, expected to be implemented JS-side
-    #[wasm_bindgen(method, structural)]
-    async fn readFile(this: &ProjectHost, uri: &str) -> JsValue;
+    #[wasm_bindgen(method, structural, catch)]
+    async fn readFile(this: &ProjectHost, uri: &str) -> Result<JsValue, JsValue>;
 
     #[wasm_bindgen(method, structural)]
     async fn listDirectory(this: &ProjectHost, uri: &str) -> JsValue;
@@ -44,6 +46,15 @@ extern "C" {
     #[wasm_bindgen(method, structural)]
     async fn resolvePath(this: &ProjectHost, base: &str, path: &str) -> JsValue;
 
+    #[wasm_bindgen(method, structural, catch)]
+    async fn fetchGithub(
+        this: &ProjectHost,
+        owner: &str,
+        repo: &str,
+        r#ref: &str,
+        path: &str,
+    ) -> Result<JsValue, JsValue>;
+
     #[wasm_bindgen(method, structural)]
     async fn findManifestDirectory(this: &ProjectHost, docUri: &str) -> JsValue;
 
@@ -56,10 +67,7 @@ extern "C" {
 
     // Getters for IProgramConfig
     #[wasm_bindgen(method, getter, structural)]
-    fn sources(this: &ProgramConfig) -> ProjectSources;
-
-    #[wasm_bindgen(method, getter, structural)]
-    fn languageFeatures(this: &ProgramConfig) -> Vec<String>;
+    fn packageGraphSources(this: &ProgramConfig) -> IPackageGraphSources;
 
     #[wasm_bindgen(method, getter, structural)]
     fn profile(this: &ProgramConfig) -> String;
@@ -74,6 +82,8 @@ pub(crate) fn to_js_function(val: JsValue, help_text_panic: &'static str) -> js_
     Into::<js_sys::Function>::into(val)
 }
 
+thread_local! { static PACKAGE_CACHE: Rc<RefCell<PackageCache>> = Rc::default(); }
+
 /// a minimal implementation for interacting with async JS filesystem callbacks to
 /// load project files
 #[wasm_bindgen]
@@ -81,11 +91,23 @@ pub struct ProjectLoader(ProjectHost);
 
 #[async_trait(?Send)]
 impl JSProjectHost for ProjectHost {
-    async fn read_file(&self, uri: &str) -> (Arc<str>, Arc<str>) {
+    async fn read_file(&self, uri: &str) -> miette::Result<(Arc<str>, Arc<str>)> {
         let name = Arc::from(uri);
 
-        let val = self.readFile(uri).await;
-        (name, val.as_string().unwrap_or_default().into())
+        match self.readFile(uri).await {
+            Ok(val) => Ok((name, val.as_string().unwrap_or_default().into())),
+
+            Err(js_val) => {
+                let err: js_sys::Error = js_val
+                    .dyn_into()
+                    .expect("exception should be an error type");
+                let message = err
+                    .message()
+                    .as_string()
+                    .expect("error message should be a string");
+                Err(Report::msg(message))
+            }
+        }
     }
 
     async fn list_directory(&self, uri: &str) -> Vec<JSFileEntry> {
@@ -128,6 +150,31 @@ impl JSProjectHost for ProjectHost {
         js_val.as_string().map(Into::into)
     }
 
+    async fn fetch_github(
+        &self,
+        owner: &str,
+        repo: &str,
+        r#ref: &str,
+        path: &str,
+    ) -> miette::Result<Arc<str>> {
+        match self.fetchGithub(owner, repo, r#ref, path).await {
+            Ok(js_val) => Ok(js_val
+                .as_string()
+                .expect("fetchGithub should return a string or throw")
+                .into()),
+            Err(js_val) => {
+                let err: js_sys::Error = js_val
+                    .dyn_into()
+                    .expect("exception should be an error type");
+                let message = err
+                    .message()
+                    .as_string()
+                    .expect("error message should be a string");
+                Err(Report::msg(message))
+            }
+        }
+    }
+
     async fn find_manifest_directory(&self, doc_uri: &str) -> Option<Arc<str>> {
         let js_val = self.findManifestDirectory(doc_uri).await;
         js_val.as_string().map(Into::into)
@@ -141,36 +188,109 @@ impl ProjectLoader {
         ProjectLoader(project_host)
     }
 
-    #[allow(clippy::from_iter_instead_of_collect)]
-    pub async fn load_project(&self, directory: String) -> Result<IProjectConfig, JsValue> {
-        match self.0.load_project_in_dir(&PathBuf::from(directory)).await {
-            Ok(p) => Ok(p.into()),
-            Err(e) => Err(JsError::new(&format!("{e}")).into()),
-        }
+    pub async fn load_project_with_deps(
+        &self,
+        directory: String,
+    ) -> Result<IProjectConfig, String> {
+        let package_cache = PACKAGE_CACHE.with(Clone::clone);
+
+        let dir_path = std::path::Path::new(&directory);
+        let project_config = match self.0.load_project(dir_path, Some(&package_cache)).await {
+            Ok(loaded_project) => loaded_project,
+            Err(errs) => return Err(project_errors_into_qsharp_errors_json(&directory, &errs)),
+        };
+
+        // Will return error if project has errors
+        project_config.try_into()
     }
 }
 
-impl From<ProjectSources> for Vec<(String, String)> {
-    fn from(sources: ProjectSources) -> Self {
-        serde_wasm_bindgen::from_value(sources.into())
-            .expect("sources object should be an array of string pairs")
-    }
+fn project_errors_into_qsharp_errors_json(
+    project_dir: &str,
+    errs: &[qsc_project::Error],
+) -> String {
+    serde_json::to_string(&project_errors_into_qsharp_errors(project_dir, errs))
+        .expect("serializing errors to json should succeed")
 }
 
-impl From<qsc_project::Project> for IProjectConfig {
-    fn from(value: qsc_project::Project) -> Self {
-        let project_config = ProjectConfig {
-            project_name: value.name.to_string(),
-            project_uri: value.manifest_path.to_string(),
+impl From<qsc_project::PackageInfo> for PackageInfo {
+    fn from(value: qsc_project::PackageInfo) -> Self {
+        Self {
             sources: value
                 .sources
                 .into_iter()
-                .map(|(a, b)| (a.to_string(), b.to_string()))
+                .map(|(k, v)| (k.to_string(), v.to_string()))
+                .collect(),
+            language_features: value.language_features.into(),
+            dependencies: value
+                .dependencies
+                .into_iter()
+                .map(|(k, v)| (k.to_string(), v.to_string()))
                 .collect(),
-            language_features: value.manifest.language_features,
-            lints: value.manifest.lints,
+        }
+    }
+}
+
+impl From<qsc_project::PackageGraphSources> for PackageGraphSources {
+    fn from(value: qsc_project::PackageGraphSources) -> Self {
+        Self {
+            root: value.root.into(),
+            packages: value
+                .packages
+                .into_iter()
+                .map(|(pkg_key, pkg_info)| (pkg_key.to_string(), pkg_info.into()))
+                .collect(),
+        }
+    }
+}
+
+serializable_type! {
+    PackageGraphSources,
+    {
+        pub root: PackageInfo,
+        pub packages: FxHashMap<PackageKey,PackageInfo>,
+    },
+    r#"
+    export type PackageKey = string;
+
+    export interface IPackageGraphSources {
+        root: IPackageInfo;
+        packages: Record<PackageKey,IPackageInfo>;
+    }"#,
+    IPackageGraphSources
+}
+
+serializable_type! {
+    PackageInfo,
+    {
+        pub sources: Vec<(String, String)>,
+        pub language_features: Vec<String>,
+        pub dependencies: FxHashMap<PackageAlias,PackageKey>,
+    },
+    r#"export interface IPackageInfo {
+        sources: [string, string][];
+        languageFeatures: string[];
+        dependencies: Record<string,string>;
+    }"#
+}
+
+impl TryFrom<qsc_project::Project> for IProjectConfig {
+    type Error = String;
+
+    fn try_from(value: qsc_project::Project) -> Result<Self, Self::Error> {
+        if !value.errors.is_empty() {
+            return Err(project_errors_into_qsharp_errors_json(
+                &value.path,
+                &value.errors,
+            ));
+        }
+        let project_config = ProjectConfig {
+            project_name: value.name.to_string(),
+            project_uri: value.path.to_string(),
+            lints: value.lints,
+            package_graph_sources: value.package_graph_sources.into(),
         };
-        project_config.into()
+        Ok(project_config.into())
     }
 }
 
@@ -179,8 +299,7 @@ serializable_type! {
     {
         pub project_name: String,
         pub project_uri: String,
-        pub sources: Vec<(String, String)>,
-        pub language_features: Vec<String>,
+        pub package_graph_sources: PackageGraphSources,
         pub lints: Vec<LintConfig>,
     },
     r#"export interface IProjectConfig {
@@ -192,14 +311,7 @@ serializable_type! {
          * Uri for the qsharp.json or the root source file (single file projects)
          */
         projectUri: string;
-        /**
-         * [sourceName, sourceContents] tuples
-         *
-         * In the future, for projects with dependencies, this will contain the
-         * full dependency graph and sources for all the packages referenced by the project.
-         */
-        sources: [string, string][];
-        languageFeatures: string[];
+        packageGraphSources: IPackageGraphSources;
         lints: {
           lint: string;
           level: string;
@@ -208,6 +320,40 @@ serializable_type! {
     IProjectConfig
 }
 
+type PackageAlias = String;
+type PackageKey = String;
+
+impl From<PackageGraphSources> for qsc_project::PackageGraphSources {
+    fn from(value: PackageGraphSources) -> Self {
+        Self {
+            root: value.root.into(),
+            packages: value
+                .packages
+                .into_iter()
+                .map(|(k, v)| (Arc::from(k), v.into()))
+                .collect(),
+        }
+    }
+}
+
+impl From<PackageInfo> for qsc_project::PackageInfo {
+    fn from(value: PackageInfo) -> Self {
+        Self {
+            sources: value
+                .sources
+                .into_iter()
+                .map(|(k, v)| (Arc::from(k), Arc::from(v)))
+                .collect(),
+            language_features: LanguageFeatures::from_iter(value.language_features),
+            dependencies: value
+                .dependencies
+                .into_iter()
+                .map(|(k, v)| (Arc::from(k), Arc::from(v)))
+                .collect(),
+        }
+    }
+}
+
 /// This returns the common parameters that the compiler/interpreter uses
 #[allow(clippy::needless_pass_by_value)]
 pub(crate) fn into_qsc_args(
@@ -221,14 +367,13 @@ pub(crate) fn into_qsc_args(
     let capabilities = qsc::target::Profile::from_str(&program.profile())
         .unwrap_or_else(|()| panic!("Invalid target : {}", program.profile()))
         .into();
-    let sources: Vec<(String, String)> = program.sources().into();
 
-    let source_map = qsc::SourceMap::new(
-        sources.into_iter().map(|(a, b)| (a.into(), b.into())),
-        entry.map(Into::into),
-    );
+    let pkg_graph: PackageGraphSources = program.packageGraphSources().into();
+    let pkg_graph: qsc_project::PackageGraphSources = pkg_graph.into();
+
+    let (sources, language_features) = pkg_graph.into_sources_temporary();
 
-    let language_features = qsc::LanguageFeatures::from_iter(program.languageFeatures());
+    let source_map = qsc::SourceMap::new(sources, entry.map(Into::into));
 
     (source_map, capabilities, language_features)
 }
diff --git a/wasm/src/serializable_type.rs b/wasm/src/serializable_type.rs
index b676f410..425cf99a 100644
--- a/wasm/src/serializable_type.rs
+++ b/wasm/src/serializable_type.rs
@@ -104,9 +104,10 @@
 ///
 #[macro_export]
 macro_rules! serializable_type {
-    ($struct_ident: ident, $struct: tt, $typescript: literal) => {
+    ($(#[$($attrss:meta)*])* $struct_ident: ident, $struct: tt, $typescript: literal) => {
         #[derive(Debug, Serialize, Deserialize)]
         #[serde(rename_all = "camelCase")]
+        $(#[$($attrss)*])*
         pub(crate) struct $struct_ident $struct
 
         // TypeScript type definition that will be included in the generated .d.ts file.
@@ -116,9 +117,10 @@ macro_rules! serializable_type {
         const TYPESCRIPT_CUSTOM_SECTION: &'static str = $typescript;
     };
 
-    ($struct_ident: ident, $struct: tt, $typescript: literal, $typescript_type_ident:ident) => {
+    ($(#[$($attrss:meta)*])* $struct_ident: ident, $struct: tt, $typescript: literal, $typescript_type_ident:ident) => {
         #[derive(Debug, Serialize, Deserialize)]
         #[serde(rename_all = "camelCase")]
+        $(#[$($attrss)*])*
         pub(crate) struct $struct_ident $struct
 
         // TypeScript type definition that will be included in the generated .d.ts file.


Test patch:
diff --git a/compiler/qsc/src/interpret/tests.rs b/compiler/qsc/src/interpret/tests.rs
index 3bdc73f2..5fa5eea4 100644
--- a/compiler/qsc/src/interpret/tests.rs
+++ b/compiler/qsc/src/interpret/tests.rs
@@ -482,7 +482,7 @@ mod given_interpreter {
                 &output,
                 &expect![[r#"
                     runtime error: qubits in invocation are not unique
-                       [intrinsic.qs] [(control, target)]
+                       [qsharp-library-source:intrinsic.qs] [(control, target)]
                 "#]],
             );
         }
@@ -1740,7 +1740,7 @@ mod given_interpreter {
                 &output,
                 &expect![[r#"
                     runtime error: program failed: Cannot allocate qubit array with a negative length
-                      explicit fail [core/qir.qs] [fail "Cannot allocate qubit array with a negative length"]
+                      explicit fail [qsharp-library-source:core/qir.qs] [fail "Cannot allocate qubit array with a negative length"]
                 "#]],
             );
         }
diff --git a/compiler/qsc_project/src/tests.rs b/compiler/qsc_project/src/tests.rs
index ce16cb80..d9c41d58 100644
--- a/compiler/qsc_project/src/tests.rs
+++ b/compiler/qsc_project/src/tests.rs
@@ -17,29 +17,32 @@ fn basic_manifest() {
         &expect![[r#"
             Project {
                 name: "basic_manifest",
-                manifest_path: "basic_manifest/qsharp.json",
-                sources: [
-                    (
-                        "basic_manifest/src/Dependency1.qs",
-                        "namespace Dependency1 {\n    function First() : String {\n        \"123\"\n    }\n}\n",
-                    ),
-                    (
-                        "basic_manifest/src/Dependency2.qs",
-                        "namespace Dependency2 {\n    function Second() : String {\n        \"45\"\n    }\n}\n",
-                    ),
-                    (
-                        "basic_manifest/src/Main.qs",
-                        "namespace Main {\n    open Dependency1;\n    open Dependency2;\n    @EntryPoint()\n    operation Main() : String {\n        First() + Second()\n    }\n}\n",
-                    ),
-                ],
-                manifest: Manifest {
-                    author: Some(
-                        "Microsoft",
-                    ),
-                    license: None,
-                    language_features: [],
-                    lints: [],
+                path: "basic_manifest/qsharp.json",
+                package_graph_sources: PackageGraphSources {
+                    root: PackageInfo {
+                        sources: [
+                            (
+                                "basic_manifest/src/Dependency1.qs",
+                                "namespace Dependency1 {\n    function First() : String {\n        \"123\"\n    }\n}\n",
+                            ),
+                            (
+                                "basic_manifest/src/Dependency2.qs",
+                                "namespace Dependency2 {\n    function Second() : String {\n        \"45\"\n    }\n}\n",
+                            ),
+                            (
+                                "basic_manifest/src/Main.qs",
+                                "namespace Main {\n    open Dependency1;\n    open Dependency2;\n    @EntryPoint()\n    operation Main() : String {\n        First() + Second()\n    }\n}\n",
+                            ),
+                        ],
+                        language_features: LanguageFeatures(
+                            0,
+                        ),
+                        dependencies: {},
+                    },
+                    packages: {},
                 },
+                lints: [],
+                errors: [],
             }"#]],
     );
 }
@@ -51,29 +54,32 @@ fn circular_imports() {
         &expect![[r#"
             Project {
                 name: "circular_imports",
-                manifest_path: "circular_imports/qsharp.json",
-                sources: [
-                    (
-                        "circular_imports/src/Evens.qs",
-                        "namespace Evens {\n    open Odds;\n    function Two() : String {\n        \"2\"\n    }\n    function Four() : String {\n        \"4\"\n    }\n    function Twelve() : String {\n        One_() + Two()\n    }\n}\n",
-                    ),
-                    (
-                        "circular_imports/src/Main.qs",
-                        "namespace Main {\n    open Evens;\n    open Odds;\n\n    @EntryPoint()\n    operation Main() : String {\n        Twelve() + Three() + FortyFive()\n    }\n}\n",
-                    ),
-                    (
-                        "circular_imports/src/Odds.qs",
-                        "namespace Odds {\n    open Evens;\n    function One_() : String {\n        \"1\"\n    }\n    function Three() : String {\n        \"3\"\n    }\n    function Five() : String {\n        \"5\"\n    }\n    function FortyFive() : String {\n        Four() + Five()\n    }\n}\n",
-                    ),
-                ],
-                manifest: Manifest {
-                    author: Some(
-                        "Microsoft",
-                    ),
-                    license: None,
-                    language_features: [],
-                    lints: [],
+                path: "circular_imports/qsharp.json",
+                package_graph_sources: PackageGraphSources {
+                    root: PackageInfo {
+                        sources: [
+                            (
+                                "circular_imports/src/Evens.qs",
+                                "namespace Evens {\n    open Odds;\n    function Two() : String {\n        \"2\"\n    }\n    function Four() : String {\n        \"4\"\n    }\n    function Twelve() : String {\n        One_() + Two()\n    }\n}\n",
+                            ),
+                            (
+                                "circular_imports/src/Main.qs",
+                                "namespace Main {\n    open Evens;\n    open Odds;\n\n    @EntryPoint()\n    operation Main() : String {\n        Twelve() + Three() + FortyFive()\n    }\n}\n",
+                            ),
+                            (
+                                "circular_imports/src/Odds.qs",
+                                "namespace Odds {\n    open Evens;\n    function One_() : String {\n        \"1\"\n    }\n    function Three() : String {\n        \"3\"\n    }\n    function Five() : String {\n        \"5\"\n    }\n    function FortyFive() : String {\n        Four() + Five()\n    }\n}\n",
+                            ),
+                        ],
+                        language_features: LanguageFeatures(
+                            0,
+                        ),
+                        dependencies: {},
+                    },
+                    packages: {},
                 },
+                lints: [],
+                errors: [],
             }"#]],
     );
 }
@@ -85,29 +91,32 @@ fn different_files_same_manifest() {
         &expect![[r#"
             Project {
                 name: "different_files_same_manifest",
-                manifest_path: "different_files_same_manifest/qsharp.json",
-                sources: [
-                    (
-                        "different_files_same_manifest/src/Dependency1.qs",
-                        "namespace Dependency {\n    function First() : String {\n        \"123\"\n    }\n}\n",
-                    ),
-                    (
-                        "different_files_same_manifest/src/Dependency2.qs",
-                        "namespace Dependency {\n    function Second() : String {\n        \"45\"\n    }\n}\n",
-                    ),
-                    (
-                        "different_files_same_manifest/src/Main.qs",
-                        "namespace Main {\n    open Dependency;\n    @EntryPoint()\n    operation Main() : String {\n        First() + Second()\n    }\n}\n",
-                    ),
-                ],
-                manifest: Manifest {
-                    author: Some(
-                        "Microsoft",
-                    ),
-                    license: None,
-                    language_features: [],
-                    lints: [],
+                path: "different_files_same_manifest/qsharp.json",
+                package_graph_sources: PackageGraphSources {
+                    root: PackageInfo {
+                        sources: [
+                            (
+                                "different_files_same_manifest/src/Dependency1.qs",
+                                "namespace Dependency {\n    function First() : String {\n        \"123\"\n    }\n}\n",
+                            ),
+                            (
+                                "different_files_same_manifest/src/Dependency2.qs",
+                                "namespace Dependency {\n    function Second() : String {\n        \"45\"\n    }\n}\n",
+                            ),
+                            (
+                                "different_files_same_manifest/src/Main.qs",
+                                "namespace Main {\n    open Dependency;\n    @EntryPoint()\n    operation Main() : String {\n        First() + Second()\n    }\n}\n",
+                            ),
+                        ],
+                        language_features: LanguageFeatures(
+                            0,
+                        ),
+                        dependencies: {},
+                    },
+                    packages: {},
                 },
+                lints: [],
+                errors: [],
             }"#]],
     );
 }
@@ -119,19 +128,24 @@ fn empty_manifest() {
         &expect![[r#"
             Project {
                 name: "empty_manifest",
-                manifest_path: "empty_manifest/qsharp.json",
-                sources: [
-                    (
-                        "empty_manifest/src/Main.qs",
-                        "namespace Main {\n    @EntryPoint()\n    operation Main() : String {\n        \"12345\"\n    }\n}\n",
-                    ),
-                ],
-                manifest: Manifest {
-                    author: None,
-                    license: None,
-                    language_features: [],
-                    lints: [],
+                path: "empty_manifest/qsharp.json",
+                package_graph_sources: PackageGraphSources {
+                    root: PackageInfo {
+                        sources: [
+                            (
+                                "empty_manifest/src/Main.qs",
+                                "namespace Main {\n    @EntryPoint()\n    operation Main() : String {\n        \"12345\"\n    }\n}\n",
+                            ),
+                        ],
+                        language_features: LanguageFeatures(
+                            0,
+                        ),
+                        dependencies: {},
+                    },
+                    packages: {},
                 },
+                lints: [],
+                errors: [],
             }"#]],
     );
 }
@@ -143,31 +157,36 @@ fn folder_structure() {
         &expect![[r#"
             Project {
                 name: "folder_structure",
-                manifest_path: "folder_structure/qsharp.json",
-                sources: [
-                    (
-                        "folder_structure/src/Project.qs",
-                        "namespace Project {\n    @EntryPoint()\n    operation Entry() : String {\n        Strings.Concat(\"12\", $\"{(Math.Subtract(346, 1))}\")\n    }\n}\n",
-                    ),
-                    (
-                        "folder_structure/src/utils/ops/Add.qs",
-                        "namespace Math {\n    function Add(a: Int, b: Int) : Int {\n        a + b\n    }\n}\n",
-                    ),
-                    (
-                        "folder_structure/src/utils/ops/Subtract.qs",
-                        "namespace Math {\n    function Subtract(a: Int, b: Int) : Int {\n        a - b\n    }\n}\n",
-                    ),
-                    (
-                        "folder_structure/src/utils/strings/Concat.qs",
-                        "namespace Strings {\n    function Concat(a: String, b: String) : String {\n        a + b\n    }\n}\n",
-                    ),
-                ],
-                manifest: Manifest {
-                    author: None,
-                    license: None,
-                    language_features: [],
-                    lints: [],
+                path: "folder_structure/qsharp.json",
+                package_graph_sources: PackageGraphSources {
+                    root: PackageInfo {
+                        sources: [
+                            (
+                                "folder_structure/src/Project.qs",
+                                "namespace Project {\n    @EntryPoint()\n    operation Entry() : String {\n        Strings.Concat(\"12\", $\"{(Math.Subtract(346, 1))}\")\n    }\n}\n",
+                            ),
+                            (
+                                "folder_structure/src/utils/ops/Add.qs",
+                                "namespace Math {\n    function Add(a: Int, b: Int) : Int {\n        a + b\n    }\n}\n",
+                            ),
+                            (
+                                "folder_structure/src/utils/ops/Subtract.qs",
+                                "namespace Math {\n    function Subtract(a: Int, b: Int) : Int {\n        a - b\n    }\n}\n",
+                            ),
+                            (
+                                "folder_structure/src/utils/strings/Concat.qs",
+                                "namespace Strings {\n    function Concat(a: String, b: String) : String {\n        a + b\n    }\n}\n",
+                            ),
+                        ],
+                        language_features: LanguageFeatures(
+                            0,
+                        ),
+                        dependencies: {},
+                    },
+                    packages: {},
                 },
+                lints: [],
+                errors: [],
             }"#]],
     );
 }
@@ -178,27 +197,32 @@ fn hidden_files() {
         &expect![[r#"
             Project {
                 name: "hidden_files",
-                manifest_path: "hidden_files/qsharp.json",
-                sources: [
-                    (
-                        "hidden_files/src/Project.qs",
-                        "namespace Project {\n    @EntryPoint()\n    operation Entry() : String {\n        Strings.Concat(\"12\", $\"{(Math.Subtract(346, 1))}\")\n    }\n}\n",
-                    ),
-                    (
-                        "hidden_files/src/utils/ops/Add.qs",
-                        "namespace Math {\n    function Add(a: Int, b: Int) : Int {\n        a + b\n    }\n}\n",
-                    ),
-                    (
-                        "hidden_files/src/utils/ops/Subtract.qs",
-                        "namespace Math {\n    function Subtract(a: Int, b: Int) : Int {\n        a - b\n    }\n}\n",
-                    ),
-                ],
-                manifest: Manifest {
-                    author: None,
-                    license: None,
-                    language_features: [],
-                    lints: [],
+                path: "hidden_files/qsharp.json",
+                package_graph_sources: PackageGraphSources {
+                    root: PackageInfo {
+                        sources: [
+                            (
+                                "hidden_files/src/Project.qs",
+                                "namespace Project {\n    @EntryPoint()\n    operation Entry() : String {\n        Strings.Concat(\"12\", $\"{(Math.Subtract(346, 1))}\")\n    }\n}\n",
+                            ),
+                            (
+                                "hidden_files/src/utils/ops/Add.qs",
+                                "namespace Math {\n    function Add(a: Int, b: Int) : Int {\n        a + b\n    }\n}\n",
+                            ),
+                            (
+                                "hidden_files/src/utils/ops/Subtract.qs",
+                                "namespace Math {\n    function Subtract(a: Int, b: Int) : Int {\n        a - b\n    }\n}\n",
+                            ),
+                        ],
+                        language_features: LanguageFeatures(
+                            0,
+                        ),
+                        dependencies: {},
+                    },
+                    packages: {},
                 },
+                lints: [],
+                errors: [],
             }"#]],
     );
 }
@@ -209,31 +233,36 @@ fn peer_file() {
         &expect![[r#"
             Project {
                 name: "peer_file",
-                manifest_path: "peer_file/qsharp.json",
-                sources: [
-                    (
-                        "peer_file/src/Project.qs",
-                        "namespace Project {\n    @EntryPoint()\n    operation Entry() : String {\n        Strings.Concat(\"12\", $\"{(Math.Subtract(346, 1))}\")\n    }\n}\n",
-                    ),
-                    (
-                        "peer_file/src/utils/ops/Add.qs",
-                        "namespace Math {\n    function Add(a: Int, b: Int) : Int {\n        a + b\n    }\n}\n",
-                    ),
-                    (
-                        "peer_file/src/utils/ops/Subtract.qs",
-                        "namespace Math {\n    function Subtract(a: Int, b: Int) : Int {\n        a - b\n    }\n}\n",
-                    ),
-                    (
-                        "peer_file/src/utils/strings/Concat.qs",
-                        "namespace Strings {\n    function Concat(a: String, b: String) : String {\n        a + b\n    }\n}\n",
-                    ),
-                ],
-                manifest: Manifest {
-                    author: None,
-                    license: None,
-                    language_features: [],
-                    lints: [],
+                path: "peer_file/qsharp.json",
+                package_graph_sources: PackageGraphSources {
+                    root: PackageInfo {
+                        sources: [
+                            (
+                                "peer_file/src/Project.qs",
+                                "namespace Project {\n    @EntryPoint()\n    operation Entry() : String {\n        Strings.Concat(\"12\", $\"{(Math.Subtract(346, 1))}\")\n    }\n}\n",
+                            ),
+                            (
+                                "peer_file/src/utils/ops/Add.qs",
+                                "namespace Math {\n    function Add(a: Int, b: Int) : Int {\n        a + b\n    }\n}\n",
+                            ),
+                            (
+                                "peer_file/src/utils/ops/Subtract.qs",
+                                "namespace Math {\n    function Subtract(a: Int, b: Int) : Int {\n        a - b\n    }\n}\n",
+                            ),
+                            (
+                                "peer_file/src/utils/strings/Concat.qs",
+                                "namespace Strings {\n    function Concat(a: String, b: String) : String {\n        a + b\n    }\n}\n",
+                            ),
+                        ],
+                        language_features: LanguageFeatures(
+                            0,
+                        ),
+                        dependencies: {},
+                    },
+                    packages: {},
                 },
+                lints: [],
+                errors: [],
             }"#]],
     );
 }
@@ -245,21 +274,191 @@ fn language_feature() {
         &expect![[r#"
             Project {
                 name: "language_feature",
-                manifest_path: "language_feature/qsharp.json",
-                sources: [
-                    (
-                        "language_feature/src/Project.qs",
-                        "namespace Project {\n    @EntryPoint()\n    operation Entry() : Unit {\n        use qs = Qubit[2] { };\n    }\n}\n",
+                path: "language_feature/qsharp.json",
+                package_graph_sources: PackageGraphSources {
+                    root: PackageInfo {
+                        sources: [
+                            (
+                                "language_feature/src/Project.qs",
+                                "namespace Project {\n    @EntryPoint()\n    operation Entry() : Unit {\n        use qs = Qubit[2] { };\n    }\n}\n",
+                            ),
+                        ],
+                        language_features: LanguageFeatures(
+                            1,
+                        ),
+                        dependencies: {},
+                    },
+                    packages: {},
+                },
+                lints: [],
+                errors: [],
+            }"#]],
+    );
+}
+
+#[test]
+fn with_local_dep() {
+    check(
+        &"with_local_dep".into(),
+        &expect![[r#"
+            Project {
+                name: "with_local_dep",
+                path: "with_local_dep/qsharp.json",
+                package_graph_sources: PackageGraphSources {
+                    root: PackageInfo {
+                        sources: [
+                            (
+                                "with_local_dep/src/Main.qs",
+                                "namespace Main {\n    @EntryPoint()\n    function Main() : Unit {\n        Dependency.LibraryFn();\n    }\n}\n",
+                            ),
+                        ],
+                        language_features: LanguageFeatures(
+                            0,
+                        ),
+                        dependencies: {
+                            "MyDep": "{\"path\":\"local_dep\"}",
+                        },
+                    },
+                    packages: {
+                        "{\"path\":\"local_dep\"}": PackageInfo {
+                            sources: [
+                                (
+                                    "local_dep/src/Dependency.qs",
+                                    "namespace Dependency {\n    function LibraryFn() : Unit {\n    }\n}\n",
+                                ),
+                            ],
+                            language_features: LanguageFeatures(
+                                0,
+                            ),
+                            dependencies: {},
+                        },
+                    },
+                },
+                lints: [],
+                errors: [],
+            }"#]],
+    );
+}
+
+#[test]
+fn transitive_dep() {
+    check(
+        &"transitive_dep".into(),
+        &expect![[r#"
+            Project {
+                name: "transitive_dep",
+                path: "transitive_dep/qsharp.json",
+                package_graph_sources: PackageGraphSources {
+                    root: PackageInfo {
+                        sources: [
+                            (
+                                "transitive_dep/src/Main.qs",
+                                "namespace Main {\n    @EntryPoint()\n    function Main() : Unit {\n    }\n}\n",
+                            ),
+                        ],
+                        language_features: LanguageFeatures(
+                            0,
+                        ),
+                        dependencies: {
+                            "MyDep": "{\"path\":\"with_local_dep\"}",
+                        },
+                    },
+                    packages: {
+                        "{\"path\":\"local_dep\"}": PackageInfo {
+                            sources: [
+                                (
+                                    "local_dep/src/Dependency.qs",
+                                    "namespace Dependency {\n    function LibraryFn() : Unit {\n    }\n}\n",
+                                ),
+                            ],
+                            language_features: LanguageFeatures(
+                                0,
+                            ),
+                            dependencies: {},
+                        },
+                        "{\"path\":\"with_local_dep\"}": PackageInfo {
+                            sources: [
+                                (
+                                    "with_local_dep/src/Main.qs",
+                                    "namespace Main {\n    @EntryPoint()\n    function Main() : Unit {\n        Dependency.LibraryFn();\n    }\n}\n",
+                                ),
+                            ],
+                            language_features: LanguageFeatures(
+                                0,
+                            ),
+                            dependencies: {
+                                "MyDep": "{\"path\":\"local_dep\"}",
+                            },
+                        },
+                    },
+                },
+                lints: [],
+                errors: [],
+            }"#]],
+    );
+}
+
+#[test]
+fn explicit_files_list() {
+    check(
+        &"explicit_files_list".into(),
+        &expect![[r#"
+            Project {
+                name: "explicit_files_list",
+                path: "explicit_files_list/qsharp.json",
+                package_graph_sources: PackageGraphSources {
+                    root: PackageInfo {
+                        sources: [
+                            (
+                                "explicit_files_list/src/Main.qs",
+                                "namespace Dependency {\n    function LibraryFn() : Unit {\n    }\n}\n",
+                            ),
+                        ],
+                        language_features: LanguageFeatures(
+                            0,
+                        ),
+                        dependencies: {},
+                    },
+                    packages: {},
+                },
+                lints: [],
+                errors: [],
+            }"#]],
+    );
+}
+
+#[test]
+fn circular_dep() {
+    check(
+        &"circular_dep".into(),
+        &expect![[r#"
+            Project {
+                name: "circular_dep",
+                path: "circular_dep/qsharp.json",
+                package_graph_sources: PackageGraphSources {
+                    root: PackageInfo {
+                        sources: [
+                            (
+                                "circular_dep/src/Main.qs",
+                                "namespace Main {\n    @EntryPoint()\n    function Main() : Unit {}\n}\n",
+                            ),
+                        ],
+                        language_features: LanguageFeatures(
+                            0,
+                        ),
+                        dependencies: {
+                            "MyCircularDep": "{\"path\":\"circular_dep\"}",
+                        },
+                    },
+                    packages: {},
+                },
+                lints: [],
+                errors: [
+                    Circular(
+                        "REPLACED",
+                        "REPLACED",
                     ),
                 ],
-                manifest: Manifest {
-                    author: None,
-                    license: None,
-                    language_features: [
-                        "v2-preview-syntax",
-                    ],
-                    lints: [],
-                },
             }"#]],
     );
 }
diff --git a/compiler/qsc_project/src/tests/harness.rs b/compiler/qsc_project/src/tests/harness.rs
index 774e273d..d1ff0896 100644
--- a/compiler/qsc_project/src/tests/harness.rs
+++ b/compiler/qsc_project/src/tests/harness.rs
@@ -1,42 +1,124 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
-use std::{path::PathBuf, sync::Arc};
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
 
 use expect_test::Expect;
-use qsc_project::{FileSystem, Manifest, StdFs};
+use qsc_project::{
+    key_for_package_ref, package_ref_from_key, Error, FileSystem, Manifest, PackageRef, Project,
+    StdFs,
+};
+use rustc_hash::FxHashMap;
 
 pub fn check(project_path: &PathBuf, expect: &Expect) {
-    // use the path to the src folder as the root path
     let mut root_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
-    root_path.push("src");
-    root_path.push("tests");
-    root_path.push("projects");
+    root_path.push(PathBuf::from("src"));
+    root_path.push(PathBuf::from("tests"));
+    root_path.push(PathBuf::from("projects"));
     let mut absolute_project_path = root_path.clone();
     absolute_project_path.push(project_path);
     let manifest = Manifest::load_from_path(absolute_project_path)
         .expect("manifest should load")
         .expect("manifest should contain descriptor");
     let fs = StdFs;
-    let mut project = fs.load_project(&manifest).expect("project should load");
+    let mut project = fs
+        .load_project(&manifest.manifest_dir, None)
+        .expect("project should load");
 
-    for (path, _contents) in &mut project.sources {
-        remove_absolute_path_prefix(path, &root_path);
+    normalize(&mut project, &root_path);
+
+    expect.assert_eq(&format!("{project:#?}"));
+}
+
+/// If the `Project` contains absolute paths, replace them with relative paths
+/// so that running the tests on different machines produce the same results.
+/// Some error messages may contain paths formatted into strings, in that case
+/// we'll just replace the message with filler text.
+fn normalize(project: &mut Project, root_path: &Path) {
+    let pkg_graph = &mut project.package_graph_sources;
+
+    normalize_pkg(&mut pkg_graph.root, root_path);
+
+    let mut new_packages = FxHashMap::default();
+    for (mut key, mut pkg) in pkg_graph.packages.drain() {
+        remove_absolute_path_prefix_from_key(&mut key, root_path);
+
+        normalize_pkg(&mut pkg, root_path);
+
+        new_packages.insert(key, pkg);
     }
 
-    remove_absolute_path_prefix(&mut project.manifest_path, &root_path);
+    pkg_graph.packages = new_packages;
 
-    project.sources.sort();
+    remove_absolute_path_prefix(&mut project.path, root_path);
 
-    expect.assert_eq(&format!("{project:#?}"));
+    for err in &mut project.errors {
+        match err {
+            Error::NoSrcDir { path }
+            | Error::ManifestParse { path, .. }
+            | Error::GitHubManifestParse { path, .. } => {
+                let mut str = std::mem::take(path).into();
+                remove_absolute_path_prefix(&mut str, root_path);
+                *path = str.to_string();
+            }
+            Error::FileSystem {
+                about_path: path,
+                error,
+            } => {
+                let mut str = std::mem::take(path).into();
+                remove_absolute_path_prefix(&mut str, root_path);
+                *path = str.to_string();
+                *error = "REPLACED".to_string();
+            }
+            Error::Circular(s1, s2) | Error::GitHubToLocal(s1, s2) => {
+                // These errors contain absolute paths which don't work well in test output
+                *s1 = "REPLACED".to_string();
+                *s2 = "REPLACED".to_string();
+            }
+            Error::GitHub(s) => {
+                *s = "REPLACED".to_string();
+            }
+        }
+    }
 }
 
-fn remove_absolute_path_prefix(path: &mut Arc<str>, root_path: &PathBuf) {
+fn normalize_pkg(pkg: &mut qsc_project::PackageInfo, root_path: &Path) {
+    for (path, _contents) in &mut pkg.sources {
+        remove_absolute_path_prefix(path, root_path);
+    }
+    pkg.sources.sort();
+
+    for key in pkg.dependencies.values_mut() {
+        remove_absolute_path_prefix_from_key(key, root_path);
+    }
+}
+
+fn remove_absolute_path_prefix(path: &mut Arc<str>, root_path: &Path) {
     let new_path = PathBuf::from(path.to_string());
     let new_path = new_path
         .strip_prefix(root_path)
-        .expect("prefix should be present")
+        .unwrap_or_else(|_| {
+            panic!(
+                "prefix {} should be present in {}",
+                root_path.display(),
+                path
+            )
+        })
         .to_string_lossy();
     let new_path = new_path.replace(std::path::MAIN_SEPARATOR, "/");
     *path = Arc::from(new_path);
 }
+
+fn remove_absolute_path_prefix_from_key(key: &mut Arc<str>, root_path: &Path) {
+    let def = package_ref_from_key(key);
+    if let PackageRef::Path { path } = def {
+        let mut path = path.into();
+        remove_absolute_path_prefix(&mut path, root_path);
+        *key = key_for_package_ref(&PackageRef::Path {
+            path: path.to_string(),
+        });
+    }
+}
diff --git a/compiler/qsc_project/src/tests/projects/circular_dep/qsharp.json b/compiler/qsc_project/src/tests/projects/circular_dep/qsharp.json
new file mode 100644
index 00000000..862895a1
--- /dev/null
+++ b/compiler/qsc_project/src/tests/projects/circular_dep/qsharp.json
@@ -0,0 +1,8 @@
+{
+  "author": "Microsoft",
+  "dependencies": {
+    "MyCircularDep": {
+      "path": "../circular_dep"
+    }
+  }
+}
diff --git a/compiler/qsc_project/src/tests/projects/circular_dep/src/Main.qs b/compiler/qsc_project/src/tests/projects/circular_dep/src/Main.qs
new file mode 100644
index 00000000..ba7e267e
--- /dev/null
+++ b/compiler/qsc_project/src/tests/projects/circular_dep/src/Main.qs
@@ -0,0 +1,4 @@
+namespace Main {
+    @EntryPoint()
+    function Main() : Unit {}
+}
diff --git a/compiler/qsc_project/src/tests/projects/explicit_files_list/qsharp.json b/compiler/qsc_project/src/tests/projects/explicit_files_list/qsharp.json
new file mode 100644
index 00000000..7e519c44
--- /dev/null
+++ b/compiler/qsc_project/src/tests/projects/explicit_files_list/qsharp.json
@@ -0,0 +1,6 @@
+{
+  "author": "Microsoft",
+  "files" : [
+    "src/Main.qs"
+  ]
+}
diff --git a/compiler/qsc_project/src/tests/projects/explicit_files_list/src/Main.qs b/compiler/qsc_project/src/tests/projects/explicit_files_list/src/Main.qs
new file mode 100644
index 00000000..7c841ca6
--- /dev/null
+++ b/compiler/qsc_project/src/tests/projects/explicit_files_list/src/Main.qs
@@ -0,0 +1,4 @@
+namespace Dependency {
+    function LibraryFn() : Unit {
+    }
+}
diff --git a/compiler/qsc_project/src/tests/projects/explicit_files_list/src/NotIncluded.qs b/compiler/qsc_project/src/tests/projects/explicit_files_list/src/NotIncluded.qs
new file mode 100644
index 00000000..7c841ca6
--- /dev/null
+++ b/compiler/qsc_project/src/tests/projects/explicit_files_list/src/NotIncluded.qs
@@ -0,0 +1,4 @@
+namespace Dependency {
+    function LibraryFn() : Unit {
+    }
+}
diff --git a/compiler/qsc_project/src/tests/projects/local_dep/qsharp.json b/compiler/qsc_project/src/tests/projects/local_dep/qsharp.json
new file mode 100644
index 00000000..b6c4bb47
--- /dev/null
+++ b/compiler/qsc_project/src/tests/projects/local_dep/qsharp.json
@@ -0,0 +1,6 @@
+{
+  "author": "Microsoft",
+  "files" : [
+    "src/Dependency.qs"
+  ]
+}
diff --git a/compiler/qsc_project/src/tests/projects/local_dep/src/Dependency.qs b/compiler/qsc_project/src/tests/projects/local_dep/src/Dependency.qs
new file mode 100644
index 00000000..7c841ca6
--- /dev/null
+++ b/compiler/qsc_project/src/tests/projects/local_dep/src/Dependency.qs
@@ -0,0 +1,4 @@
+namespace Dependency {
+    function LibraryFn() : Unit {
+    }
+}
diff --git a/compiler/qsc_project/src/tests/projects/transitive_dep/qsharp.json b/compiler/qsc_project/src/tests/projects/transitive_dep/qsharp.json
new file mode 100644
index 00000000..b3e4c2f3
--- /dev/null
+++ b/compiler/qsc_project/src/tests/projects/transitive_dep/qsharp.json
@@ -0,0 +1,8 @@
+{
+  "author": "Microsoft",
+  "dependencies": {
+    "MyDep": {
+      "path": "../with_local_dep"
+    }
+  }
+}
diff --git a/compiler/qsc_project/src/tests/projects/transitive_dep/src/Main.qs b/compiler/qsc_project/src/tests/projects/transitive_dep/src/Main.qs
new file mode 100644
index 00000000..933f9000
--- /dev/null
+++ b/compiler/qsc_project/src/tests/projects/transitive_dep/src/Main.qs
@@ -0,0 +1,5 @@
+namespace Main {
+    @EntryPoint()
+    function Main() : Unit {
+    }
+}
diff --git a/compiler/qsc_project/src/tests/projects/with_local_dep/qsharp.json b/compiler/qsc_project/src/tests/projects/with_local_dep/qsharp.json
new file mode 100644
index 00000000..9cf74724
--- /dev/null
+++ b/compiler/qsc_project/src/tests/projects/with_local_dep/qsharp.json
@@ -0,0 +1,8 @@
+{
+  "author": "Microsoft",
+  "dependencies": {
+    "MyDep": {
+      "path": "../local_dep"
+    }
+  }
+}
diff --git a/compiler/qsc_project/src/tests/projects/with_local_dep/src/Main.qs b/compiler/qsc_project/src/tests/projects/with_local_dep/src/Main.qs
new file mode 100644
index 00000000..4826d63c
--- /dev/null
+++ b/compiler/qsc_project/src/tests/projects/with_local_dep/src/Main.qs
@@ -0,0 +1,6 @@
+namespace Main {
+    @EntryPoint()
+    function Main() : Unit {
+        Dependency.LibraryFn();
+    }
+}
diff --git a/language_service/src/state/tests.rs b/language_service/src/state/tests.rs
index 6892ded5..61e1b8ca 100644
--- a/language_service/src/state/tests.rs
+++ b/language_service/src/state/tests.rs
@@ -6,11 +6,11 @@
 
 use super::{CompilationState, CompilationStateUpdater};
 use crate::{
-    protocol::{DiagnosticUpdate, NotebookMetadata, WorkspaceConfigurationUpdate},
+    protocol::{DiagnosticUpdate, ErrorKind, NotebookMetadata, WorkspaceConfigurationUpdate},
     tests::test_fs::{dir, file, FsNode, TestProjectHost},
 };
 use expect_test::{expect, Expect};
-use qsc::{compile::ErrorKind, target::Profile, PackageType};
+use qsc::{compile, project, target::Profile, PackageType};
 use qsc_linter::{AstLint, LintConfig, LintKind, LintLevel};
 use std::{cell::RefCell, fmt::Write, rc::Rc};
 
@@ -73,6 +73,7 @@ async fn clear_error() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]],
@@ -96,6 +97,7 @@ async fn clear_error() {
                         2,
                     ),
                     [],
+                    [],
                 ),
             ]
         "#]],
@@ -183,11 +185,13 @@ async fn close_last_doc_in_project() {
                             ),
                         ),
                     ],
+                    [],
                 ),
                 (
                     "project/src/this_file.qs",
                     None,
                     [],
+                    [],
                 ),
             ]
         "#]],
@@ -246,6 +250,7 @@ async fn clear_on_document_close() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]],
@@ -261,6 +266,7 @@ async fn clear_on_document_close() {
                     "single/foo.qs",
                     None,
                     [],
+                    [],
                 ),
             ]
         "#]],
@@ -302,6 +308,7 @@ async fn compile_error() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]],
@@ -355,6 +362,7 @@ async fn rca_errors_are_reported_when_compilation_succeeds() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]],
@@ -418,6 +426,7 @@ async fn base_profile_rca_errors_are_reported_when_compilation_succeeds() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]],
@@ -470,6 +479,7 @@ async fn package_type_update_causes_error() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]],
@@ -516,6 +526,7 @@ async fn target_profile_update_fixes_error() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]],
@@ -536,6 +547,7 @@ async fn target_profile_update_fixes_error() {
                         1,
                     ),
                     [],
+                    [],
                 ),
             ]
         "#]],
@@ -586,6 +598,7 @@ async fn target_profile_update_causes_error_in_stdlib() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]],
@@ -668,6 +681,7 @@ fn notebook_document_errors() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]],
@@ -723,6 +737,7 @@ fn notebook_document_lints() {
                             },
                         ),
                     ],
+                    [],
                 ),
                 (
                     "cell2",
@@ -746,6 +761,7 @@ fn notebook_document_lints() {
                             },
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]],
@@ -805,6 +821,7 @@ fn notebook_update_remove_cell_clears_errors() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]],
@@ -824,6 +841,7 @@ fn notebook_update_remove_cell_clears_errors() {
                     "cell2",
                     None,
                     [],
+                    [],
                 ),
             ]
         "#]],
@@ -883,6 +901,7 @@ fn close_notebook_clears_errors() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]],
@@ -898,6 +917,7 @@ fn close_notebook_clears_errors() {
                     "cell2",
                     None,
                     [],
+                    [],
                 ),
             ]
         "#]],
@@ -988,6 +1008,7 @@ async fn update_doc_updates_project() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]],
@@ -1080,11 +1101,13 @@ async fn close_doc_prioritizes_fs() {
                             ),
                         ),
                     ],
+                    [],
                 ),
                 (
                     "project/src/this_file.qs",
                     None,
                     [],
+                    [],
                 ),
             ]
         "#]],
@@ -1530,7 +1553,7 @@ async fn lints_update_after_manifest_change() {
         .await;
 
     // Check generated lints.
-    let lints: &[ErrorKind] = &received_errors.take()[0].2;
+    let lints: &[compile::ErrorKind] = &received_errors.take()[0].2;
     check_lints(
         lints,
         &expect![[r#"
@@ -1595,7 +1618,7 @@ async fn lints_update_after_manifest_change() {
         .await;
 
     // Check lints again
-    let lints: &[ErrorKind] = &received_errors.take()[0].2;
+    let lints: &[compile::ErrorKind] = &received_errors.take()[0].2;
     check_lints(
         lints,
         &expect![[r#"
@@ -1670,7 +1693,7 @@ async fn lints_prefer_workspace_over_defaults() {
         .await;
 
     // Check generated lints.
-    let lints: &[ErrorKind] = &received_errors.take()[0].2;
+    let lints: &[compile::ErrorKind] = &received_errors.take()[0].2;
     check_lints(
         lints,
         &expect![[r#"
@@ -1733,20 +1756,139 @@ async fn lints_prefer_manifest_over_workspace() {
     assert_eq!(received_errors.borrow().len(), 0);
 }
 
-type ErrorInfo = (String, Option<u32>, Vec<ErrorKind>);
+#[tokio::test]
+async fn missing_dependency_reported() {
+    let fs = FsNode::Dir(
+        [dir(
+            "parent",
+            [
+                file(
+                    "qsharp.json",
+                    r#"{ "dependencies" : { "MyDep" : { "path" : "../child" } } }"#,
+                ),
+                dir("src", [file("main.qs", "function Main() : Unit {}")]),
+            ],
+        )]
+        .into_iter()
+        .collect(),
+    );
+
+    let fs = Rc::new(RefCell::new(fs));
+    let received_errors = RefCell::new(Vec::new());
+    let mut updater = new_updater_with_file_system(&received_errors, &fs);
+
+    // Triger a document update.
+    updater
+        .update_document("parent/src/main.qs", 1, "function Main() : Unit {}")
+        .await;
+
+    expect![[r#"
+        [
+            (
+                "parent/qsharp.json",
+                None,
+                [],
+                [
+                    FileSystem {
+                        about_path: "child/qsharp.json",
+                        error: "file not found",
+                    },
+                ],
+            ),
+        ]
+    "#]]
+    .assert_debug_eq(&received_errors.borrow());
+}
+
+#[tokio::test]
+async fn error_from_dependency_reported() {
+    let fs = FsNode::Dir(
+        [
+            dir(
+                "parent",
+                [
+                    file(
+                        "qsharp.json",
+                        r#"{ "dependencies" : { "MyDep" : { "path" : "../child" } } }"#,
+                    ),
+                    dir("src", [file("main.qs", "function Main() : Unit {}")]),
+                ],
+            ),
+            dir(
+                "child",
+                [
+                    file("qsharp.json", "{}"),
+                    dir("src", [file("main.qs", "broken_syntax")]),
+                ],
+            ),
+        ]
+        .into_iter()
+        .collect(),
+    );
+
+    let fs = Rc::new(RefCell::new(fs));
+    let received_errors = RefCell::new(Vec::new());
+    let mut updater = new_updater_with_file_system(&received_errors, &fs);
+
+    // Triger a document update.
+    updater
+        .update_document("parent/src/main.qs", 1, "function Main() : Unit {}")
+        .await;
+
+    expect![[r#"
+        [
+            (
+                "child/src/main.qs",
+                None,
+                [
+                    Frontend(
+                        Error(
+                            Parse(
+                                Error(
+                                    ExpectedItem(
+                                        Ident,
+                                        Span {
+                                            lo: 26,
+                                            hi: 26,
+                                        },
+                                    ),
+                                ),
+                            ),
+                        ),
+                    ),
+                ],
+                [],
+            ),
+        ]
+    "#]]
+    .assert_debug_eq(&received_errors.borrow());
+}
+
+type ErrorInfo = (
+    String,
+    Option<u32>,
+    Vec<compile::ErrorKind>,
+    Vec<project::Error>,
+);
 
 fn new_updater(received_errors: &RefCell<Vec<ErrorInfo>>) -> CompilationStateUpdater<'_> {
     let diagnostic_receiver = move |update: DiagnosticUpdate| {
+        let project_errors = update.errors.iter().filter_map(|error| match error {
+            ErrorKind::Project(error) => Some(error.clone()),
+            ErrorKind::Compile(_) => None,
+        });
+        let compile_errors = update.errors.iter().filter_map(|error| match error {
+            ErrorKind::Compile(error) => Some(error.error().clone()),
+            ErrorKind::Project(_) => None,
+        });
+
         let mut v = received_errors.borrow_mut();
 
         v.push((
-            update.uri.to_string(),
+            update.uri,
             update.version,
-            update
-                .errors
-                .iter()
-                .map(|e| e.error().clone())
-                .collect::<Vec<_>>(),
+            compile_errors.collect(),
+            project_errors.collect(),
         ));
     };
 
@@ -1764,16 +1906,22 @@ fn new_updater_with_file_system<'a>(
     fs: &Rc<RefCell<FsNode>>,
 ) -> CompilationStateUpdater<'a> {
     let diagnostic_receiver = move |update: DiagnosticUpdate| {
+        let project_errors = update.errors.iter().filter_map(|error| match error {
+            ErrorKind::Project(error) => Some(error.clone()),
+            ErrorKind::Compile(_) => None,
+        });
+        let compile_errors = update.errors.iter().filter_map(|error| match error {
+            ErrorKind::Compile(error) => Some(error.error().clone()),
+            ErrorKind::Project(_) => None,
+        });
+
         let mut v = received_errors.borrow_mut();
 
         v.push((
-            update.uri.to_string(),
+            update.uri,
             update.version,
-            update
-                .errors
-                .iter()
-                .map(|e| e.error().clone())
-                .collect::<Vec<_>>(),
+            compile_errors.collect(),
+            project_errors.collect(),
         ));
     };
 
@@ -1835,14 +1983,15 @@ async fn check_lints_config(updater: &CompilationStateUpdater<'_>, expected_conf
     let manifest = updater
         .load_manifest(&"project/src/this_file.qs".into())
         .await
+        .expect("manifest should load successfully")
         .expect("manifest should exist");
 
-    let lints_config = manifest.manifest.lints;
+    let lints_config = manifest.lints;
 
     expected_config.assert_eq(&format!("{lints_config:#?}"));
 }
 
-fn check_lints(lints: &[ErrorKind], expected_lints: &Expect) {
+fn check_lints(lints: &[compile::ErrorKind], expected_lints: &Expect) {
     expected_lints.assert_eq(&format!("{lints:#?}"));
 }
 
diff --git a/language_service/src/test_utils.rs b/language_service/src/test_utils.rs
index d93d2b8e..efac1c9c 100644
--- a/language_service/src/test_utils.rs
+++ b/language_service/src/test_utils.rs
@@ -85,7 +85,8 @@ fn compile_project_with_fake_stdlib_and_markers_cursor_optional(
             package_store,
             user_package_id: package_id,
             kind: CompilationKind::OpenProject,
-            errors,
+            compile_errors: errors,
+            project_errors: Vec::new(),
         },
         cursor_location,
         target_spans,
@@ -109,7 +110,7 @@ where
 {
     let std_source_map = SourceMap::new(
         [(
-            "<std>".into(),
+            "qsharp-library-source:<std>".into(),
             "namespace FakeStdLib {
                 operation Fake() : Unit {}
                 operation FakeWithParam(x: Int) : Unit {}
@@ -148,8 +149,9 @@ where
     Compilation {
         package_store,
         user_package_id: package_id,
-        errors,
+        compile_errors: errors,
         kind: CompilationKind::Notebook,
+        project_errors: Vec::new(),
     }
 }
 
@@ -157,7 +159,7 @@ fn compile_fake_stdlib() -> (PackageStore, PackageId) {
     let mut package_store = PackageStore::new(compile::core());
     let std_source_map = SourceMap::new(
         [(
-            "<std>".into(),
+            "qsharp-library-source:<std>".into(),
             r#"namespace FakeStdLib {
                 operation Fake() : Unit {}
                 operation FakeWithParam(x : Int) : Unit {}
diff --git a/language_service/src/tests.rs b/language_service/src/tests.rs
index c4b616ee..93d010d1 100644
--- a/language_service/src/tests.rs
+++ b/language_service/src/tests.rs
@@ -3,12 +3,12 @@
 
 #![allow(clippy::needless_raw_string_hashes)]
 
-use crate::{protocol::DiagnosticUpdate, Encoding, LanguageService, UpdateWorker};
-use expect_test::{expect, Expect};
-use qsc::{
-    compile::{self, ErrorKind},
-    line_column::Position,
+use crate::{
+    protocol::{DiagnosticUpdate, ErrorKind},
+    Encoding, LanguageService, UpdateWorker,
 };
+use expect_test::{expect, Expect};
+use qsc::{compile, line_column::Position, project};
 use std::{cell::RefCell, rc::Rc};
 use test_fs::{dir, file, FsNode, TestProjectHost};
 
@@ -42,6 +42,7 @@ async fn single_document() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]]),
@@ -90,6 +91,7 @@ async fn single_document_update() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]]),
@@ -129,6 +131,7 @@ async fn single_document_update() {
                         1,
                     ),
                     [],
+                    [],
                 ),
             ]
         "#]]),
@@ -185,6 +188,7 @@ async fn document_in_project() {
                             ),
                         ),
                     ],
+                    [],
                 ),
             ]
         "#]],
@@ -275,7 +279,7 @@ async fn completions_requested_after_document_load() {
 
 fn check_errors_and_compilation(
     ls: &LanguageService,
-    received_errors: &mut Vec<(String, Option<u32>, Vec<ErrorKind>)>,
+    received_errors: &mut Vec<ErrorInfo>,
     uri: &str,
     expected_errors: &Expect,
     expected_compilation: &Expect,
@@ -287,7 +291,7 @@ fn check_errors_and_compilation(
 
 fn check_errors_and_no_compilation(
     ls: &LanguageService,
-    received_errors: &mut Vec<(String, Option<u32>, Vec<ErrorKind>)>,
+    received_errors: &mut Vec<ErrorInfo>,
     uri: &str,
     expected_errors: &Expect,
 ) {
@@ -306,7 +310,12 @@ fn assert_compilation(ls: &LanguageService, uri: &str, expected: &Expect) {
     expected.assert_debug_eq(&compilation.user_unit().sources);
 }
 
-type ErrorInfo = (String, Option<u32>, Vec<compile::ErrorKind>);
+type ErrorInfo = (
+    String,
+    Option<u32>,
+    Vec<compile::ErrorKind>,
+    Vec<project::Error>,
+);
 
 fn create_update_worker<'a>(
     ls: &mut LanguageService,
@@ -314,16 +323,22 @@ fn create_update_worker<'a>(
 ) -> UpdateWorker<'a> {
     let worker = ls.create_update_worker(
         |update: DiagnosticUpdate| {
+            let project_errors = update.errors.iter().filter_map(|error| match error {
+                ErrorKind::Project(error) => Some(error.clone()),
+                ErrorKind::Compile(_) => None,
+            });
+            let compile_errors = update.errors.iter().filter_map(|error| match error {
+                ErrorKind::Compile(error) => Some(error.error().clone()),
+                ErrorKind::Project(_) => None,
+            });
+
             let mut v = received_errors.borrow_mut();
 
             v.push((
-                update.uri.to_string(),
+                update.uri,
                 update.version,
-                update
-                    .errors
-                    .iter()
-                    .map(|e| e.error().clone())
-                    .collect::<Vec<_>>(),
+                compile_errors.collect(),
+                project_errors.collect(),
             ));
         },
         TestProjectHost {
diff --git a/language_service/src/tests/test_fs.rs b/language_service/src/tests/test_fs.rs
index 26de3c02..4af49e58 100644
--- a/language_service/src/tests/test_fs.rs
+++ b/language_service/src/tests/test_fs.rs
@@ -23,7 +23,7 @@ pub(crate) enum FsError {
 }
 
 impl FsNode {
-    pub fn read_file(&self, file: String) -> (Arc<str>, Arc<str>) {
+    pub fn read_file(&self, file: String) -> miette::Result<(Arc<str>, Arc<str>)> {
         let mut curr = Some(self);
 
         for part in file.split('/') {
@@ -34,8 +34,8 @@ impl FsNode {
         }
 
         match curr {
-            Some(FsNode::File(contents)) => (file.into(), contents.clone()),
-            Some(FsNode::Dir(_)) | None => (file.into(), "".into()),
+            Some(FsNode::File(contents)) => Ok((file.into(), contents.clone())),
+            Some(FsNode::Dir(_)) | None => Err(miette::Error::msg("file not found")),
         }
     }
 
@@ -152,7 +152,7 @@ impl FileSystem for FsNode {
     type Entry = JSFileEntry;
 
     fn read_file(&self, path: &std::path::Path) -> miette::Result<(Arc<str>, Arc<str>)> {
-        Ok(self.read_file(path.to_string_lossy().into()))
+        self.read_file(path.to_string_lossy().into())
     }
 
     fn list_directory(&self, path: &std::path::Path) -> miette::Result<Vec<Self::Entry>> {
@@ -166,6 +166,18 @@ impl FileSystem for FsNode {
     ) -> miette::Result<std::path::PathBuf> {
         Ok(Self::resolve_path(&base.to_string_lossy(), &path.to_string_lossy()).into())
     }
+
+    fn fetch_github(
+        &self,
+        _owner: &str,
+        _repo: &str,
+        _ref: &str,
+        _path: &str,
+    ) -> miette::Result<Arc<str>> {
+        Err(miette::Error::msg(
+            "github references not supported for this file system",
+        ))
+    }
 }
 
 pub(crate) fn dir<const COUNT: usize>(
@@ -185,7 +197,7 @@ pub(crate) struct TestProjectHost {
 
 #[async_trait(?Send)]
 impl JSProjectHost for TestProjectHost {
-    async fn read_file(&self, uri: &str) -> (Arc<str>, Arc<str>) {
+    async fn read_file(&self, uri: &str) -> miette::Result<(Arc<str>, Arc<str>)> {
         self.fs.borrow().read_file(uri.to_string())
     }
 
@@ -207,4 +219,14 @@ impl JSProjectHost for TestProjectHost {
             .find_manifest_directory(doc_uri)
             .map(|p| p.to_string_lossy().into())
     }
+
+    async fn fetch_github(
+        &self,
+        _owner: &str,
+        _repo: &str,
+        _ref: &str,
+        _path: &str,
+    ) -> miette::Result<Arc<str>> {
+        unimplemented!()
+    }
 }
diff --git a/pip/tests/test_project.py b/pip/tests/test_project.py
index d2ab97e6..3af2e3d7 100644
--- a/pip/tests/test_project.py
+++ b/pip/tests/test_project.py
@@ -9,12 +9,14 @@ import os
 def qsharp():
     import qsharp
     import qsharp._fs
+    import qsharp._http
 
     qsharp._fs.read_file = read_file_memfs
     qsharp._fs.list_directory = list_directory_memfs
     qsharp._fs.exists = exists_memfs
     qsharp._fs.join = join_memfs
     qsharp._fs.resolve = resolve_memfs
+    qsharp._http.fetch_github = fetch_github_test
 
     return qsharp
 
@@ -34,7 +36,7 @@ def test_project_compile_error(qsharp) -> None:
 def test_project_bad_qsharp_json(qsharp) -> None:
     with pytest.raises(Exception) as excinfo:
         qsharp.init(project_root="/bad_qsharp_json")
-    assert str(excinfo.value).startswith("Error parsing /bad_qsharp_json/qsharp.json")
+    assert str(excinfo.value).find("Failed to parse manifest") != -1
 
 
 def test_project_unreadable_qsharp_json(qsharp) -> None:
@@ -48,14 +50,35 @@ def test_project_unreadable_qsharp_json(qsharp) -> None:
 def test_project_unreadable_source(qsharp) -> None:
     with pytest.raises(Exception) as excinfo:
         qsharp.init(project_root="/unreadable_source")
-    assert str(excinfo.value).find("OSError: could not read test.qs") != -1
+    # If this seems like a silly substring to assert on, it's
+    # because the error reporting code is inserting a line break
+    # between "could not" and "read test.qs"
+    assert str(excinfo.value).find("OSError: could not") != -1
+
+
+def test_project_dependencies(qsharp) -> None:
+    qsharp.init(project_root="/with_deps")
+    result = qsharp.eval("Test.CallsDependency()")
+    assert result == 4
+
+
+def test_project_circular_dependency_error(qsharp) -> None:
+    with pytest.raises(Exception) as excinfo:
+        qsharp.init(project_root="/circular")
+    assert str(excinfo.value).find("Circular dependency detected between") != -1
+
+
+def test_github_dependency(qsharp) -> None:
+    qsharp.init(project_root="/with_github_dep")
+    result = qsharp.eval("Test.CallsDependency()")
+    assert result == 12
 
 
 memfs = {
     "": {
         "good": {
             "src": {
-                "test.qs": "namespace Test { operation ReturnsFour() : Int { 4 } }",
+                "test.qs": "namespace Test { operation ReturnsFour() : Int { 4 } export ReturnsFour; }",
             },
             "qsharp.json": "{}",
         },
@@ -75,10 +98,69 @@ memfs = {
             },
             "qsharp.json": "{}",
         },
+        "with_deps": {
+            "src": {
+                # TODO(packages) When package aliases are taken into account,
+                # the below call should become: `Foo.Test.ReturnsFour()`
+                "test.qs": "namespace Test { operation CallsDependency() : Int { return Test.ReturnsFour(); } }",
+            },
+            "qsharp.json": """
+                {
+                    "dependencies": {
+                        "Foo": {
+                            "path": "../good"
+                        }
+                    }
+                }""",
+        },
+        "circular": {
+            "src": {
+                "test.qs": "namespace Test {}",
+            },
+            "qsharp.json": """
+                {
+                    "dependencies": {
+                        "Foo": {
+                            "path": "../circular"
+                        }
+                    }
+                }""",
+        },
+        "with_github_dep": {
+            "src": {
+                # TODO(packages) When package aliases are taken into account,
+                # the below call should become: `Foo.Test.ReturnsTwelve()`
+                "test.qs": "namespace Test { operation CallsDependency() : Int { return Test.ReturnsTwelve(); } }",
+            },
+            "qsharp.json": """
+                {
+                    "dependencies": {
+                        "Foo": {
+                            "github" : {
+                                "owner" : "test-owner",
+                                "repo" : "test-repo",
+                                "ref" : "12345"
+                            }
+                        }
+                    }
+                }""",
+        },
     }
 }
 
 
+def fetch_github_test(owner: str, repo: str, ref: str, path: str):
+    match (owner, repo, ref, path):
+        case ("test-owner", "test-repo", "12345", "/qsharp.json"):
+            return """{ "files" : ["src/test.qs"] }"""
+        case ("test-owner", "test-repo", "12345", "/src/test.qs"):
+            return "namespace Test { operation ReturnsTwelve() : Int { 12 } }"
+        case _:
+            raise Exception(
+                f"Unexpected fetch_github call: {owner}, {repo}, {ref}, {path}"
+            )
+
+
 def read_file_memfs(path):
     global memfs
     item = memfs
diff --git a/vscode/test/suites/extensionUtils.ts b/vscode/test/suites/extensionUtils.ts
index 6f762a6f..736513ee 100644
--- a/vscode/test/suites/extensionUtils.ts
+++ b/vscode/test/suites/extensionUtils.ts
@@ -16,6 +16,17 @@ export async function activateExtension() {
 
   const start = performance.now();
   const extensionApi: ExtensionApi = await ext.activate();
+
+  // http://localhost:3000/static/mount is set up by the @vscode/test-web
+  // test infrastructure. This local webserver is normally set up to serve
+  // files for the test workspace. We're taking advantage of it here to
+  // also act as a a fake github endpoint.
+  //
+  // /web/github is a folder in the test workspace.
+  extensionApi.setGithubEndpoint(
+    "http://localhost:3000/static/mount/web/github",
+  );
+
   const logForwarder = extensionApi.logging;
   if (!logForwarder) {
     throw new Error(`qsharp-tests: extension did not return a log forwarder`);
diff --git a/vscode/test/suites/language-service/language-service.test.ts b/vscode/test/suites/language-service/language-service.test.ts
index 2651dacb..6fa77da2 100644
--- a/vscode/test/suites/language-service/language-service.test.ts
+++ b/vscode/test/suites/language-service/language-service.test.ts
@@ -12,10 +12,38 @@ suite("Q# Language Service Tests", function suite() {
 
   const workspaceFolderUri = workspaceFolder.uri;
   const testQs = vscode.Uri.joinPath(workspaceFolderUri, "test.qs");
+  const projectMainDocUri = vscode.Uri.joinPath(
+    workspaceFolderUri,
+    "packages",
+    "MainPackage",
+    "src",
+    "Main.qs",
+  );
+  const projectDepDocUri = vscode.Uri.joinPath(
+    workspaceFolderUri,
+    "packages",
+    "DepPackage",
+    "src",
+    "Main.qs",
+  );
   const noErrorsQs = vscode.Uri.joinPath(workspaceFolderUri, "no-errors.qs");
 
   this.beforeAll(async () => {
     await activateExtension();
+
+    // Pre-open the text documents that are going to be interacted with in
+    // the tests. This just gives the language a service a bit of time to load
+    // fully in the background before the test cases run.
+    //
+    // This isn't great, but we don't currently have a way to await background
+    // language service tasks in tests.
+    // This is the best we can do to ensure the features have been initialized
+    // before we start testing.
+    await vscode.workspace.openTextDocument(testQs);
+    await vscode.workspace.openTextDocument(projectMainDocUri);
+    await vscode.workspace.openTextDocument(noErrorsQs);
+    // Give the language service a tiny bit of time to settle
+    await new Promise((resolve) => setTimeout(resolve, 50));
   });
 
   test("Q# language is registered", async () => {
@@ -174,4 +202,83 @@ suite("Q# Language Service Tests", function suite() {
       assert.include(doc.getText(lens.range), "function Test()");
     }
   });
+
+  test("Package dependencies", async () => {
+    const doc = await vscode.workspace.openTextDocument(projectMainDocUri);
+    vscode.commands.executeCommand("workbench.action.problems.focus");
+
+    // No errors if package dependencies are properly resolved
+    const actualDiagnostics =
+      vscode.languages.getDiagnostics(projectMainDocUri);
+
+    assert.isEmpty(
+      actualDiagnostics,
+      `Expected no diagnostics, but got ${JSON.stringify(actualDiagnostics)}`,
+    );
+
+    // Sanity check the test setup - is this the correct position?
+    const text = doc.getText(
+      new vscode.Range(new vscode.Position(2, 8), new vscode.Position(2, 24)),
+    );
+    assert.equal(
+      text,
+      "MyDep.MyFunction",
+      `${projectMainDocUri.fsPath} file contents don't match expected`,
+    );
+
+    // Verify go-to-definition works across packages
+    const actualDefinition = (await vscode.commands.executeCommand(
+      "vscode.executeDefinitionProvider",
+      projectMainDocUri,
+      new vscode.Position(2, 18), // cursor on the usage of "MyFunction"
+    )) as vscode.Location[];
+
+    // Returned location should be in DepPackage on the definition of "MyFunction"
+    assert.lengthOf(
+      actualDefinition,
+      1,
+      "Expected to find one definition for MyFunction",
+    );
+    const location = actualDefinition[0];
+    assert.equal(location.uri.toString(), projectDepDocUri.toString());
+    assert.equal(location.range.start.line, 1);
+    assert.equal(location.range.start.character, 13);
+  });
+
+  test("Web package dependencies", async () => {
+    const doc = await vscode.workspace.openTextDocument(projectMainDocUri);
+    vscode.commands.executeCommand("workbench.action.problems.focus");
+
+    // Sanity check the test setup - is this the correct position?
+    const text = doc.getText(
+      new vscode.Range(new vscode.Position(3, 8), new vscode.Position(3, 26)),
+    );
+    assert.equal(text, "Library.MyFunction");
+
+    // Verify go-to-definition works across packages
+    const actualDefinition = (await vscode.commands.executeCommand(
+      "vscode.executeDefinitionProvider",
+      projectMainDocUri,
+      new vscode.Position(3, 20), // cursor on the usage of "MyFunction"
+    )) as vscode.Location[];
+
+    // Returned location should be in the web dependency on the definition of "MyFunction"
+    assert.lengthOf(
+      actualDefinition,
+      1,
+      "Expected to find one definition for MyFunction",
+    );
+    const location = actualDefinition[0];
+    assert.equal(
+      location.uri.toString(),
+      "qsharp-github-source:test-owner/test-repo/test-ref/src/Main.qs",
+    );
+    assert.equal(location.range.start.line, 1);
+    assert.equal(location.range.start.character, 13);
+
+    // No errors if package dependencies are properly resolved
+    const actualDiagnostics =
+      vscode.languages.getDiagnostics(projectMainDocUri);
+    assert.isEmpty(actualDiagnostics);
+  });
 });
diff --git a/vscode/test/suites/language-service/test-workspace/packages/DepPackage/qsharp.json b/vscode/test/suites/language-service/test-workspace/packages/DepPackage/qsharp.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/vscode/test/suites/language-service/test-workspace/packages/DepPackage/qsharp.json
@@ -0,0 +1,1 @@
+{}
diff --git a/vscode/test/suites/language-service/test-workspace/packages/DepPackage/src/Main.qs b/vscode/test/suites/language-service/test-workspace/packages/DepPackage/src/Main.qs
new file mode 100644
index 00000000..f9b93505
--- /dev/null
+++ b/vscode/test/suites/language-service/test-workspace/packages/DepPackage/src/Main.qs
@@ -0,0 +1,4 @@
+namespace MyDep {
+    function MyFunction() : Unit {}
+    export MyFunction;
+}
diff --git a/vscode/test/suites/language-service/test-workspace/packages/MainPackage/qsharp.json b/vscode/test/suites/language-service/test-workspace/packages/MainPackage/qsharp.json
new file mode 100644
index 00000000..95dd5ca6
--- /dev/null
+++ b/vscode/test/suites/language-service/test-workspace/packages/MainPackage/qsharp.json
@@ -0,0 +1,14 @@
+{
+  "dependencies": {
+    "MyDep": {
+      "path": "../DepPackage"
+    },
+    "GitHubDep": {
+      "github": {
+        "owner": "test-owner",
+        "repo": "test-repo",
+        "ref": "test-ref"
+      }
+    }
+  }
+}
diff --git a/vscode/test/suites/language-service/test-workspace/packages/MainPackage/src/Main.qs b/vscode/test/suites/language-service/test-workspace/packages/MainPackage/src/Main.qs
new file mode 100644
index 00000000..ad15a416
--- /dev/null
+++ b/vscode/test/suites/language-service/test-workspace/packages/MainPackage/src/Main.qs
@@ -0,0 +1,7 @@
+namespace Main {
+    function Main() : Unit {
+        MyDep.MyFunction();
+        Library.MyFunction();
+        // TODO(packages): these need to use aliases when alias support arrives
+    }
+}
diff --git a/vscode/test/suites/language-service/test-workspace/web/github/test-owner/test-repo/test-ref/qsharp.json b/vscode/test/suites/language-service/test-workspace/web/github/test-owner/test-repo/test-ref/qsharp.json
new file mode 100644
index 00000000..15eb8442
--- /dev/null
+++ b/vscode/test/suites/language-service/test-workspace/web/github/test-owner/test-repo/test-ref/qsharp.json
@@ -0,0 +1,3 @@
+{
+  "files": ["src/Main.qs"]
+}
diff --git a/vscode/test/suites/language-service/test-workspace/web/github/test-owner/test-repo/test-ref/src/Main.qs b/vscode/test/suites/language-service/test-workspace/web/github/test-owner/test-repo/test-ref/src/Main.qs
new file mode 100644
index 00000000..3a2c929f
--- /dev/null
+++ b/vscode/test/suites/language-service/test-workspace/web/github/test-owner/test-repo/test-ref/src/Main.qs
@@ -0,0 +1,4 @@
+namespace Library {
+    function MyFunction() : Unit {}
+    export MyFunction;
+}
diff --git a/wasm/src/tests.rs b/wasm/src/tests.rs
index 451f249e..b7f387d8 100644
--- a/wasm/src/tests.rs
+++ b/wasm/src/tests.rs
@@ -449,7 +449,7 @@ fn test_runtime_error_default_span() {
         1,
     )
     .expect("code should compile and run");
-    expect![[r#"{"result":{"code":"Qsc.Eval.UserFail","message":"runtime error: program failed: Cannot allocate qubit array with a negative length","range":{"end":{"character":1,"line":0},"start":{"character":0,"line":0}},"related":[{"location":{"source":"core/qir.qs","span":{"end":{"character":69,"line":14},"start":{"character":12,"line":14}}},"message":"explicit fail"}],"severity":"error"},"success":false,"type":"Result"}"#]]
+    expect![[r#"{"result":{"code":"Qsc.Eval.UserFail","message":"runtime error: program failed: Cannot allocate qubit array with a negative length","range":{"end":{"character":1,"line":0},"start":{"character":0,"line":0}},"related":[{"location":{"source":"qsharp-library-source:core/qir.qs","span":{"end":{"character":69,"line":14},"start":{"character":12,"line":14}}},"message":"explicit fail"}],"severity":"error"},"success":false,"type":"Result"}"#]]
     .assert_eq(&output.join("\n"));
 }