diff --git a/package.json b/package.json index 321bfa3642a..3613f8cbce5 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "test:types": "npm run --workspaces test:types && tsc", "test": "npm run test:caniuse -- --quiet && npm run test:schematypes && npm run test:specs && npm run test:types && npm run test:format && npm run test:index && npm run test:dist && npm test --workspaces && npm run test:lint", "update-drafts": "tsx scripts/update-drafts.ts", - "remove-tagged-compat-features": "tsx scripts/remove-tagged-compat-features.ts && npm run format" + "remove-tagged-compat-features": "tsx scripts/remove-tagged-compat-features.ts && npm run format", + "stats": "tsx ./scripts/stats.ts" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/scripts/stats.ts b/scripts/stats.ts index 1e43c917164..344e3a175c8 100644 --- a/scripts/stats.ts +++ b/scripts/stats.ts @@ -1,70 +1,205 @@ import { Compat } from "compute-baseline/browser-compat-data"; +import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import yargs from "yargs"; -import { features } from "../index.ts"; +import { features, groups } from "../index.ts"; import { isOrdinaryFeatureData } from "../type-guards.ts"; +import { caniuseToWebFeaturesId } from "./caniuse.ts"; +import { compatFeaturesToCumulativeDaysShipped } from "./unmapped-compat-keys.ts"; -yargs(process.argv.slice(2)) +interface ResultBase { + featuresCount: number; // `kind: feature` ID count (ignores other `kind` values) + groupsCount: number; // group ID count + compatKeysCount: number; // Count of in-scope BCD keys (i.e., excluding webextensions) + normalCompatKeysCount: number; // Count of in-scope BCD keys that are "normal" — standard and not deprecated + discourageableCompatKeysCount: number; // Count of in-scope BCD keys that are not "normal" — non-standard or deprecated + + // Mapped keys are mapped to one or more web-features entries (higher is better). + // Unmapped keys are not mapped to any web-features entry (lower is better). + mappedCompatKeysCount: number; + unmappedCompatKeysCount: number; + + mappedNormalCompatKeysCount: number; + unmappedNormalCompatKeysCount: number; + + mappedDiscourageableCompatKeysCount: number; + unmappedDiscourageableCompatKeysCount: number; + + mappedCompatKeysRatio: number; // mappedCompatKeysCount : compatKeysCount + unmappedCompatKeysRatio: number; // unmappedCompatKeysCount : compatKeysCount + unmappedNormalCompatKeysRatio: number; // unmappedNormalCompatKeysCount : normalCompatKeysCount + unmappedDiscourageableCompatKeysRatio: number; // unmappedDiscourageableCompatKeysCount : discourageableCompatKeysCount + + caniuseIdsCount: number; // caniuse IDs + unmappedCaniuseIdsCount: number; // caniuseIDs that lack a corresponding web-features entry + unmappedCaniuseIdsRatio: number; // unmappedCaniuseIdsCont : caniuseIDs + + // The "cumulative shipping days" metrics are the sum of days since each BCD + // key has shipped in each browser in the core browser set. A smaller number + // is better. This number increases every day for every key that has shipped + // but has not been mapped. This metric encourages us to map keys that are + // more likely to affect real-world developers (i.e., features implemented in + // multiple browsers). Widely-implemented but not mapped keys increase this + // metric more (up to +7 per calendar day per key), while other keys increase + // this metric less or not at all. Keys which have not yet shipped in any + // stable release count as 0. Like the Baseline calculation, partials and + // prefixes do not count as shipping. + unmappedCompatKeysCumulativeShippingDays: number; + unmappedNormalCompatKeysCumulativeShippingDays: number; + unmappedDiscourageableCompatKeysCumulativeShippingDays: number; +} + +interface Result extends ResultBase { + change?: Change; +} + +type ResultKey = keyof Result; +type ChangeKey = `${ResultKey}Change`; +type Change = Record; + +const argv = yargs(process.argv.slice(2)) .scriptName("stats") - .usage("$0", "Generate statistics").argv; + .option("previous", { + alias: "p", + type: "string", + description: "Path to a JSON file", + coerce: (filePath) => { + const raw = readFileSync(filePath, "utf-8"); + return JSON.parse(raw); + }, + }) + .usage("$0", "Generate statistics") + .parseSync(); -export function stats() { - const featureCount = Object.values(features).filter( +export function stats(previous: Partial): Result { + const featuresCount = Object.values(features).filter( isOrdinaryFeatureData, ).length; + const groupsCount = Object.values(groups).length; - const keys = []; - const doneKeys = Array.from( - new Set( - Object.values(features).flatMap((f) => { - if (isOrdinaryFeatureData(f)) { - return f.compat_features ?? []; - } - return []; - }), - ), + const mappedCompatKeys = new Set( + Object.values(features).flatMap((f) => { + if (isOrdinaryFeatureData(f)) { + return f.compat_features ?? []; + } + return []; + }), ); + const inScopeCompatKeys = new Set(); + const deprecatedCompatKeys = new Set(); + const nonstandardCompatKeys = new Set(); for (const f of new Compat().walk()) { - if (!f.id.startsWith("webextensions")) { - keys.push(f.id); + if (!f.id.startsWith("webextensions.")) { + inScopeCompatKeys.add(f.id); + if (f.deprecated) { + deprecatedCompatKeys.add(f.id); + } + if (!f.standard_track) { + nonstandardCompatKeys.add(f.id); + } } } - const featureSizes = Object.values(features) - .filter(isOrdinaryFeatureData) - .map((feature) => (feature.compat_features ?? []).length) - .sort((a, b) => a - b); - - const result = { - features: featureCount, - compatKeys: doneKeys.length, - compatKeysUnmapped: keys.length - doneKeys.length, - compatCoverage: doneKeys.length / keys.length, - compatKeysPerFeatureMean: doneKeys.length / featureCount, - compatKeysPerFeatureMedian: (() => { - const sizes = featureSizes; - const middle = Math.floor(sizes.length / 2); - return sizes.length % 2 - ? sizes[middle] - : (sizes[middle - 1] + sizes[middle]) / 2; - })(), - compatKeysPerFeatureMode: (() => { - const frequencyMap = new Map(); - for (const size of featureSizes) { - frequencyMap.set(size, (frequencyMap.get(size) ?? 0) + 1); - } - return [...frequencyMap.entries()] - .sort(([, frequencyA], [, frequencyB]) => frequencyA - frequencyB) - .pop()[0]; - })(), + const discourageableCompatKeys = deprecatedCompatKeys.union( + nonstandardCompatKeys, + ); + const normalCompatKeys = inScopeCompatKeys.difference( + discourageableCompatKeys, + ); + const unmappedKeys = inScopeCompatKeys.difference(mappedCompatKeys); + + const compatKeysCount = inScopeCompatKeys.size; + const normalCompatKeysCount = normalCompatKeys.size; + const discourageableCompatKeysCount = compatKeysCount - normalCompatKeysCount; + + const mappedCompatKeysCount = mappedCompatKeys.size; + const unmappedCompatKeysCount = unmappedKeys.size; + const unmappedNormalCompatKeysCount = unmappedKeys.difference( + discourageableCompatKeys, + ).size; + const mappedNormalCompatKeysCount = + mappedCompatKeysCount - unmappedNormalCompatKeysCount; + const unmappedDiscourageableCompatKeysCount = + unmappedKeys.difference(normalCompatKeys).size; + const mappedDiscourageableCompatKeysCount = + discourageableCompatKeysCount - unmappedDiscourageableCompatKeysCount; + + const featuresToDays = compatFeaturesToCumulativeDaysShipped(); + const unmappedDiscourageableCompatKeysCumulativeShippingDays = Array.from( + featuresToDays.entries(), + ) + .filter(([f]) => discourageableCompatKeys.has(f.id)) + .map(([, days]) => days) + .reduce((prev, curr) => prev + curr, 0); + const unmappedNormalCompatKeysCumulativeShippingDays = Array.from( + featuresToDays.entries(), + ) + .filter(([f]) => normalCompatKeys.has(f.id)) + .map(([, days]) => days) + .reduce((prev, curr) => prev + curr, 0); + + const unmappedCompatKeysCumulativeShippingDays = + unmappedDiscourageableCompatKeysCumulativeShippingDays + + unmappedNormalCompatKeysCumulativeShippingDays; + + const caniuseIdsCount = [...caniuseToWebFeaturesId.keys()].length; + const unmappedCaniuseIdsCount = [...caniuseToWebFeaturesId.values()].filter( + (v) => v === null, + ).length; + const unmappedCaniuseIdsRatio = unmappedCaniuseIdsCount / caniuseIdsCount; + + const mappedCompatKeysRatio = mappedCompatKeysCount / compatKeysCount; + const unmappedCompatKeysRatio = unmappedCompatKeysCount / compatKeysCount; + const unmappedNormalCompatKeysRatio = + unmappedNormalCompatKeysCount / normalCompatKeysCount; + const unmappedDiscourageableCompatKeysRatio = + unmappedDiscourageableCompatKeysCount / discourageableCompatKeysCount; + + const result: ResultBase = { + featuresCount, + groupsCount, + compatKeysCount, + normalCompatKeysCount, + discourageableCompatKeysCount, + + mappedCompatKeysCount, + unmappedCompatKeysCount, + + mappedNormalCompatKeysCount, + unmappedNormalCompatKeysCount, + + mappedDiscourageableCompatKeysCount, + unmappedDiscourageableCompatKeysCount, + + mappedCompatKeysRatio, + unmappedCompatKeysRatio, + unmappedNormalCompatKeysRatio, + unmappedDiscourageableCompatKeysRatio, + + caniuseIdsCount, + unmappedCaniuseIdsCount, + unmappedCaniuseIdsRatio, + + unmappedCompatKeysCumulativeShippingDays, + unmappedNormalCompatKeysCumulativeShippingDays, + unmappedDiscourageableCompatKeysCumulativeShippingDays, }; + if (previous) { + const change = Object.fromEntries( + Object.keys(previous).map((key) => [ + `${key}Change`, + result[key] - previous[key], + ]), + ) as Change; + return { ...result, change }; + } return result; } if (import.meta.url.startsWith("file:")) { if (process.argv[1] === fileURLToPath(import.meta.url)) { - console.log(JSON.stringify(stats(), undefined, 2)); + console.log(JSON.stringify(stats(argv.previous), undefined, 2)); } } diff --git a/scripts/unmapped-compat-keys.ts b/scripts/unmapped-compat-keys.ts index c82b44149a1..1f949f09f24 100644 --- a/scripts/unmapped-compat-keys.ts +++ b/scripts/unmapped-compat-keys.ts @@ -1,39 +1,28 @@ import { Temporal } from "@js-temporal/polyfill"; import { coreBrowserSet } from "compute-baseline"; import { Compat, Feature } from "compute-baseline/browser-compat-data"; +import { fileURLToPath } from "node:url"; import winston from "winston"; import yargs from "yargs"; import { features } from "../index.ts"; import { support } from "../packages/compute-baseline/dist/baseline/support.js"; import { isOrdinaryFeatureData } from "../type-guards.ts"; +const defaultLogLevel = "warn"; + const compat = new Compat(); -const browsers = coreBrowserSet.map((b) => compat.browser(b)); -const today = Temporal.Now.plainDateISO(); -const argv = yargs(process.argv.slice(2)) - .scriptName("unmapped-compat-keys") - .usage( - "$0", - "Print keys from mdn/browser-compat-data not assigned to a feature", - ) - .option("format", { - choices: ["json", "yaml"], - default: "yaml", - describe: - "Choose the output format. JSON has more detail, while YAML is suited to pasting into feature files.", - }) - .option("verbose", { - alias: "v", - describe: "Show more information", - type: "count", - default: 0, - defaultDescription: "warn", - }) - .parseSync(); +// The reference date is a date that approximates "now" but doesn't advance past +// the moment in time that a BCD release represents. This makes generated stats +// consistent between runs where the underlying data hasn't changed but the +// wall-clock time has. It also permits running stats retrospectively. +const bcdTimestamp: string = (compat.data as any).__meta.timestamp; +const referenceDate = Temporal.Instant.from(bcdTimestamp) + .toZonedDateTimeISO("UTC") + .toPlainDate(); const logger = winston.createLogger({ - level: argv.verbose > 0 ? "debug" : "warn", + level: defaultLogLevel, format: winston.format.combine( winston.format.colorize(), winston.format.simple(), @@ -52,7 +41,10 @@ const mappedCompatKeys = (() => { ); })(); -const compatFeatures: Map = (() => { +/** + * Get a map of each compat key to the sum of days that key has been shipping. + */ +export function compatFeaturesToCumulativeDaysShipped(): Map { const map = new Map(); for (const f of compat.walk()) { if (f.id.startsWith("webextensions")) { @@ -66,30 +58,6 @@ const compatFeatures: Map = (() => { map.set(f, cumulativeDaysShipped(f)); } return map; -})(); - -const byAge = [...compatFeatures.entries()].sort( - ([, aDays], [, bDays]) => aDays - bDays, -); - -if (argv.format === "yaml") { - for (const [f] of byAge) { - console.log(` - ${f.id}`); - } -} - -if (argv.format === "json") { - console.log( - JSON.stringify( - byAge.map(([f, days]) => ({ - key: f.id, - cumulativeDaysShipped: days, - deprecated: f.deprecated, - })), - undefined, - 2, - ), - ); } /** @@ -103,15 +71,71 @@ if (argv.format === "json") { * @return {number} an integer */ function cumulativeDaysShipped(feature: Feature) { + const browsers = coreBrowserSet.map((b) => compat.browser(b)); const results = support(feature, browsers); return [...results.values()] .filter((r) => r !== undefined) .map( (r) => - r.release.date.until(today, { + r.release.date.until(referenceDate, { largestUnit: "days", smallestUnit: "days", }).days, ) .reduce((prev, curr) => prev + curr, 0); } + +function main() { + const argv = yargs(process.argv.slice(2)) + .scriptName("unmapped-compat-keys") + .usage( + "$0", + "Print keys from mdn/browser-compat-data not assigned to a feature", + ) + .option("format", { + choices: ["json", "yaml"], + default: "yaml", + describe: + "Choose the output format. JSON has more detail, while YAML is suited to pasting into feature files.", + }) + .option("verbose", { + alias: "v", + describe: "Show more information", + type: "count", + default: 0, + defaultDescription: "warn", + }) + .parseSync(); + + logger.transports[0].level = argv.verbose > 0 ? "debug" : "warn"; + + const byAge = [...compatFeaturesToCumulativeDaysShipped().entries()].sort( + ([, aDays], [, bDays]) => aDays - bDays, + ); + + if (argv.format === "yaml") { + for (const [f] of byAge) { + console.log(` - ${f.id}`); + } + } + + if (argv.format === "json") { + console.log( + JSON.stringify( + byAge.map(([f, days]) => ({ + key: f.id, + cumulativeDaysShipped: days, + deprecated: f.deprecated, + })), + undefined, + 2, + ), + ); + } +} + +if (import.meta.url.startsWith("file:")) { + if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); + } +}