Skip to content
Open
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
96 changes: 95 additions & 1 deletion packages/build-info/src/frameworks/framework.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down
65 changes: 54 additions & 11 deletions packages/build-info/src/frameworks/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<PackageJson>
/** The absolute path to config file that is associated with the framework */
config?: string
Expand Down Expand Up @@ -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[]
Expand Down Expand Up @@ -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<PackageJson>,
): Promise<{ name: string; version?: SemVer } | undefined> {
const allDeps = [...Object.entries(pkgJSON.dependencies || {}), ...Object.entries(pkgJSON.devDependencies || {})]
Copy link
Member Author

@serhalp serhalp Dec 18, 2025

Choose a reason for hiding this comment

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

I couldn't help myself here. This was unnecessarily complicated. Instead of just merging two record objects, we were turning them into an array [[k1, v1], [k2, v2], ...] for some reason, which is less readable, more complicated, and presumably less performant (we use .find() on this, twice).

): 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,
}
}
}
Expand Down Expand Up @@ -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: {
Expand Down
8 changes: 7 additions & 1 deletion packages/build-info/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading