Skip to content

feat: build workspace context then resolve dependencies#68

Open
9romise wants to merge 45 commits intomainfrom
resolve
Open

feat: build workspace context then resolve dependencies#68
9romise wants to merge 45 commits intomainfrom
resolve

Conversation

@9romise
Copy link
Member

@9romise 9romise commented Mar 8, 2026

  • Introduces WorkspaceContext, a per workspace-folder context that manages package manager detection, catalog loading, and dependency resolution
  • Rename extractors to JsonExtractor / YamlExtractor, selected by file extension; extractors only parse raw dependency info.
  • Unified resolution for all dependency protocols, including catalog resolution, close Support catalog #12, close feat: add catalogs support #56
  • All providers refactored to consume ResolvedDependencyInfo from WorkspaceContext now.

@9romise 9romise changed the title Resolve refactor: build workspace context then resolve dependencies Mar 8, 2026
9romise added 12 commits March 8, 2026 22:01
# Conflicts:
#	src/providers/diagnostics/rules/upgrade.ts
# Conflicts:
#	src/commands/open-file-in-npmx.ts
#	src/constants.ts
#	src/providers/completion-item/index.ts
#	src/providers/completion-item/version.ts
#	src/providers/diagnostics/index.ts
#	src/providers/diagnostics/rules/engine-mismatch.ts
#	src/utils/document.ts
#	src/utils/file.ts
#	src/utils/resolve.ts
#	tests/diagnostics/context.ts
@9romise 9romise changed the title refactor: build workspace context then resolve dependencies feat: build workspace context then resolve dependencies Mar 9, 2026
@9romise 9romise marked this pull request as ready for review March 9, 2026 08:35
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 9, 2026

📝 Walkthrough

Walkthrough

This PR adds workspace-aware dependency resolution for package.json, pnpm-workspace.yaml and .yarnrc.yml by introducing SUPPORTED_DOCUMENT_PATTERN, new JsonExtractor and YamlExtractor, ResolvedDependencyInfo types, and workspace context utilities (getWorkspaceContext, getResolvedDependencies, getResolvedDependencyByOffset, useWorkspaceContext). It consolidates provider registrations to a single pattern, replaces extractor-driven parsing with resolved dependency flows, adds resolveDependencySpec and workspace utilities, removes legacy extractors/parsers, and updates diagnostics, hover, completion, document-links and tests to operate on resolved dependency information.

