Skip to content

Commit 66891ca

Browse files
authored
feat: return detected framework version accuracy (#6852)
1 parent 2a1e701 commit 66891ca

File tree

4 files changed

+157
-13
lines changed

4 files changed

+157
-13
lines changed

packages/build-info/src/frameworks/framework.test.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import { Environment } from '../file-system.js'
77
import { NodeFS } from '../node/file-system.js'
88
import { Project } from '../project.js'
99

10-
import { Accuracy, DetectedFramework, mergeDetections, sortFrameworksBasedOnAccuracy } from './framework.js'
10+
import {
11+
Accuracy,
12+
type DetectedFramework,
13+
VersionAccuracy,
14+
mergeDetections,
15+
sortFrameworksBasedOnAccuracy,
16+
} from './framework.js'
1117
import { Grunt } from './grunt.js'
1218
import { Gulp } from './gulp.js'
1319
import { Hexo } from './hexo.js'
@@ -180,6 +186,94 @@ describe('detect framework version', () => {
180186
})
181187
})
182188

189+
describe('detected framework version accuracy', () => {
190+
test('should mark pinned version from package.json with medium accuracy', async ({ fs }) => {
191+
const cwd = mockFileSystem({
192+
'package.json': JSON.stringify({ devDependencies: { '@11ty/eleventy': '2.0.0' } }),
193+
})
194+
const project = new Project(fs, cwd)
195+
const detection = await project.detectFrameworks()
196+
expect(detection).toHaveLength(1)
197+
expect(detection?.[0].detected.package?.versionAccuracy).toBe(VersionAccuracy.PackageJSONPinned)
198+
expect(detection?.[0].toJSON().package).toMatchObject({
199+
name: '@11ty/eleventy',
200+
version: '2.0.0',
201+
versionAccuracy: VersionAccuracy.PackageJSONPinned,
202+
})
203+
})
204+
205+
test('should mark range version from package.json with low accuracy', async ({ fs }) => {
206+
const cwd = mockFileSystem({
207+
'package.json': JSON.stringify({ devDependencies: { '@11ty/eleventy': '^2.0.0' } }),
208+
})
209+
const project = new Project(fs, cwd)
210+
const detection = await project.detectFrameworks()
211+
expect(detection).toHaveLength(1)
212+
expect(detection?.[0].detected.package?.versionAccuracy).toBe(VersionAccuracy.PackageJSON)
213+
expect(detection?.[0].toJSON().package).toMatchObject({
214+
name: '@11ty/eleventy',
215+
version: '2.0.0',
216+
versionAccuracy: VersionAccuracy.PackageJSON,
217+
})
218+
})
219+
220+
test('should mark version from node_modules with high accuracy', async ({ fs }) => {
221+
const cwd = mockFileSystem({
222+
'package.json': JSON.stringify({ devDependencies: { '@11ty/eleventy': '^2.0.0' } }),
223+
'node_modules/@11ty/eleventy/package.json': JSON.stringify({ version: '2.0.1' }),
224+
})
225+
const project = new Project(fs, cwd)
226+
const detection = await project.detectFrameworks()
227+
expect(detection).toHaveLength(1)
228+
expect(detection?.[0].detected.package?.versionAccuracy).toBe(VersionAccuracy.NodeModules)
229+
expect(detection?.[0].toJSON().package).toMatchObject({
230+
name: '@11ty/eleventy',
231+
version: '2.0.1',
232+
versionAccuracy: VersionAccuracy.NodeModules,
233+
})
234+
})
235+
236+
test('should not set version accuracy if no version is detected', async ({ fs }) => {
237+
const cwd = mockFileSystem({
238+
'package.json': JSON.stringify({ devDependencies: { '@11ty/eleventy': 'latest' } }),
239+
})
240+
const project = new Project(fs, cwd)
241+
const detection = await project.detectFrameworks()
242+
expect(detection).toHaveLength(1)
243+
expect(detection?.[0].detected.package?.versionAccuracy).toBeUndefined()
244+
expect(detection?.[0].toJSON().package.versionAccuracy).toBeUndefined()
245+
})
246+
247+
test('should not set version accuracy for non-node.js frameworks', async ({ fs }) => {
248+
const cwd = mockFileSystem({
249+
'config.rb': '', // Middleman framework (no npm dependencies)
250+
})
251+
const project = new Project(fs, cwd)
252+
const detection = await project.detectFrameworks()
253+
expect(detection).toHaveLength(1)
254+
expect(detection?.[0].detected.package).toBeUndefined()
255+
expect(detection?.[0].toJSON().package).toMatchObject({
256+
version: 'unknown',
257+
versionAccuracy: undefined,
258+
})
259+
})
260+
261+
test('should fall back to package.json accuracy in browser environment', async ({ fs }) => {
262+
const cwd = mockFileSystem({
263+
'package.json': JSON.stringify({ devDependencies: { '@11ty/eleventy': '^2.0.0' } }),
264+
})
265+
vi.spyOn(fs, 'getEnvironment').mockImplementation(() => Environment.Browser)
266+
const project = new Project(fs, cwd)
267+
const detection = await project.detectFrameworks()
268+
expect(detection).toHaveLength(1)
269+
expect(detection?.[0].detected.package?.versionAccuracy).toBe(VersionAccuracy.PackageJSON)
270+
expect(detection?.[0].toJSON().package).toMatchObject({
271+
version: '2.0.0',
272+
versionAccuracy: VersionAccuracy.PackageJSON,
273+
})
274+
})
275+
})
276+
183277
describe('detection merging', () => {
184278
test('return undefined if no detection is provided', () => {
185279
expect(mergeDetections([undefined, undefined])).toBeUndefined()

packages/build-info/src/frameworks/framework.ts

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ export enum Accuracy {
2020
NPMHoisted = 1, // Matched the npm dependency but in a folder up the provided path
2121
}
2222

23+
export enum VersionAccuracy {
24+
NodeModules = 'node_modules', // High accuracy: read from installed package in node_modules
25+
PackageJSONPinned = 'package_json_pinned', // Medium accuracy: exact pinned version from package.json (e.g., "1.2.3")
26+
PackageJSON = 'package_json', // Low accuracy: parsed from package.json dependency range (e.g., "^1.2.3")
27+
}
28+
2329
export type PollingStrategy = {
2430
// TODO(serhalp) Define an enum
2531
name: string
@@ -33,7 +39,11 @@ export type Detection = {
3339
*/
3440
accuracy: Accuracy
3541
/** The NPM package that was able to detect it (high accuracy) */
36-
package?: { name: string; version?: SemVer }
42+
package?: {
43+
name: string
44+
version?: SemVer
45+
versionAccuracy?: VersionAccuracy
46+
}
3747
packageJSON?: Partial<PackageJson>
3848
/** The absolute path to config file that is associated with the framework */
3949
config?: string
@@ -93,6 +103,7 @@ export interface Framework {
93103
package: {
94104
name?: string // if detected via config file the name can be empty
95105
version: string | 'unknown'
106+
versionAccuracy?: VersionAccuracy
96107
}
97108
dev: {
98109
commands: string[]
@@ -249,19 +260,50 @@ export abstract class BaseFramework implements Framework {
249260
/** check if the npmDependencies are used inside the provided package.json */
250261
private async npmDependenciesUsed(
251262
pkgJSON: Partial<PackageJson>,
252-
): Promise<{ name: string; version?: SemVer } | undefined> {
253-
const allDeps = [...Object.entries(pkgJSON.dependencies || {}), ...Object.entries(pkgJSON.devDependencies || {})]
263+
): Promise<{ name: string; version?: SemVer; versionAccuracy?: VersionAccuracy } | undefined> {
264+
const allDeps = {
265+
...(pkgJSON.dependencies ?? {}),
266+
...(pkgJSON.devDependencies ?? {}),
267+
}
268+
const matchedDepName = Object.keys(allDeps).find((depName) => this.npmDependencies.includes(depName))
269+
const hasExcludedDeps = Object.keys(allDeps).some((depName) => this.excludedNpmDependencies.includes(depName))
270+
271+
if (!hasExcludedDeps && matchedDepName != null) {
272+
const versionFromNodeModules = await this.getVersionFromNodeModules(matchedDepName)
273+
if (versionFromNodeModules) {
274+
return {
275+
name: matchedDepName,
276+
version: versionFromNodeModules,
277+
versionAccuracy: VersionAccuracy.NodeModules,
278+
}
279+
}
254280

255-
const found = allDeps.find(([depName]) => this.npmDependencies.includes(depName))
256-
// check for excluded dependencies
257-
const excluded = allDeps.some(([depName]) => this.excludedNpmDependencies.includes(depName))
281+
const matchedDepVersion = allDeps[matchedDepName]
282+
283+
// Try to parse without coercing first to detect pinned versions (e.g., "1.2.3")
284+
const pinnedVersion = parse(matchedDepVersion)
285+
if (pinnedVersion) {
286+
return {
287+
name: matchedDepName,
288+
version: pinnedVersion,
289+
versionAccuracy: VersionAccuracy.PackageJSONPinned,
290+
}
291+
}
292+
293+
// Coerce to parse syntax like ~0.1.2 or ^1.2.3
294+
const coercedVersion = parse(coerce(matchedDepVersion)) || undefined
295+
if (coercedVersion) {
296+
return {
297+
name: matchedDepName,
298+
version: coercedVersion,
299+
versionAccuracy: VersionAccuracy.PackageJSON,
300+
}
301+
}
258302

259-
if (!excluded && found?.[0]) {
260-
const version = await this.getVersionFromNodeModules(found[0])
261303
return {
262-
name: found[0],
263-
// coerce to parse syntax like ~0.1.2 or ^1.2.3
264-
version: version || parse(coerce(found[1])) || undefined,
304+
name: matchedDepName,
305+
version: undefined,
306+
versionAccuracy: undefined,
265307
}
266308
}
267309
}
@@ -367,6 +409,7 @@ export abstract class BaseFramework implements Framework {
367409
package: {
368410
name: this.detected?.package?.name || this.npmDependencies?.[0],
369411
version: this.detected?.package?.version?.raw || 'unknown',
412+
versionAccuracy: this.detected?.package?.versionAccuracy,
370413
},
371414
category: this.category,
372415
dev: {

packages/build-info/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
export * from './file-system.js'
22
export * from './logger.js'
3-
export type { Category, DetectedFramework, FrameworkInfo, PollingStrategy } from './frameworks/framework.js'
3+
export type {
4+
Category,
5+
DetectedFramework,
6+
FrameworkInfo,
7+
PollingStrategy,
8+
VersionAccuracy,
9+
} from './frameworks/framework.js'
410
export * from './get-framework.js'
511
export * from './project.js'
612
export * from './settings/get-build-settings.js'

packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ exports[`should retrieve the build info for providing a rootDir and a nested pro
144144
"package": {
145145
"name": "astro",
146146
"version": "1.5.1",
147+
"versionAccuracy": "package_json",
147148
},
148149
"plugins": [],
149150
"staticAssetsDirectory": "public",

0 commit comments

Comments
 (0)