diff --git a/.github/workflows/generate-stats.yml b/.github/workflows/generate-stats.yml index 3cfbacd8..5b6e1667 100644 --- a/.github/workflows/generate-stats.yml +++ b/.github/workflows/generate-stats.yml @@ -16,6 +16,7 @@ on: - '!packages/starter-*/corejs-stats.json' - '!packages/starter-*/browser-baseline-stats.json' - '!packages/starter-*/e18e-stats.json' + - '!packages/starter-*/node-engines-stats.json' - 'packages/app-*/**' - '!packages/app-*/ci-stats.json' - '!packages/app-*/stats/**' diff --git a/.github/workflows/measure-framework.yml b/.github/workflows/measure-framework.yml index 4da9e7af..02f92c05 100644 --- a/.github/workflows/measure-framework.yml +++ b/.github/workflows/measure-framework.yml @@ -114,6 +114,9 @@ jobs: if: contains(toJson(matrix.framework.measurements), 'browserBaseline') run: pnpm --filter @framework-tracker/stats-generator run:browser-baseline ${{ matrix.framework.package }} + - name: Scan installed dependencies for Node engine constraints + run: pnpm --filter @framework-tracker/stats-generator run:node-engines ${{ matrix.framework.package }} + - name: Upload build stats uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -139,6 +142,14 @@ jobs: retention-days: 1 if-no-files-found: error + - name: Upload node engines stats + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: node-engines-stats-${{ matrix.framework.name }} + path: packages/${{ matrix.framework.package }}/node-engines-stats.json + retention-days: 1 + if-no-files-found: warn + measure-ssr-request-throughput: if: inputs.ssr-request-throughput-matrix != '[]' runs-on: depot-ubuntu-24.04 diff --git a/.github/workflows/validate-stats.yml b/.github/workflows/validate-stats.yml index 15d0771f..870fb078 100644 --- a/.github/workflows/validate-stats.yml +++ b/.github/workflows/validate-stats.yml @@ -19,6 +19,7 @@ on: - '!packages/starter-*/corejs-stats.json' - '!packages/starter-*/browser-baseline-stats.json' - '!packages/starter-*/e18e-stats.json' + - '!packages/starter-*/node-engines-stats.json' jobs: validate: @@ -81,6 +82,8 @@ jobs: pnpm --filter @framework-tracker/stats-generator run:install $PKG 1 echo "Running build benchmark for $PKG..." pnpm --filter @framework-tracker/stats-generator run:build $PKG + echo "Running Node engines scan for $PKG..." + pnpm --filter @framework-tracker/stats-generator run:node-engines $PKG done echo "" diff --git a/.gitignore b/.gitignore index 5ce1fa04..0cd058d5 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ packages/*/build-stats.json packages/*/corejs-stats.json packages/*/e18e-stats.json packages/*/framework-dependency-stats.json +packages/*/node-engines-stats.json # Misc *.tmp diff --git a/packages/docs/src/components/MinimumNodeVersionTable.astro b/packages/docs/src/components/MinimumNodeVersionTable.astro new file mode 100644 index 00000000..7fe1a817 --- /dev/null +++ b/packages/docs/src/components/MinimumNodeVersionTable.astro @@ -0,0 +1,23 @@ +--- +import { minimumNodeVersionTableData } from '../lib/collections' +import { getFrameworkSlug } from '../lib/utils' +import StatsTable from './StatsTable.astro' + +const columns = [ + { + key: 'name', + header: 'Framework', + nameCell: true, + href: (row: Record) => + `/framework/${getFrameworkSlug(row.package as string)}`, + }, + { key: 'minNodeVersion', header: 'Min Node' }, + { key: 'imposedBy', header: 'Set by' }, +] +--- + + diff --git a/packages/docs/src/content.config.ts b/packages/docs/src/content.config.ts index dbd56b6b..ad7fdd75 100644 --- a/packages/docs/src/content.config.ts +++ b/packages/docs/src/content.config.ts @@ -57,6 +57,8 @@ const devtimeSchema = z.object({ baselineFeatureCount: z.number(), }) .optional(), + minimumNodeVersion: z.string().optional(), + minimumNodeVersionImposedBy: z.array(z.string()).optional(), timingMeasuredAt: z.string(), runner: z.string(), browserVersion: z.string().optional(), diff --git a/packages/docs/src/content/docs/all-frameworks.mdx b/packages/docs/src/content/docs/all-frameworks.mdx index 22094061..0f9d1e02 100644 --- a/packages/docs/src/content/docs/all-frameworks.mdx +++ b/packages/docs/src/content/docs/all-frameworks.mdx @@ -16,6 +16,7 @@ import DependencyCountText from '../../components/DependencyCountText.astro' import DependencyStatsTable from '../../components/DependencyStatsTable.astro' import FrameworkDependencyCharts from '../../components/FrameworkDependencyCharts.astro' import FrameworkDependencyStatsTable from '../../components/FrameworkDependencyStatsTable.astro' +import MinimumNodeVersionTable from '../../components/MinimumNodeVersionTable.astro' import NodeModulesSizeCharts from '../../components/NodeModulesSizeCharts.astro' import ServerSideRenderedCharts from '../../components/ServerSideRenderedCharts.astro' import ServerSideRenderedStatsTable from '../../components/ServerSideRenderedStatsTable.astro' @@ -68,6 +69,12 @@ Methodology: [Browser Baseline](/methodology/#browser-baseline). +## Minimum Node Version + +Methodology: [Minimum Node Version](/methodology/#minimum-node-version). + + + ## Client Side Rendered Tests Methodology: [Client Side Rendered Tests](/methodology/#client-side-rendered-tests). diff --git a/packages/docs/src/content/docs/dev-time.mdx b/packages/docs/src/content/docs/dev-time.mdx index b9a433f9..cea77b58 100644 --- a/packages/docs/src/content/docs/dev-time.mdx +++ b/packages/docs/src/content/docs/dev-time.mdx @@ -12,6 +12,7 @@ import DependencyCountText from '../../components/DependencyCountText.astro' import DependencyStatsTable from '../../components/DependencyStatsTable.astro' import FrameworkDependencyCharts from '../../components/FrameworkDependencyCharts.astro' import FrameworkDependencyStatsTable from '../../components/FrameworkDependencyStatsTable.astro' +import MinimumNodeVersionTable from '../../components/MinimumNodeVersionTable.astro' import NodeModulesSizeCharts from '../../components/NodeModulesSizeCharts.astro' ## Setup @@ -61,4 +62,10 @@ Methodology: [Browser Baseline](/methodology/#browser-baseline). +## Minimum Node Version + +Methodology: [Minimum Node Version](/methodology/#minimum-node-version). + + + diff --git a/packages/docs/src/content/docs/methodology.md b/packages/docs/src/content/docs/methodology.md index 5cd48076..05ebc305 100644 --- a/packages/docs/src/content/docs/methodology.md +++ b/packages/docs/src/content/docs/methodology.md @@ -131,6 +131,12 @@ matches the framework version tracked by the starter project. - Features is the number of unique web platform feature IDs detected in the browser-facing build output. +### Minimum Node Version + +The oldest Node.js release that satisfies every `engines.node` range declared by the packages installed in the starter's `node_modules`, dev and prod dependencies included, together with the starter's own `package.json`. The ranges are deduplicated and intersected, so the floor is the lowest version that every package accepts at once. The "Set by" column lists the packages that impose that floor, meaning removing any one of them would lower it. When several packages share the floor and no single one is responsible, the column shows a dash. + +A dash in the Min Node column means no installed package declares a resolvable `engines.node` range. + ### Duplicate Dependencies - Duplicate dependency details come from e18e dependency analysis messages diff --git a/packages/docs/src/lib/collections.ts b/packages/docs/src/lib/collections.ts index c8a526fd..d3d8a0c1 100644 --- a/packages/docs/src/lib/collections.ts +++ b/packages/docs/src/lib/collections.ts @@ -228,6 +228,16 @@ export const depsStats = starterStats.map((f) => ({ graph: 'View', })) +export const minimumNodeVersionTableData = starterStats.map((f) => ({ + name: f.name, + package: f.package, + isFocused: f.isFocused, + minNodeVersion: f.minimumNodeVersion ? `v${f.minimumNodeVersion}` : '—', + imposedBy: f.minimumNodeVersionImposedBy?.length + ? f.minimumNodeVersionImposedBy.join(', ') + : '—', +})) + const frameworkPackageNames: Record = { 'starter-astro': 'astro', 'starter-mastro': '@mastrojs/mastro', diff --git a/packages/stats-generator/package.json b/packages/stats-generator/package.json index fc929bb3..02cb484b 100644 --- a/packages/stats-generator/package.json +++ b/packages/stats-generator/package.json @@ -14,6 +14,7 @@ "run:corejs": "node src/run-corejs-scan.ts", "run:browser-baseline": "node src/run-browser-baseline-scan.ts", "run:framework-dependencies": "node src/run-framework-dependency-scan.ts", + "run:node-engines": "node src/run-node-engines-scan.ts", "save:ci-stats": "node src/save-ci-stats.ts", "generate:preview-comment": "node src/generate-preview-comment.ts", "validate": "node src/validate-stats.ts", @@ -33,6 +34,7 @@ "react-dom": "19.2.8", "tinybench": "^6.1.2", "tinyglobby": "^0.2.17", + "verkit": "^0.4.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/stats-generator/src/node-engines.test.ts b/packages/stats-generator/src/node-engines.test.ts new file mode 100644 index 00000000..9604ed63 --- /dev/null +++ b/packages/stats-generator/src/node-engines.test.ts @@ -0,0 +1,130 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { resolveMinimumNodeVersion } from './node-engines.ts' +import type { NodeEngineEntry } from './node-engines.ts' + +function floorOf(range: string): string | null { + return resolveMinimumNodeVersion([{ name: 'pkg', range }]).minimumNodeVersion +} + +test('two ranges whose intersection starts above either floor report both as imposing', () => { + const stats = resolveMinimumNodeVersion([ + { name: 'a', range: '^18 || >=20' }, + { name: 'b', range: '>=19' }, + ]) + assert.equal(stats.minimumNodeVersion, '20.0.0') + assert.deepEqual(stats.imposedBy, ['a', 'b']) + assert.deepEqual(stats.unsatisfiableRanges, []) +}) + +test('spelling variants of one range both impose the floor', () => { + const stats = resolveMinimumNodeVersion([ + { name: 'a', range: '>=20' }, + { name: 'b', range: '>=20.0.0' }, + { name: 'c', range: '>= v20.x' }, + ]) + assert.equal(stats.minimumNodeVersion, '20.0.0') + assert.deepEqual(stats.imposedBy, ['a', 'b', 'c']) +}) + +test('>= 0.4 floors at 0.4.0', () => { + assert.equal(floorOf('>= 0.4'), '0.4.0') +}) + +test('>=0.8.x floors at 0.8.0', () => { + assert.equal(floorOf('>=0.8.x'), '0.8.0') +}) + +test('>=v12.22.7 floors at 12.22.7', () => { + assert.equal(floorOf('>=v12.22.7'), '12.22.7') +}) + +test('>=16 || 14 >=14.17 floors at the two-comparator branch, 14.17.0', () => { + assert.equal(floorOf('>=16 || 14 >=14.17'), '14.17.0') +}) + +test('6.* || 8.* || >= 10.* floors at 6.0.0', () => { + assert.equal(floorOf('6.* || 8.* || >= 10.*'), '6.0.0') +}) + +test('4.x || >=6.0.0 floors at 4.0.0', () => { + assert.equal(floorOf('4.x || >=6.0.0'), '4.0.0') +}) + +test('20 || >=22 floors at 20.0.0', () => { + assert.equal(floorOf('20 || >=22'), '20.0.0') +}) + +test('18.20.8 || ^20.3.0 || >=22.0.0 floors at 18.20.8', () => { + assert.equal(floorOf('18.20.8 || ^20.3.0 || >=22.0.0'), '18.20.8') +}) + +test('^18.18.0 || ^20.9.0 || >=21.1.0 floors at 18.18.0', () => { + assert.equal(floorOf('^18.18.0 || ^20.9.0 || >=21.1.0'), '18.18.0') +}) + +test('a mixed tree intersects to 20.9.0 imposed by the two ranges that exclude 18.18.0 and 20.0.0', () => { + const entries: NodeEngineEntry[] = [ + { name: 'alpha', range: '>= 0.4' }, + { name: 'bravo', range: '>=16 || 14 >=14.17' }, + { name: 'charlie', range: '6.* || 8.* || >= 10.*' }, + { name: 'delta', range: '4.x || >=6.0.0' }, + { name: 'echo', range: '20 || >=22' }, + { name: 'foxtrot', range: '^18.18.0 || ^20.9.0 || >=21.1.0' }, + { name: 'golf' }, + ] + const stats = resolveMinimumNodeVersion(entries) + assert.equal(stats.minimumNodeVersion, '20.9.0') + assert.deepEqual(stats.imposedBy, ['echo', 'foxtrot']) + assert.deepEqual(stats.unsatisfiableRanges, []) + assert.equal(stats.packagesScanned, 7) + assert.equal(stats.packagesDeclaringNodeEngine, 6) +}) + +test('universal, garbage, blank, and missing ranges are skipped but counted', () => { + const stats = resolveMinimumNodeVersion([ + { name: 'star', range: '*' }, + { name: 'zero', range: '>=0.0.0' }, + { name: 'garbage', range: 'not-a-range' }, + { name: 'blank', range: ' ' }, + { name: 'missing' }, + { name: 'real', range: '>=18' }, + ]) + assert.equal(stats.minimumNodeVersion, '18.0.0') + assert.deepEqual(stats.imposedBy, ['real']) + assert.equal(stats.packagesScanned, 6) + assert.equal(stats.packagesDeclaringNodeEngine, 4) +}) + +test('empty input resolves to nothing', () => { + assert.deepEqual(resolveMinimumNodeVersion([]), { + minimumNodeVersion: null, + imposedBy: [], + packagesScanned: 0, + packagesDeclaringNodeEngine: 0, + unsatisfiableRanges: [], + }) +}) + +test('disjoint ranges yield null and list every range a candidate failed', () => { + const stats = resolveMinimumNodeVersion([ + { name: 'a', range: '^18.0.0' }, + { name: 'b', range: '>=20' }, + ]) + assert.equal(stats.minimumNodeVersion, null) + assert.deepEqual(stats.imposedBy, []) + assert.deepEqual(stats.unsatisfiableRanges, [ + '>=18.0.0 <19.0.0-0', + '>=20.0.0', + ]) +}) + +test('the starter package itself can be the sole imposer', () => { + const stats = resolveMinimumNodeVersion([ + { name: 'starter-mastro', range: '>=24.12' }, + { name: 'a', range: '>=18' }, + { name: 'b', range: '^20 || >=22' }, + ]) + assert.equal(stats.minimumNodeVersion, '24.12.0') + assert.deepEqual(stats.imposedBy, ['starter-mastro']) +}) diff --git a/packages/stats-generator/src/node-engines.ts b/packages/stats-generator/src/node-engines.ts new file mode 100644 index 00000000..cf0c60f9 --- /dev/null +++ b/packages/stats-generator/src/node-engines.ts @@ -0,0 +1,100 @@ +import { + compare, + findMinimumForRange, + isLess, + normalize, + normalizeRange as normalizeSemverRange, + rangeToComparators, + satisfies, +} from 'verkit' +import type { NodeEnginesStats } from './types.ts' + +export interface NodeEngineEntry { + name: string + range?: string +} + +interface Solution { + version: string | null + unsatisfiableRanges: string[] +} + +const UNIVERSAL_RANGES = new Set(['*', '>=0.0.0']) + +function normalizeRange(range: string): string | null { + const normalized = normalizeSemverRange(range, { loose: true }) + if (normalized === null || UNIVERSAL_RANGES.has(normalized)) { + return null + } + return findMinimumForRange(normalized) === null ? null : normalized +} + +function branchFloors(range: string): string[] { + const floors: string[] = [] + for (const branch of rangeToComparators(range)) { + const floor = findMinimumForRange(branch.join(' ') || '*') + const version = floor === null ? null : normalize(floor) + if (version !== null) { + floors.push(version) + } + } + return floors +} + +function solve(ranges: string[]): Solution { + const candidates = [...new Set(ranges.flatMap(branchFloors))].sort(compare) + const failed = new Set() + for (const candidate of candidates) { + const unsatisfied = ranges.filter((range) => !satisfies(candidate, range)) + if (unsatisfied.length === 0) { + return { version: candidate, unsatisfiableRanges: [] } + } + for (const range of unsatisfied) { + failed.add(range) + } + } + return { version: null, unsatisfiableRanges: [...failed].sort() } +} + +export function resolveMinimumNodeVersion( + entries: NodeEngineEntry[], +): NodeEnginesStats { + const declared = entries.flatMap(({ name, range }) => { + const trimmed = range?.trim() + return trimmed ? [{ name, range: trimmed }] : [] + }) + + const packagesByRange = new Map>() + for (const { name, range } of declared) { + const normalized = normalizeRange(range) + if (normalized === null) { + continue + } + const names = packagesByRange.get(normalized) ?? new Set() + names.add(name) + packagesByRange.set(normalized, names) + } + + const ranges = [...packagesByRange.keys()] + const { version, unsatisfiableRanges } = solve(ranges) + + const imposedBy = new Set() + if (version !== null) { + for (const [range, names] of packagesByRange) { + const reduced = solve(ranges.filter((r) => r !== range)).version + if (reduced === null || isLess(reduced, version)) { + for (const name of names) { + imposedBy.add(name) + } + } + } + } + + return { + minimumNodeVersion: version, + imposedBy: [...imposedBy].sort(), + packagesScanned: entries.length, + packagesDeclaringNodeEngine: declared.length, + unsatisfiableRanges, + } +} diff --git a/packages/stats-generator/src/run-corejs-scan.ts b/packages/stats-generator/src/run-corejs-scan.ts index d720d062..a8794cd3 100644 --- a/packages/stats-generator/src/run-corejs-scan.ts +++ b/packages/stats-generator/src/run-corejs-scan.ts @@ -38,7 +38,7 @@ function extractVersion(source: string): string { async function main() { const { packageName } = parseArgs( - 'Usage: run-corejs-scan \nExample: run-corejs-scan starter-next', + 'Usage: run-corejs-scan \nExample: run-corejs-scan starter-next-js', ) const { framework, testConfig } = await getFrameworkByPackage(packageName) diff --git a/packages/stats-generator/src/run-node-engines-scan.ts b/packages/stats-generator/src/run-node-engines-scan.ts new file mode 100644 index 00000000..43ae431d --- /dev/null +++ b/packages/stats-generator/src/run-node-engines-scan.ts @@ -0,0 +1,179 @@ +import { lstatSync, readdirSync, readFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { packagesDir } from './constants.ts' +import { getFrameworkByPackage, parseArgs, writeJsonFile } from './utils.ts' +import { resolveMinimumNodeVersion } from './node-engines.ts' +import type { NodeEngineEntry } from './node-engines.ts' + +interface Manifest { + name?: unknown + version?: unknown + engines?: { node?: unknown } +} + +function isRealDirectory(path: string): boolean { + try { + return lstatSync(path).isDirectory() + } catch { + return false + } +} + +function readDirNames(path: string): string[] { + try { + return readdirSync(path) + } catch { + return [] + } +} + +function readManifest(dir: string): Manifest | null { + try { + return JSON.parse( + readFileSync(join(dir, 'package.json'), 'utf-8'), + ) as Manifest + } catch { + return null + } +} + +function engineRange(manifest: Manifest | null): string | undefined { + const node = manifest?.engines?.node + return typeof node === 'string' ? node : undefined +} + +function collectPackageDirs(nodeModulesDir: string): string[] { + const dirs: string[] = [] + + for (const name of readDirNames(nodeModulesDir)) { + const path = join(nodeModulesDir, name) + // Entries here are symlinks to other packages' real locations, so following + // them would count the same installed package once per dependent. + if (!isRealDirectory(path)) { + continue + } + + if (name.startsWith('@')) { + for (const scopedName of readDirNames(path)) { + const scopedPath = join(path, scopedName) + if (isRealDirectory(scopedPath)) { + dirs.push(scopedPath) + } + } + continue + } + + dirs.push(path) + } + + return dirs +} + +function collectPnpmPackageDirs(pnpmDir: string): string[] { + const dirs: string[] = [] + + for (const entry of readDirNames(pnpmDir)) { + const nested = join(pnpmDir, entry, 'node_modules') + if (isRealDirectory(nested)) { + dirs.push(...collectPackageDirs(nested)) + } + } + + return dirs +} + +function collectNestedPackageDirs(nodeModulesDir: string): string[] { + const dirs: string[] = [] + + for (const dir of collectPackageDirs(nodeModulesDir)) { + dirs.push(dir) + const nested = join(dir, 'node_modules') + if (isRealDirectory(nested)) { + dirs.push(...collectNestedPackageDirs(nested)) + } + } + + return dirs +} + +async function main() { + const { packageName } = parseArgs( + 'Usage: run-node-engines-scan \nExample: run-node-engines-scan starter-next-js', + ) + + const { framework } = await getFrameworkByPackage(packageName) + + console.info( + `Scanning installed dependencies for engines.node in ${framework.displayName} (${packageName})...\n`, + ) + + const starterDir = join(packagesDir, packageName) + const nodeModulesDir = join(starterDir, 'node_modules') + const pnpmDir = join(nodeModulesDir, '.pnpm') + + const packageDirs = isRealDirectory(pnpmDir) + ? collectPnpmPackageDirs(pnpmDir) + : collectNestedPackageDirs(nodeModulesDir) + + const entries: NodeEngineEntry[] = [] + const seen = new Set() + + for (const dir of packageDirs) { + const manifest = readManifest(dir) + if (!manifest) { + continue + } + + const name = typeof manifest.name === 'string' ? manifest.name : null + const version = + typeof manifest.version === 'string' ? manifest.version : null + + const key = name && version ? `${name}@${version}` : dir + if (seen.has(key)) { + continue + } + seen.add(key) + + entries.push({ + name: name ?? relative(packagesDir, dir), + range: engineRange(manifest), + }) + } + + entries.push({ + name: packageName, + range: engineRange(readManifest(starterDir)), + }) + + const stats = resolveMinimumNodeVersion(entries) + + console.info( + ` Scanned ${stats.packagesScanned} package(s), ${stats.packagesDeclaringNodeEngine} declaring engines.node`, + ) + + if (stats.minimumNodeVersion) { + console.info(` Minimum Node version: ${stats.minimumNodeVersion}`) + if (stats.imposedBy.length > 0) { + console.info(` Imposed by: ${stats.imposedBy.join(', ')}`) + } else { + console.info(' No single package imposes the floor; it is joint.') + } + } else { + console.info(' Could not resolve a minimum Node version.') + if (stats.unsatisfiableRanges.length > 0) { + console.info( + ` Unsatisfiable ranges: ${stats.unsatisfiableRanges.join(', ')}`, + ) + } + } + + const outputPath = join(packagesDir, packageName, 'node-engines-stats.json') + writeJsonFile(outputPath, stats) + + console.info(`\nāœ“ Saved Node engines stats to ${outputPath}`) +} + +main().catch((error) => { + console.error('Node engines scan failed:', error) + process.exit(1) +}) diff --git a/packages/stats-generator/src/save-ci-stats.ts b/packages/stats-generator/src/save-ci-stats.ts index e62b34f2..8ba798d6 100644 --- a/packages/stats-generator/src/save-ci-stats.ts +++ b/packages/stats-generator/src/save-ci-stats.ts @@ -14,6 +14,7 @@ import type { BuildStats, CoreJsStats, BrowserBaselineStats, + NodeEnginesStats, DependencyStats, E18eStats, } from './types.ts' @@ -131,6 +132,29 @@ async function main() { ) } + const nodeEnginesArtifactPath = join( + artifactsDir, + `node-engines-stats-${name}`, + 'node-engines-stats.json', + ) + const nodeEnginesStats = readJsonFile( + nodeEnginesArtifactPath, + ) + if (nodeEnginesStats) { + console.info(` āœ“ Found Node engines stats artifact`) + if (nodeEnginesStats.minimumNodeVersion) { + stats = { + ...stats, + minimumNodeVersion: nodeEnginesStats.minimumNodeVersion, + minimumNodeVersionImposedBy: nodeEnginesStats.imposedBy, + } + } + } else { + console.warn( + `No Node engines stats artifact found at ${nodeEnginesArtifactPath}`, + ) + } + // Load dependency stats from artifacts const e18eArtifactPath = join( artifactsDir, diff --git a/packages/stats-generator/src/schemas.ts b/packages/stats-generator/src/schemas.ts index 9961d72f..21436ccc 100644 --- a/packages/stats-generator/src/schemas.ts +++ b/packages/stats-generator/src/schemas.ts @@ -30,6 +30,14 @@ export const BrowserBaselineStatsSchema = z.object({ baselineFeatureCount: z.number().nonnegative(), }) +export const NodeEnginesStatsSchema = z.object({ + minimumNodeVersion: z.string().nullable(), + imposedBy: z.array(z.string()), + packagesScanned: z.number().nonnegative(), + packagesDeclaringNodeEngine: z.number().nonnegative(), + unsatisfiableRanges: z.array(z.string()), +}) + export const SSRRequestThroughputStatsSchema = z.object({ ssrRequestThroughputTests: z.object({ opsPerSec: z.number().positive(), @@ -121,6 +129,7 @@ export const ServerSideRenderedStatsSchema = z.object({ export type InstallStats = z.infer export type BuildStats = z.infer export type BrowserBaselineStats = z.infer +export type NodeEnginesStats = z.infer export type SSRRequestThroughputStats = z.infer< typeof SSRRequestThroughputStatsSchema > diff --git a/packages/stats-generator/src/types.ts b/packages/stats-generator/src/types.ts index ab7e11a8..72f30814 100644 --- a/packages/stats-generator/src/types.ts +++ b/packages/stats-generator/src/types.ts @@ -112,6 +112,9 @@ export interface CIStats { vendoredCoreJsUnnecessaryModules?: string[] // Browser baseline stats browserBaselineTests?: BrowserBaselineStats + // Minimum Node version across the installed dependency tree + minimumNodeVersion?: string + minimumNodeVersionImposedBy?: string[] // Dependency stats (from e18e analysis) prodDependencies?: number devDependencies?: number @@ -167,6 +170,14 @@ export interface BrowserBaselineStats { baselineFeatureCount: number } +export interface NodeEnginesStats { + minimumNodeVersion: string | null + imposedBy: string[] + packagesScanned: number + packagesDeclaringNodeEngine: number + unsatisfiableRanges: string[] +} + export interface TimeStat { avgMs: number minMs: number diff --git a/packages/stats-generator/src/validate-stats.ts b/packages/stats-generator/src/validate-stats.ts index fc0f2a08..5895c8d2 100644 --- a/packages/stats-generator/src/validate-stats.ts +++ b/packages/stats-generator/src/validate-stats.ts @@ -7,6 +7,7 @@ import { InstallStatsSchema, BuildStatsSchema, BrowserBaselineStatsSchema, + NodeEnginesStatsSchema, SSRRequestThroughputStatsSchema, SSRLoadStatsSchema, ClientSideRenderedStatsSchema, @@ -17,6 +18,7 @@ type BenchmarkType = | 'install' | 'build' | 'browserBaseline' + | 'nodeEngines' | 'ssrRequestThroughput' | 'ssrLoad' | 'clientSideRendered' @@ -36,6 +38,11 @@ const STARTER_BENCHMARKS: BenchmarkConfig[] = [ file: 'browser-baseline-stats.json', schema: BrowserBaselineStatsSchema, }, + { + type: 'nodeEngines', + file: 'node-engines-stats.json', + schema: NodeEnginesStatsSchema, + }, ] const APP_BENCHMARKS: BenchmarkConfig[] = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c3ca142..bf2fef1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: tinyglobby: specifier: ^0.2.17 version: 0.2.17 + verkit: + specifier: ^0.4.0 + version: 0.4.0 zod: specifier: ^4.4.3 version: 4.4.3 @@ -3800,6 +3803,10 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + verkit@0.4.0: + resolution: {integrity: sha512-sXMwN6DMHeouPfCxkxWkKAmxphWKEenHYY5H1nIBzU3PmDsmJp6kBXJdshjVdpMZuWCmL9SH7KFRx29AylpP6g==} + engines: {node: '>=18.12.0'} + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -8292,6 +8299,8 @@ snapshots: uuid@8.3.2: {} + verkit@0.4.0: {} + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3