diff --git a/packages/build-info/src/frameworks/framework.test.ts b/packages/build-info/src/frameworks/framework.test.ts index c85b370a57..119f647cea 100644 --- a/packages/build-info/src/frameworks/framework.test.ts +++ b/packages/build-info/src/frameworks/framework.test.ts @@ -7,7 +7,13 @@ import { Environment } from '../file-system.js' import { NodeFS } from '../node/file-system.js' import { Project } from '../project.js' -import { Accuracy, DetectedFramework, mergeDetections, sortFrameworksBasedOnAccuracy } from './framework.js' +import { + Accuracy, + type DetectedFramework, + VersionAccuracy, + mergeDetections, + sortFrameworksBasedOnAccuracy, +} from './framework.js' import { Grunt } from './grunt.js' import { Gulp } from './gulp.js' import { Hexo } from './hexo.js' @@ -180,6 +186,94 @@ describe('detect framework version', () => { }) }) +describe('detected framework version accuracy', () => { + test('should mark pinned version from package.json with medium accuracy', async ({ fs }) => { + const cwd = mockFileSystem({ + 'package.json': JSON.stringify({ devDependencies: { '@11ty/eleventy': '2.0.0' } }), + }) + const project = new Project(fs, cwd) + const detection = await project.detectFrameworks() + expect(detection).toHaveLength(1) + expect(detection?.[0].detected.package?.versionAccuracy).toBe(VersionAccuracy.PackageJSONPinned) + expect(detection?.[0].toJSON().package).toMatchObject({ + name: '@11ty/eleventy', + version: '2.0.0', + versionAccuracy: VersionAccuracy.PackageJSONPinned, + }) + }) + + test('should mark range version from package.json with low accuracy', async ({ fs }) => { + const cwd = mockFileSystem({ + 'package.json': JSON.stringify({ devDependencies: { '@11ty/eleventy': '^2.0.0' } }), + }) + const project = new Project(fs, cwd) + const detection = await project.detectFrameworks() + expect(detection).toHaveLength(1) + expect(detection?.[0].detected.package?.versionAccuracy).toBe(VersionAccuracy.PackageJSON) + expect(detection?.[0].toJSON().package).toMatchObject({ + name: '@11ty/eleventy', + version: '2.0.0', + versionAccuracy: VersionAccuracy.PackageJSON, + }) + }) + + test('should mark version from node_modules with high accuracy', async ({ fs }) => { + const cwd = mockFileSystem({ + 'package.json': JSON.stringify({ devDependencies: { '@11ty/eleventy': '^2.0.0' } }), + 'node_modules/@11ty/eleventy/package.json': JSON.stringify({ version: '2.0.1' }), + }) + const project = new Project(fs, cwd) + const detection = await project.detectFrameworks() + expect(detection).toHaveLength(1) + expect(detection?.[0].detected.package?.versionAccuracy).toBe(VersionAccuracy.NodeModules) + expect(detection?.[0].toJSON().package).toMatchObject({ + name: '@11ty/eleventy', + version: '2.0.1', + versionAccuracy: VersionAccuracy.NodeModules, + }) + }) + + test('should not set version accuracy if no version is detected', async ({ fs }) => { + const cwd = mockFileSystem({ + 'package.json': JSON.stringify({ devDependencies: { '@11ty/eleventy': 'latest' } }), + }) + const project = new Project(fs, cwd) + const detection = await project.detectFrameworks() + expect(detection).toHaveLength(1) + expect(detection?.[0].detected.package?.versionAccuracy).toBeUndefined() + expect(detection?.[0].toJSON().package.versionAccuracy).toBeUndefined() + }) + + test('should not set version accuracy for non-node.js frameworks', async ({ fs }) => { + const cwd = mockFileSystem({ + 'config.rb': '', // Middleman framework (no npm dependencies) + }) + const project = new Project(fs, cwd) + const detection = await project.detectFrameworks() + expect(detection).toHaveLength(1) + expect(detection?.[0].detected.package).toBeUndefined() + expect(detection?.[0].toJSON().package).toMatchObject({ + version: 'unknown', + versionAccuracy: undefined, + }) + }) + + test('should fall back to package.json accuracy in browser environment', async ({ fs }) => { + const cwd = mockFileSystem({ + 'package.json': JSON.stringify({ devDependencies: { '@11ty/eleventy': '^2.0.0' } }), + }) + vi.spyOn(fs, 'getEnvironment').mockImplementation(() => Environment.Browser) + const project = new Project(fs, cwd) + const detection = await project.detectFrameworks() + expect(detection).toHaveLength(1) + expect(detection?.[0].detected.package?.versionAccuracy).toBe(VersionAccuracy.PackageJSON) + expect(detection?.[0].toJSON().package).toMatchObject({ + version: '2.0.0', + versionAccuracy: VersionAccuracy.PackageJSON, + }) + }) +}) + describe('detection merging', () => { test('return undefined if no detection is provided', () => { expect(mergeDetections([undefined, undefined])).toBeUndefined() diff --git a/packages/build-info/src/frameworks/framework.ts b/packages/build-info/src/frameworks/framework.ts index d9107854bb..64d64f267b 100644 --- a/packages/build-info/src/frameworks/framework.ts +++ b/packages/build-info/src/frameworks/framework.ts @@ -20,6 +20,12 @@ export enum Accuracy { NPMHoisted = 1, // Matched the npm dependency but in a folder up the provided path } +export enum VersionAccuracy { + NodeModules = 'node_modules', // High accuracy: read from installed package in node_modules + PackageJSONPinned = 'package_json_pinned', // Medium accuracy: exact pinned version from package.json (e.g., "1.2.3") + PackageJSON = 'package_json', // Low accuracy: parsed from package.json dependency range (e.g., "^1.2.3") +} + export type PollingStrategy = { // TODO(serhalp) Define an enum name: string @@ -33,7 +39,11 @@ export type Detection = { */ accuracy: Accuracy /** The NPM package that was able to detect it (high accuracy) */ - package?: { name: string; version?: SemVer } + package?: { + name: string + version?: SemVer + versionAccuracy?: VersionAccuracy + } packageJSON?: Partial /** The absolute path to config file that is associated with the framework */ config?: string @@ -93,6 +103,7 @@ export interface Framework { package: { name?: string // if detected via config file the name can be empty version: string | 'unknown' + versionAccuracy?: VersionAccuracy } dev: { commands: string[] @@ -249,19 +260,50 @@ export abstract class BaseFramework implements Framework { /** check if the npmDependencies are used inside the provided package.json */ private async npmDependenciesUsed( pkgJSON: Partial, - ): Promise<{ name: string; version?: SemVer } | undefined> { - const allDeps = [...Object.entries(pkgJSON.dependencies || {}), ...Object.entries(pkgJSON.devDependencies || {})] + ): Promise<{ name: string; version?: SemVer; versionAccuracy?: VersionAccuracy } | undefined> { + const allDeps = { + ...(pkgJSON.dependencies ?? {}), + ...(pkgJSON.devDependencies ?? {}), + } + const matchedDepName = Object.keys(allDeps).find((depName) => this.npmDependencies.includes(depName)) + const hasExcludedDeps = Object.keys(allDeps).some((depName) => this.excludedNpmDependencies.includes(depName)) + + if (!hasExcludedDeps && matchedDepName != null) { + const versionFromNodeModules = await this.getVersionFromNodeModules(matchedDepName) + if (versionFromNodeModules) { + return { + name: matchedDepName, + version: versionFromNodeModules, + versionAccuracy: VersionAccuracy.NodeModules, + } + } - const found = allDeps.find(([depName]) => this.npmDependencies.includes(depName)) - // check for excluded dependencies - const excluded = allDeps.some(([depName]) => this.excludedNpmDependencies.includes(depName)) + const matchedDepVersion = allDeps[matchedDepName] + + // Try to parse without coercing first to detect pinned versions (e.g., "1.2.3") + const pinnedVersion = parse(matchedDepVersion) + if (pinnedVersion) { + return { + name: matchedDepName, + version: pinnedVersion, + versionAccuracy: VersionAccuracy.PackageJSONPinned, + } + } + + // Coerce to parse syntax like ~0.1.2 or ^1.2.3 + const coercedVersion = parse(coerce(matchedDepVersion)) || undefined + if (coercedVersion) { + return { + name: matchedDepName, + version: coercedVersion, + versionAccuracy: VersionAccuracy.PackageJSON, + } + } - if (!excluded && found?.[0]) { - const version = await this.getVersionFromNodeModules(found[0]) return { - name: found[0], - // coerce to parse syntax like ~0.1.2 or ^1.2.3 - version: version || parse(coerce(found[1])) || undefined, + name: matchedDepName, + version: undefined, + versionAccuracy: undefined, } } } @@ -367,6 +409,7 @@ export abstract class BaseFramework implements Framework { package: { name: this.detected?.package?.name || this.npmDependencies?.[0], version: this.detected?.package?.version?.raw || 'unknown', + versionAccuracy: this.detected?.package?.versionAccuracy, }, category: this.category, dev: { diff --git a/packages/build-info/src/index.ts b/packages/build-info/src/index.ts index d5519cbfe3..c94e4d3bc5 100644 --- a/packages/build-info/src/index.ts +++ b/packages/build-info/src/index.ts @@ -1,6 +1,12 @@ export * from './file-system.js' export * from './logger.js' -export type { Category, DetectedFramework, FrameworkInfo, PollingStrategy } from './frameworks/framework.js' +export type { + Category, + DetectedFramework, + FrameworkInfo, + PollingStrategy, + VersionAccuracy, +} from './frameworks/framework.js' export * from './get-framework.js' export * from './project.js' export * from './settings/get-build-settings.js' diff --git a/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap b/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap index e1f62ce41b..253c43adbf 100644 --- a/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap +++ b/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap @@ -144,6 +144,7 @@ exports[`should retrieve the build info for providing a rootDir and a nested pro "package": { "name": "astro", "version": "1.5.1", + "versionAccuracy": "package_json", }, "plugins": [], "staticAssetsDirectory": "public",