Possibly related PRs

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description clearly describes workspace context introduction, extractor renaming, unified dependency protocol resolution including catalog support, and provider refactoring.
Linked Issues check ✅ Passed The PR implements catalog resolution (#12, #56) through WorkspaceContext and unified ResolvedDependencyInfo, supporting catalog: specs in pnpm-workspace.yaml and .yarnrc.yml files.
Out of Scope Changes check ✅ Passed All changes align with stated objectives: WorkspaceContext, JsonExtractor/YamlExtractor refactoring, unified dependency resolution, and provider refactoring. No unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch resolve

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/providers/diagnostics/rules/upgrade.ts (1)

34-46: ⚠️ Potential issue | 🟡 Minor

Pick the highest matching pre-release before returning.

This loop returns the first matching dist-tag, but dist-tag iteration order is not a version sort. If two tags point at different -beta/-rc builds, the diagnostic can suggest an older pre-release even when a newer one is available.

Suggested fix
-  for (const [tag, tagVersion] of Object.entries(distTags)) {
+  let bestTagVersion: string | undefined
+  let bestTargetVersion: string | undefined
+  for (const [tag, tagVersion] of Object.entries(distTags)) {
     if (tag === 'latest')
       continue
     if (prerelease(tagVersion)?.[0] !== currentPreId)
       continue
     if (lte(tagVersion, resolvedVersion))
       continue
     const targetVersion = formatUpgradeVersion(dep, tagVersion)
     if (checkIgnored({ ignoreList, name: resolvedName, version: targetVersion }))
       continue
-
-    return targetVersion
+    if (!bestTagVersion || gt(tagVersion, bestTagVersion)) {
+      bestTagVersion = tagVersion
+      bestTargetVersion = targetVersion
+    }
   }
+
+  return bestTargetVersion
src/providers/diagnostics/index.ts (1)

59-123: ⚠️ Potential issue | 🟠 Major

Make each collection run cancellable per document.

isStale() only keys off document.version, but this function is also re-entered when enabledRules or watched files change without editing the document. Because collect() and runRule() are launched fire-and-forget, an older run can still flush after a newer run has started and reintroduce stale diagnostics.

Suggested fix
 export function useDiagnostics() {
   const diagnosticCollection = useDisposable(languages.createDiagnosticCollection(displayName))
+  const collectRunIds = new WeakMap<TextDocument, number>()
 
   const activeEditor = useActiveTextEditor()
   const activeDocumentText = useDocumentText(() => activeEditor.value?.document)
@@
   async function collectDiagnostics(document: TextDocument) {
+    const runId = (collectRunIds.get(document) ?? 0) + 1
+    collectRunIds.set(document, runId)
     await nextTick()
     logger.info(`[diagnostics] collect: ${document.uri.path}`)
     diagnosticCollection.set(document.uri, [])
@@
     const targetVersion = document.version
+    const isSuperseded = () => collectRunIds.get(document) !== runId || isStale(document, targetVersion)
+    if (isSuperseded())
+      return
+
     const dependencies = await getResolvedDependencies(document.uri)
-    if (!dependencies)
+    if (!dependencies || isSuperseded())
       return
 
     const diagnostics: Diagnostic[] = []
 
     const flush = debounce(() => {
-      if (isStale(document, targetVersion))
+      if (isSuperseded())
         return
 
       diagnosticCollection.set(document.uri, [...diagnostics])
@@
     const runRule = async (rule: DiagnosticRule, ctx: DiagnosticContext) => {
       try {
         const diagnostic = await rule(ctx)
-        if (isStale(document, targetVersion))
+        if (isSuperseded())
           return
         if (!diagnostic)
           return
@@
     const collect = async (dep: ResolvedDependencyInfo) => {
       try {
         const pkg = await dep.packageInfo()
-        if (!pkg || isStale(document, targetVersion))
+        if (!pkg || isSuperseded())
           return
 
         for (const rule of rules) {
-          runRule(rule, { uri: document.uri, dep, pkg })
+          if (isSuperseded())
+            return
+          void runRule(rule, { uri: document.uri, dep, pkg })
         }
       } catch (err) {
         logger.warn(`[diagnostics] fail to check ${dep.rawName}: ${err}`)
@@
     // fire-and-forget to progressively display diagnostics as each dep resolves, rather than awaiting all
     for (const dep of dependencies) {
-      collect(dep)
+      if (isSuperseded())
+        break
+      void collect(dep)
     }
   }

Also applies to: 125-143

🧹 Nitpick comments (6)
tests/utils/memoize.test.ts (1)

110-115: Use the explicit no-TTL mode in this new test.

Line 115 is exercising invalidation, not expiry. Switching this case to ttl: false makes the test depend on the public contract instead of the current falsy-0 implementation detail.

Suggested tweak
-    const memoized = memoize(fn, { ttl: 0 })
+    const memoized = memoize(fn, { ttl: false })
src/extractors/yaml.ts (1)

21-24: Consider defensive handling for missing range.

The node.range! non-null assertion assumes the YAML node always has a range. Whilst this is true for nodes parsed from text, it could be undefined for programmatically created nodes. Since this extractor is specifically for parsing text (via parseDocument), this is likely safe in practice, but a defensive check could prevent unexpected errors if the API is used differently in future.

♻️ Optional defensive fix
  `#getScalarRange`(node: YamlNode): OffsetRange {
-   const [start, end] = node.range!
+   const range = node.range ?? [0, 0]
+   const [start, end] = range
    return [start, end]
  }
src/constants.ts (1)

5-5: Add a regression test for the shared document glob.

This selector now gates hover, completion, and code actions for every supported manifest. A small matcher-level test covering package.json, pnpm-workspace.yaml, and .yarnrc.yml would catch a silent break here before three features go dark.

tests/utils/version.test.ts (1)

6-23: Cover the protocol-aware branches here.

The PR’s main behaviour change is resolution across protocols, but this table only exercises plain specs and npm: aliases. Please add cases for workspace:, catalog:, and file:/link: inputs so regressions in the new paths are caught.

tests/utils/package-manager.test.ts (1)

34-45: Please add the default-to-npm case as well.

This matrix verifies explicit detection signals, but not the fallback branch when neither packageManager nor workspace files are present. That default is part of the new detection contract and is easy to regress during future refactors.

tests/utils/dependency.test.ts (1)

4-80: Add workspace: coverage to this suite.

The new resolution path is now workspace-aware, but these tests still skip workspace: specifiers entirely. A regression in workspace:*, workspace:^, or aliased workspace refs would slip through even though that is a core branch of this PR.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 967a24a0-f338-4757-a00e-5ee8af0692bd

📥 Commits

Reviewing files that changed from the base of the PR and between e6bee8c and d9feee0.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (62)
  • README.md
  • eslint.config.js
  • package.json
  • pnpm-workspace.yaml
  • src/composables/workspace-context.ts
  • src/constants.ts
  • src/extractors/index.ts
  • src/extractors/json.ts
  • src/extractors/package-json.ts
  • src/extractors/pnpm-workspace-yaml.ts
  • src/extractors/yaml.ts
  • src/index.ts
  • src/providers/code-actions/index.ts
  • src/providers/completion-item/index.ts
  • src/providers/completion-item/version.ts
  • src/providers/diagnostics/index.ts
  • src/providers/diagnostics/rules/deprecation.ts
  • src/providers/diagnostics/rules/dist-tag.ts
  • src/providers/diagnostics/rules/engine-mismatch.ts
  • src/providers/diagnostics/rules/replacement.ts
  • src/providers/diagnostics/rules/upgrade.ts
  • src/providers/diagnostics/rules/vulnerability.ts
  • src/providers/document-link/index.ts
  • src/providers/document-link/npmx.ts
  • src/providers/hover/index.ts
  • src/providers/hover/npmx.ts
  • src/types/context.ts
  • src/types/extractor.ts
  • src/utils/ast.ts
  • src/utils/dependency.ts
  • src/utils/file.ts
  • src/utils/memoize.ts
  • src/utils/package-manager.ts
  • src/utils/package.ts
  • src/utils/shared.ts
  • src/utils/version.ts
  • src/utils/workspace.ts
  • tests/__setup__/index.ts
  • tests/diagnostics/context.ts
  • tests/diagnostics/engine-mismatch.test.ts
  • tests/diagnostics/upgrade.test.ts
  • tests/fixtures/workspace/dirty-doc/package.json
  • tests/fixtures/workspace/dirty-doc/packages/app/package.json
  • tests/fixtures/workspace/minimal/package.json
  • tests/fixtures/workspace/package-manager-npm/.yarnrc.yml
  • tests/fixtures/workspace/package-manager-npm/package.json
  • tests/fixtures/workspace/package-manager-npm/pnpm-workspace.yaml
  • tests/fixtures/workspace/package-manager-pnpm/package.json
  • tests/fixtures/workspace/package-manager-pnpm/pnpm-workspace.yaml
  • tests/fixtures/workspace/package-manager-yarn/.yarnrc.yml
  • tests/fixtures/workspace/package-manager-yarn/package.json
  • tests/fixtures/workspace/pnpm-workspace/package.json
  • tests/fixtures/workspace/pnpm-workspace/packages/app/package.json
  • tests/fixtures/workspace/pnpm-workspace/packages/core/package.json
  • tests/fixtures/workspace/pnpm-workspace/pnpm-workspace.yaml
  • tests/utils/dependency.test.ts
  • tests/utils/memoize.test.ts
  • tests/utils/package-manager.test.ts
  • tests/utils/version.test.ts
  • tsconfig.json
  • tsdown.config.ts
  • vitest.config.ts
💤 Files with no reviewable changes (3)
  • src/utils/package.ts
  • src/extractors/package-json.ts
  • src/extractors/pnpm-workspace-yaml.ts

Comment on lines +44 to +56
export const checkReplacement: DiagnosticRule = async ({ dep: { nameRange, resolvedName } }) => {
if (checkIgnored({ ignoreList: config.ignore.replacement, name: resolvedName }))
return

const replacement = await getReplacement(name)
const replacement = await getReplacement(resolvedName)
if (!replacement)
return

const { message, link } = getReplacementInfo(replacement)

return {
node: dep.nameNode,
message: `"${name}" ${message}`,
range: nameRange,
message: `"${resolvedName}" ${message}`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Do not lose the raw manifest name for aliases.

Lines 44-56 now ignore and report against resolvedName only, while nameRange still points at the raw key. For an aliased dependency such as foo: npm:bar@..., an ignore entry for foo will stop working and the warning text will no longer match the underlined token.

Suggested fix
-export const checkReplacement: DiagnosticRule = async ({ dep: { nameRange, resolvedName } }) => {
-  if (checkIgnored({ ignoreList: config.ignore.replacement, name: resolvedName }))
+export const checkReplacement: DiagnosticRule = async ({ dep: { nameRange, rawName, resolvedName } }) => {
+  if (
+    checkIgnored({ ignoreList: config.ignore.replacement, name: rawName })
+    || checkIgnored({ ignoreList: config.ignore.replacement, name: resolvedName })
+  )
     return
@@
   return {
     range: nameRange,
-    message: `"${resolvedName}" ${message}`,
+    message: rawName === resolvedName
+      ? `"${rawName}" ${message}`
+      : `"${rawName}" (alias of "${resolvedName}") ${message}`,
     severity: DiagnosticSeverity.Warning,

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
src/composables/workspace-context.ts (2)

10-15: ⚠️ Potential issue | 🟡 Minor

Dispose the workspace folder listener.

The workspace.onDidChangeWorkspaceFolders subscription is not wrapped in useDisposable(), unlike the other subscriptions in this file. If useWorkspaceContext() is initialised multiple times, duplicate handlers will accumulate.

Proposed fix
 export function useWorkspaceContext() {
-  workspace.onDidChangeWorkspaceFolders(({ removed }) => {
+  useDisposable(workspace.onDidChangeWorkspaceFolders(({ removed }) => {
     removed.forEach((folder) => {
       getWorkspaceContext.delete(folder.uri)
       logger.info(`[workspace-context] delete workspace folder cache: ${folder.uri.path}`)
     })
-  })
+  }))

,


17-28: ⚠️ Potential issue | 🟠 Major

Workspace-level metadata changes require broader cache invalidation.

When workspace configuration files (pnpm-workspace.yaml, .yarnrc.yml, root package.json) change, only the individual URI cache entry is deleted. However, data from these files (package manager detection, catalog definitions) feeds the entire WorkspaceContext and affects resolution of all dependencies in the workspace.

Deleting only loadPackageManifestInfo.delete(uri) / loadWorkspaceCatalogInfo.delete(uri) leaves sibling manifests resolved against stale package-manager or catalog data until each file is individually touched.

Consider invalidating the entire WorkspaceContext when workspace-level configuration files change, or at minimum clearing all cached manifest info within that workspace.

,


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6a5b07a6-0d00-40a0-844d-440fe6153eda

📥 Commits

Reviewing files that changed from the base of the PR and between d9feee0 and a8fe3e5.

📒 Files selected for processing (1)
  • src/composables/workspace-context.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
src/providers/diagnostics/rules/deprecation.ts (1)

25-29: ⚠️ Potential issue | 🟡 Minor

Link the deprecated version you actually reported.

The message is based on resolvedVersion, but the target URL is still built from resolvedSpec. For tags and ranges that can open a different release from the one marked as deprecated.

💡 Suggested fix
     code: {
       value: 'deprecation',
-      target: Uri.parse(npmxPackageUrl(resolvedName, resolvedSpec)),
+      target: Uri.parse(npmxPackageUrl(resolvedName, resolvedVersion)),
     },
src/providers/hover/npmx.ts (1)

39-42: ⚠️ Potential issue | 🟡 Minor

Link provenance to the version you verified.

The provenance check is performed against resolvedVersion, but the hover link still points at resolvedSpec. For tags and ranges that can open a different release from the one just marked as verified.

💡 Suggested fix
         const resolvedVersion = await dep.resolvedVersion()
         if (resolvedVersion && pkg.versionsMeta[resolvedVersion]?.provenance)
           // npmx.dev can resolve ranges and tags version specifier
-          md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmxPackageUrl(resolvedName, resolvedSpec)}#provenance)\n\n`)
+          md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmxPackageUrl(resolvedName, resolvedVersion)}#provenance)\n\n`)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 84201ddf-328e-411e-8c6d-479ab77f3841

📥 Commits

Reviewing files that changed from the base of the PR and between a8fe3e5 and b886fc3.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (9)
  • package.json
  • pnpm-workspace.yaml
  • src/composables/workspace-context.ts
  • src/extractors/json.ts
  • src/providers/diagnostics/rules/deprecation.ts
  • src/providers/hover/npmx.ts
  • src/utils/ast.ts
  • src/utils/memoize.ts
  • src/utils/workspace.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/composables/workspace-context.ts
  • src/utils/ast.ts
  • package.json
  • pnpm-workspace.yaml

Comment on lines +89 to +100
const name = this.#getStringValue(root, 'name')
const version = this.#getStringValue(root, 'version')
if (!name || !version)
return

return {
name,
version,
packageManager: this.#getStringValue(root, 'packageManager'),
engines: this.#getEngines(root),
dependencies: this.getDependenciesInfo(root),
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not short-circuit dependency extraction on missing manifest metadata.

Many private apps and workspace roots omit version, and some also omit name. Returning undefined here means WorkspaceContext.loadPackageManifestInfo() never exposes their dependency list, so hover/diagnostics/completions disappear for otherwise valid dependency blocks. If PackageManifestInfo needs required name/version, this likely wants a separate dependency-only result instead of an early return.

Comment on lines +31 to +40
static async create(folder: WorkspaceFolder): Promise<WorkspaceContext> {
const ctx = new WorkspaceContext(folder)
ctx.packageManager = await detectPackageManager(folder)

if (ctx.packageManager !== 'npm') {
const workspaceFilename = workspaceFileMapping[ctx.packageManager]
const workspaceFile = Uri.joinPath(folder.uri, workspaceFilename)
if (await accessOk(workspaceFile))
ctx.catalogs = (await ctx.loadWorkspaceCatalogInfo(workspaceFile))?.catalogs
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Invalidate the folder context when workspace metadata changes.

WorkspaceContext.create() snapshots packageManager and catalogs, and getWorkspaceContextByFolder() caches that instance indefinitely. With the current watcher wiring in src/composables/workspace-context.ts:17-41, file changes only clear the per-URI extractor caches, so edits to package.json, pnpm-workspace.yaml, or .yarnrc.yml keep resolving against stale workspace state until the window is reloaded.

Also applies to: 130-137

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support catalog

1 participant