Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@
"module-replacements": "catalog:test",
"msw": "catalog:test",
"nano-staged": "catalog:dev",
"ocache": "catalog:inline",
"ofetch": "catalog:inline",
"pathe": "catalog:inline",
"perfect-debounce": "catalog:inline",
Expand Down
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ catalogs:
inline:
fast-npm-meta: ^1.3.0
jsonc-parser: ^3.3.1
ocache: ^0.1.2
ofetch: ^2.0.0-alpha.3
pathe: ^2.0.3
perfect-debounce: ^2.1.0
Expand Down
9 changes: 7 additions & 2 deletions src/utils/api/package.ts → src/api/package.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { MaybeError, PackageVersionsInfoWithMetadata } from 'fast-npm-meta'
import { CACHE_MAX_AGE_ONE_DAY } from '#constants'
import { logger } from '#state'
import { createBatchRunner } from '#utils/batch'
import { getVersionsBatch } from 'fast-npm-meta'
import { memoize } from '../memoize'
import { defineCachedFunction } from 'ocache'

const BATCH_SIZE = 20

Expand Down Expand Up @@ -70,4 +71,8 @@ const getPackageInfoBatch = createBatchRunner<string, PackageInfo | null>({
*
* @see https://github.com/antfu/fast-npm-meta
*/
export const getPackageInfo = memoize<string, Promise<PackageInfo | null>>(async (name) => getPackageInfoBatch(name))
export const getPackageInfo = defineCachedFunction<PackageInfo | null, [string]>(async (name) => getPackageInfoBatch(name), {
name: 'package',
getKey: (name) => name,
maxAge: CACHE_MAX_AGE_ONE_DAY,
})
22 changes: 22 additions & 0 deletions src/api/replacement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ModuleReplacement } from 'module-replacements'
import { CACHE_MAX_AGE_ONE_DAY, NPMX_DEV_API } from '#constants'
import { logger } from '#state'
import { encodePackageName } from '#utils/package'
import { defineCachedFunction } from 'ocache'
import { ofetch } from 'ofetch'

export const getReplacement = defineCachedFunction<ModuleReplacement | null, [string]>(async (name) => {
logger.info(`[replacement] fetching for ${name}`)
const encodedName = encodePackageName(name)

const result = await ofetch<ModuleReplacement | undefined>(`${NPMX_DEV_API}/replacements/${encodedName}`, {
ignoreResponseError: true,
}) ?? null
logger.info(`[replacement] fetched for ${name}`)

return result
}, {
name: 'replacement',
getKey: (name) => name,
maxAge: CACHE_MAX_AGE_ONE_DAY,
})
23 changes: 12 additions & 11 deletions src/utils/api/vulnerability.ts → src/api/vulnerability.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { NPMX_DEV_API } from '#constants'
import { CACHE_MAX_AGE_ONE_DAY, NPMX_DEV_API } from '#constants'
import { logger } from '#state'
import { encodePackageName, formatPackageId } from '#utils/package'
import { defineCachedFunction } from 'ocache'
import { ofetch } from 'ofetch'
import { memoize } from '../memoize'
import { encodePackageName, formatPackageId } from '../package'

/**
* Severity levels in priority order (highest first)
Expand Down Expand Up @@ -89,17 +89,18 @@ export interface VulnerabilityTreeResult {
}
}

export const getVulnerability = memoize<{
name: string
version: string
}, Promise<VulnerabilityTreeResult>>(async ({ name, version }) => {
logger.info(`Fetching vulnerabilities for ${formatPackageId(name, version)}`)
export const getVulnerability = defineCachedFunction<VulnerabilityTreeResult | null, [name: string, version: string]>(async (name, version) => {
logger.info(`[vulnerability] fetching for ${formatPackageId(name, version)}`)
const encodedName = encodePackageName(name)

const result = await ofetch(`${NPMX_DEV_API}/registry/vulnerabilities/${encodedName}/v/${version}`)
logger.info(`Fetched vulnerabilities for ${name}`)
const result = await ofetch(`${NPMX_DEV_API}/registry/vulnerabilities/${encodedName}/v/${version}`, {
ignoreResponseError: true,
}) ?? null
logger.info(`[vulnerability] fetched for ${name}`)

return result
}, {
getKey: ({ name, version }) => formatPackageId(name, version),
name: 'vulnerability',
getKey: (name, version) => formatPackageId(name, version),
maxAge: CACHE_MAX_AGE_ONE_DAY,
})
Comment thread
9romise marked this conversation as resolved.
5 changes: 2 additions & 3 deletions src/composables/workspace-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,8 @@ export function useWorkspaceContext() {
if (!ctx)
return

ctx.loadPackageManifestInfo.delete(uri)
ctx.loadWorkspaceCatalogInfo.delete(uri)
logger.info(`[workspace-context] delete dependencies cache: ${uri.path}`)
ctx.invalidateDependencyInfo(uri)
logger.info(`[workspace-context] invalidate dependencies cache: ${uri.path}`)
if (reload && isWorkspaceLevelFile(uri)) {
await ctx.loadWorkspace()
}
Expand Down
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const SUPPORTED_DOCUMENT_PATTERN = `**/{${PACKAGE_JSON_BASENAME},${PNPM_W

export const PRERELEASE_PATTERN = /-.+/

export const CACHE_TTL_ONE_DAY = 1000 * 60 * 60 * 24
export const CACHE_MAX_AGE_ONE_DAY = 60 * 60 * 24

export const NPMX_DEV = 'https://npmx.dev'
export const NPMX_DEV_API = `${NPMX_DEV}/api`
Expand Down
53 changes: 34 additions & 19 deletions src/core/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import type { CatalogsInfo, PackageManager, ResolvedDependencyInfo } from '#types/context'
import type { DependencyInfo, PackageManifestInfo, WorkspaceCatalogInfo } from '#types/extractor'
import type { MemoizeOptions } from '#utils/memoize'
import type { CacheOptions } from 'ocache'
import type { WorkspaceFolder } from 'vscode'
import { getPackageInfo } from '#api/package'
import { logger } from '#state'
import { getPackageInfo } from '#utils/api/package'
import { isOffsetInRange } from '#utils/ast'
import { resolveDependencySpec } from '#utils/dependency'
import { getDocumentText, isPackageManifestPath, isWorkspaceFilePath } from '#utils/file'
import { memoize } from '#utils/memoize'
import { resolveExactVersion } from '#utils/package'
import { detectPackageManager, workspaceFileMapping } from '#utils/package-manager'
import { lazyInit } from '#utils/shared'
import { defineCachedFunction } from 'ocache'
import { Uri, workspace } from 'vscode'
import { accessOk } from 'vscode-find-up'
import { getExtractor } from './extractors'
Expand All @@ -23,6 +23,7 @@ class WorkspaceContext {
folder: WorkspaceFolder
packageManager: PackageManager = 'npm'
#catalogs?: PromiseWithResolvers<CatalogsInfo | undefined>
#invalidatedPaths = new Set<string>()

private constructor(folder: WorkspaceFolder) {
this.folder = folder
Expand Down Expand Up @@ -53,11 +54,17 @@ class WorkspaceContext {
}
}

#memoizeOptions: MemoizeOptions<Uri> = {
#cacheOptions: CacheOptions<any, [Uri]> = {
getKey: (uri) => uri.path,
ttl: false,
maxSize: Number.POSITIVE_INFINITY,
fallbackToCachedOnError: false,
maxAge: 0,
swr: false,
staleMaxAge: 0,
shouldInvalidateCache: (uri) => this.#invalidatedPaths.delete(uri.path),
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

invalidateDependencyInfo(uri: Uri) {
const path = uri.path
this.#invalidatedPaths.add(path)
}
Comment on lines +57 to 68

Copy link
Copy Markdown
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 manifest caches when the workspace catalogue changes.

loadPackageManifestInfo() bakes this.#catalogs!.promise into each cached manifest result, but invalidateDependencyInfo() only expires the exact URI that changed. If the workspace file changes, the catalogue is reloaded while existing manifest entries still serve dependency resolutions computed from the old catalogue.

Possible fix
 class WorkspaceContext {
   folder: WorkspaceFolder
   packageManager: PackageManager = 'npm'
   `#catalogs`?: PromiseWithResolvers<CatalogsInfo | undefined>
   `#invalidatedPaths` = new Set<string>()
+  `#manifestCacheVersion` = 0
...
   invalidateDependencyInfo(uri: Uri) {
     const path = uri.path
     this.#invalidatedPaths.add(path)
+    if (isWorkspaceFilePath(path))
+      this.#manifestCacheVersion++
   }
...
   loadPackageManifestInfo = defineCachedFunction<
     WithResolvedDependencyInfo<PackageManifestInfo> | undefined,
     [Uri]
   >(async (uri) => {
     const path = uri.path
     if (!isPackageManifestPath(path))
       return
...
     return {
       ...info,
       dependencies: info.dependencies.map((dep) => this.#createResolvedDependencyInfo(dep, catalogs)),
     }
-  }, this.#cacheOptions)
+  }, {
+    ...this.#cacheOptions,
+    getKey: (uri) => `${this.#manifestCacheVersion}:${uri.path}`,
+  })

Also applies to: 97-123


#createResolvedDependencyInfo(dependency: DependencyInfo, catalogs?: CatalogsInfo): ResolvedDependencyInfo {
Expand Down Expand Up @@ -87,9 +94,9 @@ class WorkspaceContext {
}
}

loadPackageManifestInfo = memoize<
Uri,
Promise<WithResolvedDependencyInfo<PackageManifestInfo> | undefined>
loadPackageManifestInfo = defineCachedFunction<
WithResolvedDependencyInfo<PackageManifestInfo> | undefined,
[Uri]
>(async (uri) => {
const path = uri.path
if (!isPackageManifestPath(path))
Expand All @@ -113,11 +120,11 @@ class WorkspaceContext {
...info,
dependencies: info.dependencies.map((dep) => this.#createResolvedDependencyInfo(dep, catalogs)),
}
}, this.#memoizeOptions)
}, this.#cacheOptions)

loadWorkspaceCatalogInfo = memoize<
Uri,
Promise<WithResolvedDependencyInfo<WorkspaceCatalogInfo> | undefined>
loadWorkspaceCatalogInfo = defineCachedFunction<
WithResolvedDependencyInfo<WorkspaceCatalogInfo> | undefined,
[Uri]
>(async (uri) => {
const path = uri.path
if (!isWorkspaceFilePath(path))
Expand All @@ -138,20 +145,28 @@ class WorkspaceContext {
...info,
dependencies: info.dependencies.map((dep) => this.#createResolvedDependencyInfo(dep)),
}
}, this.#memoizeOptions)
}, this.#cacheOptions)
}

const getWorkspaceContextByFolder = memoize<WorkspaceFolder, Promise<WorkspaceContext | undefined>>(async (folder) => {
const invalidatedFolderPaths = new Set<string>()

const getWorkspaceContextByFolder = defineCachedFunction<
WorkspaceContext | undefined,
[WorkspaceFolder]
> (async (folder) => {
logger.info(`[workspace-context] built ${folder.uri.path}`)
return await WorkspaceContext.create(folder)
}, {
name: 'workspace-context',
getKey: (folder) => folder.uri.path,
ttl: false,
fallbackToCachedOnError: false,
swr: false,
maxAge: 0,
staleMaxAge: 0,
shouldInvalidateCache: (folder) => invalidatedFolderPaths.delete(folder.uri.path),
})

export function deleteWorkspaceContextCache(folder: WorkspaceFolder) {
getWorkspaceContextByFolder.delete(folder)
invalidatedFolderPaths.add(folder.uri.path)
}

export async function getWorkspaceContext(uri: Uri) {
Expand Down
2 changes: 1 addition & 1 deletion src/providers/diagnostics/rules/replacement.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ModuleReplacement } from 'module-replacements'
import type { DiagnosticRule } from '..'
import { getReplacement } from '#api/replacement'
import { config } from '#state'
import { getReplacement } from '#utils/api/replacement'
import { checkIgnored } from '#utils/ignore'
import { DiagnosticSeverity, Uri } from 'vscode'

Expand Down
2 changes: 1 addition & 1 deletion src/providers/diagnostics/rules/upgrade.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { PackageInfo } from '#api/package'
import type { ResolvedDependencyInfo } from '#types/context'
import type { OffsetRange } from '#types/extractor'
import type { PackageInfo } from '#utils/api/package'
import type { DiagnosticRule, RangeDiagnosticInfo } from '..'
import { config } from '#state'
import { checkIgnored } from '#utils/ignore'
Expand Down
6 changes: 3 additions & 3 deletions src/providers/diagnostics/rules/vulnerability.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { OsvSeverityLevel, PackageVulnerabilityInfo } from '#utils/api/vulnerability'
import type { OsvSeverityLevel, PackageVulnerabilityInfo } from '#api/vulnerability'
import type { DiagnosticRule } from '..'
import { getVulnerability, SEVERITY_LEVELS } from '#api/vulnerability'
import { config } from '#state'
import { getVulnerability, SEVERITY_LEVELS } from '#utils/api/vulnerability'
import { checkIgnored } from '#utils/ignore'
import { npmxPackageUrl } from '#utils/links'
import { formatPackageId } from '#utils/package'
Expand Down Expand Up @@ -38,7 +38,7 @@ export const checkVulnerability: DiagnosticRule = async ({ dep }) => {
if (checkIgnored({ ignoreList: config.ignore.vulnerability, name: resolvedName, version: resolvedVersion }))
return

const result = await getVulnerability({ name: resolvedName, version: resolvedVersion })
const result = await getVulnerability(resolvedName, resolvedVersion)
if (!result)
return
Comment thread
9romise marked this conversation as resolved.

Expand Down
2 changes: 1 addition & 1 deletion src/types/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { PackageInfo } from '#api/package'
import type { DependencyInfo } from '#types/extractor'
import type { PackageInfo } from '#utils/api/package'

export type PackageManager = 'npm' | 'pnpm' | 'yarn'

Expand Down
16 changes: 0 additions & 16 deletions src/utils/api/replacement.ts

This file was deleted.

Loading
Loading