diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5619a2..8fba6eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,10 @@ Git tracks the artifacts in `derived/current/` and `generated/current/`. If your If the tracked artifacts differ from the generated artifacts, CI fails. +A compat key can belong to more than one web-features feature. The dataset stores one row per key. Overlapping rows include a sorted `featureMemberships` array with each feature's name, snapshots, and groups. The existing scalar feature fields describe the first membership; use `getCompatFeatureMemberships` to read all memberships. + +Inclusion uses the key's own Baseline status and dates. All memberships must agree on those facts, but their feature metadata may differ. Conflicting facts and repeated keys within one feature stop generation. Allowlist entries remain tied to compat keys rather than feature IDs. + ## Change the compat-management registry `registry/compat-management.json` is the ledger for special compat rows. The generator uses a fail-closed policy. diff --git a/lib/classifier.mjs b/lib/classifier.mjs index baef2cc..fa3d5cf 100644 --- a/lib/classifier.mjs +++ b/lib/classifier.mjs @@ -28,6 +28,8 @@ import { isLibCompatKey, } from "./surface-inventory.mjs"; +/** @typedef {import("./compat-rows.mjs").CompatRow} CompatRow */ + const BEHAVIOR_QUALIFIERS = new Set([ "anchored_sticky_flag", "Atomic_operations_on_non_shared_buffers", @@ -93,7 +95,7 @@ const TYPED_ARRAY_INSTANCE_SYMBOLS = [ * dataset, that signals an upstream modeling change, so stop and fail closed. * Resolution runs only when a TypedArray row actually appears (lazy). * - * @param {any[]} libCompatRows + * @param {CompatRow[]} libCompatRows * @param {string} baselineTarget * @returns {() => { instanceSymbols: string[]; constructorSymbols: string[]; }} */ @@ -206,14 +208,8 @@ export async function classifyManifest(options) { ); const libCompatRows = dataset.compatRows - .filter( - /** @param {any} compatRow */ - compatRow => isLibCompatKey(compatRow.compatKey), - ) - .sort( - /** @param {any} left @param {any} right */ - (left, right) => compareStringsCaseSensitive(left.compatKey, right.compatKey), - ); + .filter(compatRow => isLibCompatKey(compatRow.compatKey)) + .sort((left, right) => compareStringsCaseSensitive(left.compatKey, right.compatKey)); const compatManagementRegistry = await loadCompatManagementRegistry(compatManagementRegistryPath, manifest.libSource?.basePackage); @@ -288,7 +284,7 @@ export async function classifyManifest(options) { /** * @param {{ - * libCompatRows: any[]; + * libCompatRows: CompatRow[]; * baselineTarget: string; * inventory: import("./surface-inventory.mjs").SurfaceInventory; * compatManagementRegistry: CompatManagementRegistry; @@ -541,7 +537,7 @@ function getCompatManagementReport(options) { /** * @param {{ - * compatRow: any; + * compatRow: CompatRow; * baselineTarget: string; * inventory: import("./surface-inventory.mjs").SurfaceInventory; * compatManagementRegistry: CompatManagementRegistry; @@ -805,7 +801,7 @@ function resolveCompatRoot(inventory, compatSegments, compatManagement, typedArr /** * @param {{ - * compatRow: any; + * compatRow: CompatRow; * compatRoot: string; * qualifierSegments: string[]; * includeInTarget: boolean; @@ -920,7 +916,7 @@ function classifyConstructorRow(options) { /** * @param {{ - * compatRow: any; + * compatRow: CompatRow; * compatRoot: string; * memberName: string; * qualifierSegments: string[]; @@ -1063,7 +1059,7 @@ function classifyMemberRow(options) { /** * @param {{ - * compatRow: any; + * compatRow: CompatRow; * compatRoot: string; * tailSegments: string[]; * inventory: import("./surface-inventory.mjs").SurfaceInventory; @@ -1254,7 +1250,7 @@ function hasReviverContextParameter(unit) { } /** - * @param {any} compatRow + * @param {CompatRow} compatRow * @param {string} compatRoot * @param {boolean} includeInTarget * @param {{ @@ -1266,15 +1262,16 @@ function hasReviverContextParameter(unit) { * @param {CompatManagementEntry | undefined} compatManagement */ function createClassifiedRow(compatRow, compatRoot, includeInTarget, resolution, compatManagement) { - return { + /** @type {ClassifiedCompatRow} */ + const classifiedRow = { compatKey: compatRow.compatKey, featureId: compatRow.featureId, featureName: compatRow.featureName, baselineStatus: compatRow.baselineStatus, baselineLowDate: compatRow.baselineLowDate, baselineHighDate: compatRow.baselineHighDate, - sourceRefs: compatRow.sourceRefs ?? [], - snapshot: compatRow.snapshot ?? [], + sourceRefs: compatRow.sourceRefs, + snapshot: compatRow.snapshot, compatRoot, includeInTarget, resolutionKind: resolution.resolutionKind, @@ -1292,6 +1289,12 @@ function createClassifiedRow(compatRow, compatRoot, includeInTarget, resolution, expectedResolutionKinds: compatManagement.expectedResolutionKinds, } : undefined, }; + + if (compatRow.featureMemberships) { + classifiedRow.featureMemberships = compatRow.featureMemberships; + } + + return classifiedRow; } /** @@ -1469,7 +1472,7 @@ function isBehaviorQualifier(qualifierSegments) { } /** - * @param {{ baselineStatus: string | boolean | undefined; baselineLowDate?: string; compatKey: string; }} compatRow + * @param {CompatRow} compatRow * @param {string} baselineTarget */ function isCompatIncluded(compatRow, baselineTarget) { @@ -1537,7 +1540,8 @@ function isRegistryManagedResolutionKind(resolutionKind) { * compatKey: string; * featureId: string; * featureName: string; - * baselineStatus: string | boolean | undefined; + * featureMemberships?: import("./compat-rows.mjs").FeatureMembership[]; + * baselineStatus: CompatRow["baselineStatus"]; * baselineLowDate?: string; * baselineHighDate?: string; * sourceRefs: string[]; @@ -1555,48 +1559,16 @@ function isRegistryManagedResolutionKind(resolutionKind) { * upstreamState: string; * reason: string; * sourceUrls: string[]; - * externalAction?: any; + * externalAction?: import("./compat-management-registry.mjs").CompatManagementEntry["externalAction"]; * expectedResolutionKinds?: string[]; * }; * }} ClassifiedCompatRow */ /** - * @typedef {{ - * id: string; - * category: string; - * delivery: string; - * upstreamState: string; - * compatRoot?: string; - * expectedResolutionKinds?: string[]; - * reason: string; - * sourceUrls: string[]; - * externalAction?: any; - * compatKeys: string[]; - * }} CompatManagementGroupRecord - */ - -/** - * @typedef {{ - * compatKey: string; - * groupId: string; - * category: string; - * delivery: string; - * upstreamState: string; - * compatRoot?: string; - * expectedResolutionKinds?: string[]; - * declarationMapping?: DeclarationMapping; - * reason: string; - * sourceUrls: string[]; - * externalAction?: any; - * }} CompatManagementEntry - */ - -/** - * @typedef {{ - * scope: "instance" | "static"; - * memberNames: string[]; - * }} DeclarationMapping + * @typedef {import("./compat-management-registry.mjs").CompatManagementGroupRecord} CompatManagementGroupRecord + * @typedef {import("./compat-management-registry.mjs").CompatManagementEntry} CompatManagementEntry + * @typedef {import("./compat-management-registry.mjs").DeclarationMapping} DeclarationMapping */ /** diff --git a/lib/compat-rows.mjs b/lib/compat-rows.mjs new file mode 100644 index 0000000..e8d80b7 --- /dev/null +++ b/lib/compat-rows.mjs @@ -0,0 +1,187 @@ +// @ts-check + +import { compareStringsCaseSensitive } from "./shared.mjs"; + +/** @type {ReadonlyArray} */ +const COMPAT_FACT_FIELDS = ["baselineStatus", "baselineLowDate", "baselineHighDate"]; + +const BASELINE_DATE_FIELDS = /** @type {const} */ (["baselineLowDate", "baselineHighDate"]); + +/** @param {CompatFeatureMetadata} row @returns {FeatureMembership[]} */ +export function getCompatFeatureMemberships(row) { + return row.featureMemberships ?? [{ + featureId: row.featureId, + featureName: row.featureName, + snapshot: row.snapshot ?? [], + group: row.group ?? [], + }]; +} + +/** @param {unknown} value @returns {CompatRow} */ +export function parseCompatRow(value) { + const data = readDataRecord(value, "Compat row"); + const compatKey = readDataString(data.compatKey, "Compat row compatKey"); + const label = `Compat row ${compatKey}`; + /** @type {CompatFacts} */ + const facts = { baselineStatus: parseBaselineStatus(data.baselineStatus, `${label} baselineStatus`) }; + + for (const field of BASELINE_DATE_FIELDS) { + if (data[field] !== undefined) { + facts[field] = readDataString(data[field], `${label} ${field}`); + } + } + + const membership = parseFeatureMembership(data, label); + + /** @type {CompatRow} */ + const row = { + compatKey, + featureId: membership.featureId, + featureName: membership.featureName, + ...facts, + snapshot: membership.snapshot, + group: membership.group, + sourceRefs: readDataStringArray(data.sourceRefs, `${label} sourceRefs`), + }; + + if (data.featureMemberships !== undefined) { + if (!Array.isArray(data.featureMemberships) || data.featureMemberships.length < 2) { + throw new Error(`${label} has invalid featureMemberships`); + } + + row.featureMemberships = data.featureMemberships.map(membership => parseFeatureMembership(membership, label, true)); + } + + return row; +} + +/** @param {unknown} value @param {string} label @param {boolean} [requireArrays] @returns {FeatureMembership} */ +function parseFeatureMembership(value, label, requireArrays = false) { + const data = readDataRecord(value, `${label} feature membership`); + + return { + featureId: readDataString(data.featureId, `${label} feature membership featureId`), + featureName: readDataString(data.featureName, `${label} feature membership featureName`), + snapshot: readDataStringArray(data.snapshot, `${label} feature membership snapshot`, requireArrays), + group: readDataStringArray(data.group, `${label} feature membership group`, requireArrays), + }; +} + +/** @param {unknown} value @param {string} label @returns {BaselineStatus} */ +export function parseBaselineStatus(value, label) { + if (value !== "high" && value !== "low" && value !== false) { + throw new Error(`${label} has unsupported baseline status ${JSON.stringify(value)}`); + } + + return value; +} + +/** @param {unknown} value @param {string} label @returns {Record} */ +export function readDataRecord(value, label) { + if (!isDataRecord(value)) { + throw new Error(`${label} must be an object`); + } + + return value; +} + +/** @param {unknown} value @returns {value is Record} */ +function isDataRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** @param {unknown} value @param {string} label @returns {string} */ +export function readDataString(value, label) { + if (typeof value !== "string" || !value) { + throw new Error(`${label} must be a nonempty string`); + } + + return value; +} + +/** @param {unknown} value @param {string} label @param {boolean} [required] @returns {string[]} */ +export function readDataStringArray(value, label, required = false) { + if (value === undefined && !required) { + return []; + } + + if (!Array.isArray(value)) { + throw new Error(`${label} must be an array of strings`); + } + + const values = []; + + for (const item of value) { + if (typeof item !== "string") { + throw new Error(`${label} must be an array of strings`); + } + + values.push(item); + } + + return values; +} + +/** @param {unknown[]} rows @returns {CompatRow[]} */ +export function normalizeCompatRows(rows) { + /** @type {Map} */ + const rowsByKey = new Map(); + + for (const value of rows) { + const row = parseCompatRow(value); + const members = rowsByKey.get(row.compatKey) ?? []; + members.push(row); + rowsByKey.set(row.compatKey, members); + } + + return [...rowsByKey.entries()] + .sort(([left], [right]) => compareStringsCaseSensitive(left, right)) + .map(([compatKey, matchingRows]) => { + /** @type {Map} */ + const memberships = new Map(); + const sourceRefs = new Set(); + + for (const row of matchingRows) { + const conflictField = COMPAT_FACT_FIELDS.find(field => row[field] !== matchingRows[0][field]); + + if (conflictField) { + throw new Error( + `Compat key ${compatKey} has conflicting ${conflictField} across features ` + + `${matchingRows[0].featureId} and ${row.featureId}`, + ); + } + + for (const membership of getCompatFeatureMemberships(row)) { + if (memberships.has(membership.featureId)) { + throw new Error(`Duplicate compat key membership ${compatKey} in feature ${membership.featureId}`); + } + + memberships.set(membership.featureId, membership); + } + + for (const sourceRef of row.sourceRefs) { + sourceRefs.add(sourceRef); + } + } + + if (memberships.size === 1) { + return matchingRows[0]; + } + + const featureMemberships = [...memberships.values()] + .sort((left, right) => compareStringsCaseSensitive(left.featureId, right.featureId)); + + return { + ...matchingRows[0], + ...featureMemberships[0], + sourceRefs: [...sourceRefs].sort(compareStringsCaseSensitive), + featureMemberships, + }; + }); +} + +/** @typedef {"high" | "low" | false} BaselineStatus */ +/** @typedef {{ baselineStatus: BaselineStatus; baselineLowDate?: string; baselineHighDate?: string; }} CompatFacts */ +/** @typedef {{ featureId: string; featureName: string; snapshot: string[]; group: string[]; }} FeatureMembership */ +/** @typedef {{ featureId: string; featureName: string; snapshot?: string[]; group?: string[]; featureMemberships?: FeatureMembership[]; }} CompatFeatureMetadata */ +/** @typedef {FeatureMembership & CompatFacts & { compatKey: string; sourceRefs: string[]; featureMemberships?: FeatureMembership[]; }} CompatRow */ diff --git a/lib/dataset-loader.mjs b/lib/dataset-loader.mjs index c48a5ae..75d2af1 100644 --- a/lib/dataset-loader.mjs +++ b/lib/dataset-loader.mjs @@ -1,6 +1,14 @@ // @ts-check import { readFile } from "node:fs/promises"; +import { + getCompatFeatureMemberships, + normalizeCompatRows, + parseBaselineStatus, + readDataRecord, + readDataString, + readDataStringArray, +} from "./compat-rows.mjs"; /** * @param {string} filePath @@ -9,85 +17,111 @@ import { readFile } from "node:fs/promises"; * @param {string} [expectedWebFeaturesVersion] */ export async function loadBaselineDataset(filePath, expectedSnapshot, expectedBaselineDate, expectedWebFeaturesVersion) { - const dataset = JSON.parse(await readFile(filePath, "utf8")); - const snapshotDate = expectedBaselineDate - ? parseIsoDate(expectedBaselineDate, "manifest snapshot baselineDate") - : undefined; - if (!dataset.snapshot?.name) { - throw new Error(`Dataset ${filePath} is missing snapshot.name`); - } - if (dataset.snapshot.name !== expectedSnapshot) { - throw new Error(`Dataset snapshot ${dataset.snapshot.name} does not match expected snapshot ${expectedSnapshot}`); + return parseBaselineDataset(JSON.parse(await readFile(filePath, "utf8")), filePath, expectedSnapshot, expectedBaselineDate, expectedWebFeaturesVersion); +} + +/** + * @param {unknown} value + * @param {string} filePath + * @param {string} expectedSnapshot + * @param {string} [expectedBaselineDate] + * @param {string} [expectedWebFeaturesVersion] + */ +export function parseBaselineDataset(value, filePath, expectedSnapshot, expectedBaselineDate, expectedWebFeaturesVersion) { + const data = readDataRecord(value, `Dataset ${filePath}`); + const snapshot = parseDatasetSnapshot(data.snapshot); + const baselineDate = expectedBaselineDate ?? snapshot.baselineDate; + const snapshotDate = baselineDate === undefined ? undefined : parseIsoDate(baselineDate, "snapshot baselineDate"); + + if (snapshot.name !== expectedSnapshot) { + throw new Error(`Dataset snapshot ${snapshot.name} does not match expected snapshot ${expectedSnapshot}`); } - if (expectedBaselineDate && dataset.snapshot.baselineDate !== expectedBaselineDate) { + + if (expectedBaselineDate && snapshot.baselineDate !== expectedBaselineDate) { throw new Error( - `Dataset baselineDate ${String(dataset.snapshot.baselineDate)} does not match expected ${expectedBaselineDate}`, + `Dataset baselineDate ${String(snapshot.baselineDate)} does not match expected ${expectedBaselineDate}`, ); } + if ( expectedWebFeaturesVersion - && dataset.snapshot.webFeaturesPackageVersion !== expectedWebFeaturesVersion + && snapshot.webFeaturesPackageVersion !== expectedWebFeaturesVersion ) { throw new Error( - `Dataset webFeaturesPackageVersion ${String(dataset.snapshot.webFeaturesPackageVersion)} ` + `Dataset webFeaturesPackageVersion ${String(snapshot.webFeaturesPackageVersion)} ` + `does not match expected ${expectedWebFeaturesVersion}`, ); } - if (!Array.isArray(dataset.featureRows)) { + + if (!Array.isArray(data.featureRows)) { throw new Error(`Dataset ${filePath} is missing featureRows`); } - if (!Array.isArray(dataset.compatRows)) { + + if (!Array.isArray(data.compatRows)) { throw new Error(`Dataset ${filePath} is missing compatRows`); } + const featureRows = data.featureRows.map(parseFeatureRow); + /** @type {Map} */ const featureRowById = new Map(); - for (const featureRow of dataset.featureRows) { - if (!featureRow.featureId) { - throw new Error(`Dataset ${filePath} has a feature row without featureId`); - } + + for (const featureRow of featureRows) { if (featureRowById.has(featureRow.featureId)) { throw new Error(`Dataset ${filePath} has duplicate featureId ${featureRow.featureId}`); } + featureRowById.set(featureRow.featureId, featureRow); } - const compatRowByKey = new Map(); - for (const compatRow of dataset.compatRows) { - if (!compatRow.compatKey) { - throw new Error(`Dataset ${filePath} has a compat row without compatKey`); - } - if (compatRowByKey.has(compatRow.compatKey)) { - throw new Error(`Dataset ${filePath} has duplicate compatKey ${compatRow.compatKey}`); + const compatRows = normalizeCompatRows(data.compatRows); + + for (const compatRow of compatRows) { + validateCompatDates(compatRow, filePath, snapshotDate); + + for (const membership of getCompatFeatureMemberships(compatRow)) { + if (!featureRowById.has(membership.featureId)) { + throw new Error(`Dataset ${filePath} compat row ${compatRow.compatKey} references missing feature ${membership.featureId}`); + } } - // Inclusion depends on an exact match of baselineStatus. Silently accepting a - // dataset whose enum was renamed or dropped would quietly generate a nearly empty - // lib with every row excluded, so reject it at load time. - if (compatRow.baselineStatus !== "high" && compatRow.baselineStatus !== "low" && compatRow.baselineStatus !== false) { + } + + const compatRowByKey = new Map(compatRows.map(row => [row.compatKey, row])); + + return { snapshot, featureRows, compatRows, featureRowById, compatRowByKey }; +} + +/** + * @param {import("./compat-rows.mjs").CompatRow} compatRow + * @param {string} filePath + * @param {string | undefined} snapshotDate + */ +function validateCompatDates(compatRow, filePath, snapshotDate) { + if (compatRow.baselineStatus !== false || compatRow.baselineLowDate !== undefined) { + const lowDate = parseBaselineLowDate( + compatRow.baselineLowDate, + `compat row ${compatRow.compatKey} baselineLowDate`, + ); + + if (snapshotDate && lowDate > snapshotDate) { throw new Error( - `Dataset ${filePath} compat row ${compatRow.compatKey} has unsupported baselineStatus ` - + `${JSON.stringify(compatRow.baselineStatus)} (expected "high", "low", or false)`, + `Dataset ${filePath} compat row ${compatRow.compatKey} baselineLowDate ` + + `${lowDate} is after snapshot ${snapshotDate}`, ); } - if (compatRow.baselineStatus !== false || compatRow.baselineLowDate !== undefined) { - const lowDate = parseBaselineLowDate( - compatRow.baselineLowDate, - `compat row ${compatRow.compatKey} baselineLowDate`, - ); - if (snapshotDate && lowDate > snapshotDate) { - throw new Error( - `Dataset ${filePath} compat row ${compatRow.compatKey} baselineLowDate ` - + `${lowDate} is after snapshot ${snapshotDate}`, - ); - } - } - compatRowByKey.set(compatRow.compatKey, compatRow); } - return { - ...dataset, - featureRowById, - compatRowByKey, - }; + if (compatRow.baselineHighDate !== undefined) { + const highDate = parseBaselineLowDate(compatRow.baselineHighDate, `compat row ${compatRow.compatKey} baselineHighDate`); + + if ( + compatRow.baselineLowDate !== undefined + && !compatRow.baselineLowDate.startsWith("≤") + && !compatRow.baselineHighDate.startsWith("≤") + && highDate < compatRow.baselineLowDate + ) { + throw new Error(`Dataset ${filePath} compat row ${compatRow.compatKey} baselineHighDate is before baselineLowDate`); + } + } } /** @@ -98,6 +132,7 @@ export function parseBaselineLowDate(value, label) { if (typeof value !== "string" || !/^(?:≤)?\d{4}-\d{2}-\d{2}$/u.test(value)) { throw new Error(`${label} is not a valid Baseline date`); } + return parseIsoDate(value.replace(/^≤/u, ""), label); } @@ -107,6 +142,7 @@ export function parseBaselineLowDate(value, label) { */ function parseIsoDate(value, label) { const timestamp = Date.parse(`${value}T00:00:00Z`); + if ( !/^\d{4}-\d{2}-\d{2}$/u.test(value) || !Number.isFinite(timestamp) @@ -114,5 +150,62 @@ function parseIsoDate(value, label) { ) { throw new Error(`${label} is not a valid ISO date: ${value}`); } + return value; } + +/** @param {unknown} value @returns {DatasetSnapshot} */ +function parseDatasetSnapshot(value) { + const data = readDataRecord(value, "Dataset snapshot"); + /** @type {DatasetSnapshot} */ + const snapshot = { name: readDataString(data.name, "Dataset snapshot.name") }; + + for (const field of /** @type {const} */ (["baselineDate", "extractedDate", "webFeaturesPackageName", "webFeaturesPackageVersion"])) { + if (data[field] !== undefined) { + snapshot[field] = readDataString(data[field], `Dataset snapshot.${field}`); + } + } + + if (snapshot.extractedDate !== undefined) { + parseIsoDate(snapshot.extractedDate, "Dataset snapshot.extractedDate"); + } + + return snapshot; +} + +/** @param {unknown} value @returns {FeatureRow} */ +function parseFeatureRow(value) { + const data = readDataRecord(value, "Dataset feature row"); + /** @type {FeatureRow} */ + const row = { featureId: readDataString(data.featureId, "Dataset feature row featureId") }; + + for (const field of /** @type {const} */ (["featureName", "baselineLowDate", "baselineHighDate"])) { + if (data[field] !== undefined) { + row[field] = readDataString(data[field], `Feature ${row.featureId} ${field}`); + } + } + + for (const field of /** @type {const} */ (["snapshot", "group", "spec"])) { + if (data[field] !== undefined) { + row[field] = readDataStringArray(data[field], `Feature ${row.featureId} ${field}`); + } + } + + if (data.baselineStatus !== undefined) { + row.baselineStatus = parseBaselineStatus(data.baselineStatus, `Feature ${row.featureId}`); + } + + if (data.hasCompatRows !== undefined) { + if (data.hasCompatRows !== true && data.hasCompatRows !== false) { + throw new Error(`Feature ${row.featureId} has invalid hasCompatRows`); + } + + row.hasCompatRows = data.hasCompatRows; + } + + return row; +} + +/** @typedef {{ name: string; baselineDate?: string; extractedDate?: string; webFeaturesPackageName?: string; webFeaturesPackageVersion?: string; }} DatasetSnapshot */ +/** @typedef {{ featureId: string; featureName?: string; baselineStatus?: import("./compat-rows.mjs").BaselineStatus; baselineLowDate?: string; baselineHighDate?: string; snapshot?: string[]; group?: string[]; spec?: string[]; hasCompatRows?: boolean; }} FeatureRow */ +/** @typedef {{ snapshot: DatasetSnapshot; featureRows: FeatureRow[]; compatRows: import("./compat-rows.mjs").CompatRow[]; }} BaselineDataset */ diff --git a/lib/web-features-dataset.mjs b/lib/web-features-dataset.mjs index d6efee4..66ce2bd 100644 --- a/lib/web-features-dataset.mjs +++ b/lib/web-features-dataset.mjs @@ -6,15 +6,17 @@ import { writeFile, } from "node:fs/promises"; import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { + normalizeCompatRows, + parseBaselineStatus, + readDataRecord, + readDataString, + readDataStringArray, +} from "./compat-rows.mjs"; import { readInstalledPackageJson, resolveInstalledPackageFile } from "./installed-package.mjs"; import { compareStringsCaseSensitive } from "./shared.mjs"; -// web-features v3 entry kinds. An unknown kind signals a new data shape, so -// silently skipping it would drop features. Fail-closed instead. -const KNOWN_FEATURE_KINDS = new Set(["feature", "moved", "split"]); - -const VALID_BASELINE_STATUSES = new Set(["high", "low", false]); - /** * @param {{ * repoRoot: string; @@ -26,122 +28,105 @@ const VALID_BASELINE_STATUSES = new Set(["high", "low", false]); export async function buildWebFeaturesDataset(options) { const packageName = options.packageName ?? "web-features"; const webFeaturesPackageJson = await readInstalledPackageJson(options.repoRoot, packageName); - /** @type {{ features?: Record; }} */ - const webFeaturesData = JSON.parse( + + const webFeaturesData = readDataRecord(JSON.parse( await readFile(resolveInstalledPackageFile(options.repoRoot, packageName, "data.json"), "utf8"), - ); - if (!webFeaturesData.features || typeof webFeaturesData.features !== "object" || Array.isArray(webFeaturesData.features)) { - throw new Error(`${packageName} data.json is missing the features map`); - } + ), `${packageName} data.json`); - /** @type {any[]} */ + const features = readDataRecord(webFeaturesData.features, `${packageName} features map`); + /** @type {ExtractedFeatureRow[]} */ const featureRows = []; - /** @type {any[]} */ + /** @type {import("./compat-rows.mjs").CompatRow[]} */ const compatRows = []; - for (const [featureId, feature] of Object.entries(webFeaturesData.features)) { + for (const [featureId, value] of Object.entries(features)) { + const feature = readDataRecord(value, `${packageName} feature ${featureId}`); const kind = feature.kind ?? "feature"; - if (!KNOWN_FEATURE_KINDS.has(kind)) { - throw new Error( - `${packageName} feature ${featureId} has unknown kind "${kind}". ` - + `Update the extractor for the new web-features schema instead of skipping entries silently.`, - ); + + if (kind !== "feature" && kind !== "moved" && kind !== "split") { + throw new Error(`${packageName} feature ${featureId} has unknown kind "${String(kind)}"`); } - // "moved" / "split" are redirect entries with no feature data. + if (kind !== "feature") { continue; } - const snapshot = Array.isArray(feature.snapshot) - ? feature.snapshot.filter( - /** @param {unknown} snapshotValue */ - snapshotValue => typeof snapshotValue === "string", - ) - : typeof feature.snapshot === "string" - ? [feature.snapshot] - : []; + const label = `${packageName} feature ${featureId}`; + + const snapshot = typeof feature.snapshot === "string" + ? [feature.snapshot] + : readDataStringArray(feature.snapshot, `${label} snapshot`); + + const status = feature.status === undefined ? undefined : readDataRecord(feature.status, `${label} status`); + + const byCompatKey = status?.by_compat_key === undefined + ? {} + : readDataRecord(status.by_compat_key, `${label} status.by_compat_key`); + if ( feature.compat_features === undefined && ( snapshot.some( - /** @param {string} value */ value => value.startsWith("ecmascript-"), ) - || Object.keys(feature.status?.by_compat_key ?? {}).length > 0 + || Object.keys(byCompatKey).length > 0 ) ) { throw new Error(`${packageName} feature ${featureId} is missing compat_features for compatibility-backed data`); } - if (feature.compat_features !== undefined && !Array.isArray(feature.compat_features)) { - throw new Error(`${packageName} feature ${featureId} has non-array compat_features`); - } - const rawCompatFeatures = feature.compat_features ?? []; - if (rawCompatFeatures.some( - /** @param {unknown} compatKey */ - compatKey => typeof compatKey !== "string", - )) { - throw new Error(`${packageName} feature ${featureId} has a non-string compat_features entry`); - } + + const rawCompatFeatures = readDataStringArray(feature.compat_features, `${label} compat_features`); + const compatFeatures = rawCompatFeatures.filter( - /** @param {string} compatKey */ compatKey => compatKey.startsWith("javascript."), ); + const isJavaScriptFeature = compatFeatures.length > 0 || snapshot.some( - /** @param {string} snapshotValue */ snapshotValue => snapshotValue.startsWith("ecmascript-"), ); + if (!isJavaScriptFeature) { continue; } - if (!feature.status || typeof feature.status !== "object") { - throw new Error(`${packageName} feature ${featureId} is missing status`); + if (!status) { + throw new Error(`${label} is missing status`); } - requireValidBaselineStatus(packageName, `feature ${featureId}`, feature.status.baseline); + + const metadata = { + featureId, + featureName: readDataString(feature.name, `${label} name`), + snapshot, + group: readDataStringArray(feature.group, `${label} group`), + }; featureRows.push({ featureId, - featureName: feature.name, - baselineStatus: feature.status.baseline, - baselineLowDate: feature.status.baseline_low_date, - baselineHighDate: feature.status.baseline_high_date, + featureName: metadata.featureName, + ...parseSourceStatus(status, label), snapshot, - group: feature.group ?? [], + group: metadata.group, hasCompatRows: compatFeatures.length > 0, - spec: feature.spec ?? [], + spec: readDataStringArray(feature.spec, `${label} spec`), }); for (const compatKey of compatFeatures) { - // Don't let per-key status silently fall back to the feature-level - // status. If by_compat_key is renamed, removed, or missing and we - // inherit the feature status, a row where the key is low/false while - // the whole feature is high gets wrongly included, and the wrong .d.ts - // is generated with no error (the biggest hole in the fail-closed contract). - const compatStatus = feature.status.by_compat_key?.[compatKey]; - if (!compatStatus || typeof compatStatus !== "object") { - throw new Error( - `${packageName} feature ${featureId} is missing status.by_compat_key["${compatKey}"]. ` - + `Refusing to fall back to the feature-level status.`, - ); + const compatStatus = byCompatKey[compatKey]; + + if (compatStatus === undefined) { + throw new Error(`${label} is missing status.by_compat_key["${compatKey}"]`); } - requireValidBaselineStatus(packageName, `feature ${featureId} compat key ${compatKey}`, compatStatus.baseline); compatRows.push({ compatKey, - featureId, - featureName: feature.name, - baselineStatus: compatStatus.baseline, - baselineLowDate: compatStatus.baseline_low_date, - baselineHighDate: compatStatus.baseline_high_date, - snapshot, - group: feature.group ?? [], + ...metadata, + ...parseSourceStatus(readDataRecord(compatStatus, `${label} compat key ${compatKey}`), `${label} compat key ${compatKey}`), sourceRefs: [compatKey], }); } } featureRows.sort((left, right) => compareStringsCaseSensitive(left.featureId, right.featureId)); - compatRows.sort((left, right) => compareStringsCaseSensitive(left.compatKey, right.compatKey)); return { snapshot: { @@ -152,26 +137,25 @@ export async function buildWebFeaturesDataset(options) { webFeaturesPackageVersion: webFeaturesPackageJson.version, }, featureRows, - compatRows, + compatRows: normalizeCompatRows(compatRows), }; } /** - * @param {{ repoRoot: string; packageName?: string; dataset: any; }} options + * @param {{ repoRoot: string; packageName?: string; dataset: unknown; }} options */ export async function verifyWebFeaturesDataset(options) { + const data = readDataRecord(options.dataset, "Dataset"); + const snapshot = readDataRecord(data.snapshot, "Dataset snapshot"); + const expected = await buildWebFeaturesDataset({ repoRoot: options.repoRoot, packageName: options.packageName, - snapshotDate: options.dataset.snapshot.baselineDate, - snapshotName: options.dataset.snapshot.name, + snapshotDate: readDataString(snapshot.baselineDate, "Dataset snapshot baselineDate"), + snapshotName: readDataString(snapshot.name, "Dataset snapshot name"), }); - const actual = { - snapshot: options.dataset.snapshot, - featureRows: options.dataset.featureRows, - compatRows: options.dataset.compatRows, - }; - if (JSON.stringify(expected) !== JSON.stringify(actual)) { + + if (!isDeepStrictEqual(expected, data)) { throw new Error("Checked-in dataset does not match the pinned web-features package extraction"); } } @@ -181,11 +165,11 @@ export async function verifyWebFeaturesDataset(options) { * (baselineDate / extractedDate). With a pinned web-features version and the * same gitHead, extraction is deterministic, so a date-only diff isn't a real change. * - * @param {any} left - * @param {any} right + * @param {import("./dataset-loader.mjs").BaselineDataset} left + * @param {import("./dataset-loader.mjs").BaselineDataset} right */ export function datasetsEqualIgnoringDate(left, right) { - return JSON.stringify(withNormalizedDate(left)) === JSON.stringify(withNormalizedDate(right)); + return isDeepStrictEqual(withNormalizedDate(left), withNormalizedDate(right)); } /** @@ -193,12 +177,13 @@ export function datasetsEqualIgnoringDate(left, right) { * from the date, keep the existing extraction date within the same calendar year. * A year boundary advances so the next completed year entrypoint can be created. * - * @param {{ existingDataset: any | undefined; newDataset: any; candidateDate: string; }} options + * @param {{ existingDataset: import("./dataset-loader.mjs").BaselineDataset | undefined; newDataset: import("./dataset-loader.mjs").BaselineDataset; candidateDate: string; }} options * @returns {string} */ export function resolveSnapshotDate(options) { if (options.existingDataset && datasetsEqualIgnoringDate(options.existingDataset, options.newDataset)) { const existingDate = options.existingDataset.snapshot?.baselineDate; + if ( typeof existingDate === "string" && existingDate.slice(0, 4) === options.candidateDate.slice(0, 4) @@ -206,16 +191,14 @@ export function resolveSnapshotDate(options) { return existingDate; } } + return options.candidateDate; } /** - * @param {any} dataset + * @param {import("./dataset-loader.mjs").BaselineDataset} dataset */ function withNormalizedDate(dataset) { - if (!dataset || typeof dataset !== "object") { - return dataset; - } return { ...dataset, snapshot: { @@ -226,27 +209,32 @@ function withNormalizedDate(dataset) { }; } -/** - * @param {string} packageName - * @param {string} subject - * @param {unknown} baselineStatus - */ -function requireValidBaselineStatus(packageName, subject, baselineStatus) { - if (!VALID_BASELINE_STATUSES.has(/** @type {any} */ (baselineStatus))) { - throw new Error( - `${packageName} ${subject} has unsupported baseline status ${JSON.stringify(baselineStatus)} ` - + `(expected "high", "low", or false)`, - ); +/** @param {Record} data @param {string} label @returns {import("./compat-rows.mjs").CompatFacts} */ +function parseSourceStatus(data, label) { + /** @type {import("./compat-rows.mjs").CompatFacts} */ + const facts = { baselineStatus: parseBaselineStatus(data.baseline, label) }; + + if (data.baseline_low_date !== undefined) { + facts.baselineLowDate = readDataString(data.baseline_low_date, `${label} baseline_low_date`); } + + if (data.baseline_high_date !== undefined) { + facts.baselineHighDate = readDataString(data.baseline_high_date, `${label} baseline_high_date`); + } + + return facts; } /** * @param {{ * outputPath: string; - * dataset: any; + * dataset: ExtractedDataset; * }} options */ export async function writeWebFeaturesDataset(options) { await mkdir(path.dirname(options.outputPath), { recursive: true }); await writeFile(options.outputPath, `${JSON.stringify(options.dataset, undefined, 2)}\n`); } + +/** @typedef {import("./compat-rows.mjs").FeatureMembership & import("./compat-rows.mjs").CompatFacts & { hasCompatRows: boolean; spec: string[]; }} ExtractedFeatureRow */ +/** @typedef {{ snapshot: { name: string; baselineDate: string; extractedDate: string; webFeaturesPackageName: string; webFeaturesPackageVersion: string; }; featureRows: ExtractedFeatureRow[]; compatRows: import("./compat-rows.mjs").CompatRow[]; }} ExtractedDataset */ diff --git a/scripts/verify-web-features-dataset.mjs b/scripts/verify-web-features-dataset.mjs index e00bbc6..a7e288a 100644 --- a/scripts/verify-web-features-dataset.mjs +++ b/scripts/verify-web-features-dataset.mjs @@ -3,16 +3,25 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { loadBaselineDataset } from "../lib/dataset-loader.mjs"; +import { parseBaselineDataset } from "../lib/dataset-loader.mjs"; import { requireRelativeManifestPath } from "../lib/shared.mjs"; import { verifyWebFeaturesDataset } from "../lib/web-features-dataset.mjs"; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); + const repoRoot = path.resolve(scriptDirectory, ".."); + const manifestPath = path.join(repoRoot, "manifests", "baseline-js.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + const datasetPath = requireRelativeManifestPath(manifest.dataset, manifestPath, "dataset"); -const dataset = await loadBaselineDataset( + +/** @type {unknown} */ +const dataset = JSON.parse(await readFile(datasetPath, "utf8")); + +parseBaselineDataset( + dataset, datasetPath, manifest.snapshot.name, manifest.snapshot.baselineDate, diff --git a/test/classifier.test.mjs b/test/classifier.test.mjs index 61c3b28..b1b4ee1 100644 --- a/test/classifier.test.mjs +++ b/test/classifier.test.mjs @@ -646,7 +646,7 @@ test("classifier fails closed on unmanaged, stale, and kind-drifted registry sta ); }); -test("dataset loader rejects duplicate compat keys before classification", async () => { +test("dataset loader rejects repeated compat keys in the same feature before classification", async () => { await assert.rejects( classifyFixture({ rows: [ @@ -654,7 +654,7 @@ test("dataset loader rejects duplicate compat keys before classification", async row("javascript.builtins.Widget.configure", "high"), ], }), - /duplicate compatKey javascript\.builtins\.Widget\.configure/, + /Duplicate compat key membership javascript\.builtins\.Widget\.configure in feature widget-fixture/, ); }); diff --git a/test/compat-overlap.test.mjs b/test/compat-overlap.test.mjs new file mode 100644 index 0000000..231e65e --- /dev/null +++ b/test/compat-overlap.test.mjs @@ -0,0 +1,110 @@ +// @ts-check + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { getCompatFeatureMemberships } from "../lib/compat-rows.mjs"; +import { + cleanupTempDirectories, + createManifest, + createTempDirectory, + readJsonFile, + repoDatasetPath, + runGenerate, + writeJsonFile, +} from "./helpers.mjs"; + +/** @type {string[]} */ +const tempDirectories = []; + +test.afterEach(() => { + cleanupTempDirectories(tempDirectories); +}); + +test("feature overlaps preserve baseline, allowlist, and year declarations without double counting", () => { + const tempDirectory = createTempDirectory(tempDirectories); + const datasetPath = path.join(tempDirectory, "dataset.json"); + const dataset = readJsonFile(repoDatasetPath); + writeJsonFile(datasetPath, dataset); + const fixture = createManifest(tempDirectory, { datasetPath }); + runGenerate(fixture.manifestPath); + const originalDeclarations = declarationSnapshot(path.join(fixture.outputRoot, "generated")); + const originalClassification = readJsonFile(fixture.classificationOutputPath); + const originalGeneration = readJsonFile(fixture.generationOutputPath); + const originalManagement = readJsonFile(fixture.compatManagementOutputPath); + + const overlappingKeys = new Set([ + "javascript.builtins.Array.at", + "javascript.builtins.Set.union", + "javascript.builtins.String.substr", + ]); + + const originalRows = dataset.compatRows.filter( + /** @param {{ compatKey: string; }} row */ + row => overlappingKeys.has(row.compatKey), + ); + + assert.equal(originalRows.length, overlappingKeys.size); + + const addedFeature = { + featureId: "aaa-overlapping-feature", + featureName: "Overlapping feature", + snapshot: ["ecmascript-2025"], + group: ["overlapping-group"], + }; + + dataset.featureRows.push({ ...addedFeature, baselineStatus: false }); + dataset.compatRows.push(...originalRows.map( + /** @param {any} row */ + row => ({ ...row, ...addedFeature, sourceRefs: ["overlap-source", ...row.sourceRefs] }), + )); + writeJsonFile(datasetPath, dataset); + runGenerate(fixture.manifestPath); + + const classification = readJsonFile(fixture.classificationOutputPath); + assert.deepEqual(classification.summary, { + ...originalClassification.summary, + featureCount: originalClassification.summary.featureCount + 1, + }); + assert.deepEqual(readJsonFile(fixture.generationOutputPath), originalGeneration); + assert.deepEqual(readJsonFile(fixture.compatManagementOutputPath), originalManagement); + assert.deepEqual(declarationSnapshot(path.join(fixture.outputRoot, "generated")), originalDeclarations); + + for (const originalRow of originalRows) { + const matchingRows = classification.classifiedCompatRows.filter( + /** @param {{ compatKey: string; }} row */ + row => row.compatKey === originalRow.compatKey, + ); + + assert.equal(matchingRows.length, 1); + const classified = matchingRows[0]; + assert.equal(classified.featureId, addedFeature.featureId); + assert.equal(classified.baselineStatus, originalRow.baselineStatus); + assert.deepEqual(getCompatFeatureMemberships(classified), [ + addedFeature, + ...getCompatFeatureMemberships(originalRow), + ]); + assert.deepEqual(classified.sourceRefs, [...originalRow.sourceRefs, "overlap-source"].sort()); + } + + const report = fs.readFileSync(fixture.classificationOutputPath, "utf8"); + dataset.compatRows.reverse(); + dataset.featureRows.reverse(); + writeJsonFile(datasetPath, dataset); + runGenerate(fixture.manifestPath); + assert.equal(fs.readFileSync(fixture.classificationOutputPath, "utf8"), report); + assert.deepEqual(declarationSnapshot(path.join(fixture.outputRoot, "generated")), originalDeclarations); +}); + +/** @param {string} directory */ +function declarationSnapshot(directory) { + return fs.readdirSync(directory, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => path.join(entry.parentPath, entry.name)) + .sort() + .map(filePath => ({ + path: path.relative(directory, filePath), + contents: fs.readFileSync(filePath, "utf8"), + })); +} diff --git a/test/web-features-dataset.test.mjs b/test/web-features-dataset.test.mjs index 48524e8..be428aa 100644 --- a/test/web-features-dataset.test.mjs +++ b/test/web-features-dataset.test.mjs @@ -10,6 +10,7 @@ import { writeJsonFile, } from "./helpers.mjs"; import { loadBaselineDataset } from "../lib/dataset-loader.mjs"; +import { normalizeCompatRows, parseCompatRow } from "../lib/compat-rows.mjs"; import { buildWebFeaturesDataset, datasetsEqualIgnoringDate, @@ -21,7 +22,8 @@ import { // change, hold the extraction date and don't open a "date-only PR". Pin this invariant. /** * @param {string} date - * @param {Record} [extra] + * @param {Partial} [extra] + * @returns {import("../lib/dataset-loader.mjs").BaselineDataset} */ function datasetFixture(date, extra = {}) { return { @@ -33,7 +35,7 @@ function datasetFixture(date, extra = {}) { webFeaturesPackageVersion: "3.32.0", }, featureRows: [{ featureId: "a" }], - compatRows: [{ compatKey: "javascript.builtins.A", baselineStatus: "high" }], + compatRows: [{ compatKey: "javascript.builtins.A", featureId: "a", featureName: "A", snapshot: [], group: [], sourceRefs: [], baselineStatus: "high" }], ...extra, }; } @@ -50,9 +52,11 @@ test("resolveSnapshotDate preserves the existing date when only the date differs test("resolveSnapshotDate advances the date when the content actually changed", () => { const existing = datasetFixture("2026-07-07"); + const rebuilt = datasetFixture("2026-12-31", { - compatRows: [{ compatKey: "javascript.builtins.A", baselineStatus: "low" }], + compatRows: [{ ...existing.compatRows[0], baselineStatus: "low" }], }); + assert.ok(!datasetsEqualIgnoringDate(existing, rebuilt)); assert.equal( resolveSnapshotDate({ existingDataset: existing, newDataset: rebuilt, candidateDate: "2026-12-31" }), @@ -138,9 +142,249 @@ test("web-features extractor accepts well-formed per-key statuses", async () => assert.equal(dataset.compatRows[0].baselineStatus, "high"); }); +test("overlapping keys retain every feature membership and use per-key facts", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + + const firstFeature = { + ...validFeature(), + name: "First feature", + snapshot: ["ecmascript-2020"], + group: ["first-group"], + }; + + const secondFeature = { + ...validFeature(), + name: "Second feature", + snapshot: ["ecmascript-2024"], + group: ["second-group"], + }; + + secondFeature.status.baseline = "low"; + secondFeature.status.baseline_low_date = "2024-01-01"; + delete secondFeature.status.baseline_high_date; + + const options = { + repoRoot: tempDirectory, + snapshotDate: "2026-07-07", + snapshotName: "web-features-test", + }; + + installWebFeaturesFixture(tempDirectory, { second: secondFeature, first: firstFeature }); + const dataset = await buildWebFeaturesDataset(options); + assert.equal(dataset.featureRows.length, 2); + assert.equal(dataset.compatRows.length, 1); + assert.deepEqual(dataset.compatRows[0], { + compatKey: "javascript.builtins.Widget.good", + featureId: "first", + featureName: "First feature", + baselineStatus: "high", + baselineLowDate: "2020-01-01", + baselineHighDate: "2022-07-01", + snapshot: ["ecmascript-2020"], + group: ["first-group"], + sourceRefs: ["javascript.builtins.Widget.good"], + featureMemberships: [ + { featureId: "first", featureName: "First feature", snapshot: ["ecmascript-2020"], group: ["first-group"] }, + { featureId: "second", featureName: "Second feature", snapshot: ["ecmascript-2024"], group: ["second-group"] }, + ], + }); + + installWebFeaturesFixture(tempDirectory, { first: firstFeature, second: secondFeature }); + assert.equal(JSON.stringify(await buildWebFeaturesDataset(options)), JSON.stringify(dataset)); + await verifyWebFeaturesDataset({ repoRoot: tempDirectory, dataset }); + const datasetPath = path.join(tempDirectory, "dataset.json"); + writeJsonFile(datasetPath, dataset); + const loaded = await loadBaselineDataset(datasetPath, options.snapshotName); + assert.deepEqual(loaded.compatRows, dataset.compatRows); + assert.equal(loaded.compatRowByKey.size, 1); +}); + +test("overlapping keys reject conflicting per-key statuses and dates", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + + /** @type {Array<[string, string | undefined]>} */ + const conflicts = [ + ["baseline", "low"], + ["baseline_low_date", "2021-01-01"], + ["baseline_high_date", "2023-07-01"], + ["baseline_high_date", undefined], + ]; + + for (const [field, value] of conflicts) { + const secondFeature = validFeature(); + secondFeature.status.by_compat_key["javascript.builtins.Widget.good"][field] = value; + installWebFeaturesFixture(tempDirectory, { + first: validFeature(), + second: secondFeature, + }); + await assert.rejects( + buildWebFeaturesDataset({ + repoRoot: tempDirectory, + snapshotDate: "2026-07-07", + snapshotName: "web-features-test", + }), + /Compat key javascript\.builtins\.Widget\.good has conflicting baseline.* across features first and second/, + ); + } +}); + +test("web-features extractor rejects repeated compat keys within a feature", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + const feature = validFeature(); + feature.compat_features.push("javascript.builtins.Widget.good"); + installWebFeaturesFixture(tempDirectory, { "widget-helpers": feature }); + await assert.rejects( + buildWebFeaturesDataset({ + repoRoot: tempDirectory, + snapshotDate: "2026-07-07", + snapshotName: "web-features-test", + }), + /Duplicate compat key membership javascript\.builtins\.Widget\.good in feature widget-helpers/, + ); +}); + +test("dataset loader rejects conflicts and duplicate memberships in overlapping rows", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + const datasetPath = path.join(tempDirectory, "dataset.json"); + installWebFeaturesFixture(tempDirectory, { first: validFeature(), second: validFeature() }); + + const dataset = await buildWebFeaturesDataset({ + repoRoot: tempDirectory, + snapshotDate: "2026-07-07", + snapshotName: "web-features-test", + }); + + const overlapping = dataset.compatRows[0]; + + const conflicting = { + ...overlapping, + featureId: "third", + featureMemberships: undefined, + baselineStatus: "low", + }; + + writeJsonFile(datasetPath, { ...dataset, compatRows: [overlapping, conflicting] }); + await assert.rejects( + loadBaselineDataset(datasetPath, "web-features-test"), + /conflicting baselineStatus/, + ); + + assert.ok(overlapping.featureMemberships); + overlapping.featureMemberships.push(overlapping.featureMemberships[0]); + writeJsonFile(datasetPath, dataset); + await assert.rejects( + loadBaselineDataset(datasetPath, "web-features-test"), + /Duplicate compat key membership .* in feature first/, + ); +}); + +test("explicit feature memberships reject malformed provenance", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + const datasetPath = path.join(tempDirectory, "dataset.json"); + installWebFeaturesFixture(tempDirectory, { first: validFeature(), second: validFeature() }); + + const dataset = await buildWebFeaturesDataset({ + repoRoot: tempDirectory, + snapshotDate: "2026-07-07", + snapshotName: "web-features-test", + }); + + const row = dataset.compatRows[0]; + const memberships = row.featureMemberships; + assert.ok(memberships); + + const invalidValues = [ + null, + {}, + [], + [memberships[0]], + [memberships[0], null], + [memberships[0], []], + [memberships[0], "second"], + ...["featureId", "featureName", "snapshot", "group"].flatMap(field => [ + [memberships[0], { ...memberships[1], [field]: undefined }], + [memberships[0], { ...memberships[1], [field]: 42 }], + ]), + [memberships[0], { ...memberships[1], featureId: "" }], + [memberships[0], { ...memberships[1], featureName: "" }], + [memberships[0], { ...memberships[1], snapshot: "ecmascript-2020" }], + [memberships[0], { ...memberships[1], snapshot: [null] }], + [memberships[0], { ...memberships[1], group: null }], + [memberships[0], { ...memberships[1], group: [42] }], + ]; + + for (const featureMemberships of invalidValues) { + const invalidRow = { ...row, featureMemberships }; + assert.throws(() => parseCompatRow(invalidRow), /Compat row .* (?:invalid|must be)/); + writeJsonFile(datasetPath, { ...dataset, compatRows: [invalidRow] }); + await assert.rejects(loadBaselineDataset(datasetPath, "web-features-test"), /Compat row .* (?:invalid|must be)/); + } +}); + +test("singleton and overlapping rows decode the same metadata and provenance contract", () => { + const row = { + compatKey: "javascript.builtins.Widget.good", + featureId: "first", + featureName: "First", + baselineStatus: "high", + baselineLowDate: "2020-01-01", + }; + + const first = parseCompatRow(row); + const second = { ...first, featureId: "second", featureName: "Second", sourceRefs: [row.compatKey] }; + const normalized = normalizeCompatRows([second, first]); + assert.deepEqual(normalizeCompatRows(normalized), normalized); + assert.deepEqual(first.sourceRefs, []); + assert.deepEqual(first.snapshot, []); + assert.deepEqual(first.group, []); + + for (const changes of [ + { featureId: 42 }, + { featureName: 42 }, + { snapshot: "ecmascript-2020" }, + { snapshot: [null] }, + { group: "javascript" }, + { group: null }, + { sourceRefs: "javascript.builtins.Widget.good" }, + { sourceRefs: [42] }, + ]) { + const invalidRow = { ...first, ...changes }; + assert.throws(() => normalizeCompatRows([invalidRow]), /Compat row .* must be/); + assert.throws(() => normalizeCompatRows([invalidRow, second]), /Compat row .* must be/); + } +}); + +test("dataset loader validates high dates and feature references without rejecting projected promotion dates", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + const datasetPath = path.join(tempDirectory, "dataset.json"); + installWebFeaturesFixture(tempDirectory, { first: validFeature(), second: validFeature() }); + const dataset = await buildWebFeaturesDataset({ repoRoot: tempDirectory, snapshotDate: "2026-07-07", snapshotName: "web-features-test" }); + const row = dataset.compatRows[0]; + + for (const baselineHighDate of ["not-a-date", "2025-02-30", "2019-12-31"]) { + writeJsonFile(datasetPath, { ...dataset, compatRows: [{ ...row, baselineHighDate }] }); + await assert.rejects(loadBaselineDataset(datasetPath, "web-features-test"), /baselineHighDate/); + } + + writeJsonFile(datasetPath, { ...dataset, compatRows: [{ ...row, baselineStatus: "low", baselineHighDate: "2027-01-01" }] }); + assert.equal((await loadBaselineDataset(datasetPath, "web-features-test", "2026-07-07")).compatRows[0].baselineHighDate, "2027-01-01"); + writeJsonFile(datasetPath, { ...dataset, featureRows: [dataset.featureRows[0]] }); + await assert.rejects(loadBaselineDataset(datasetPath, "web-features-test"), /references missing feature second/); +}); + +test("web-features metadata is decoded before either singleton or overlap normalization", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + + for (const changes of [{ name: null }, { snapshot: [42] }, { group: "javascript" }]) { + installWebFeaturesFixture(tempDirectory, { first: { ...validFeature(), ...changes } }); + await assert.rejects(buildWebFeaturesDataset({ repoRoot: tempDirectory, snapshotDate: "2026-07-07", snapshotName: "web-features-test" }), /must be/); + } +}); + test("checked-in dataset must exactly match the pinned package extraction", async () => { const tempDirectory = createTempDirectory(tempDirectories); installWebFeaturesFixture(tempDirectory, { "widget-helpers": validFeature() }); + const dataset = await buildWebFeaturesDataset({ repoRoot: tempDirectory, snapshotDate: "2026-07-07", @@ -156,6 +400,24 @@ test("checked-in dataset must exactly match the pinned package extraction", asyn ); }); +test("pinned artifact verification rejects extra fields before domain projection", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + const datasetPath = path.join(tempDirectory, "dataset.json"); + installWebFeaturesFixture(tempDirectory, { first: validFeature() }); + const dataset = await buildWebFeaturesDataset({ repoRoot: tempDirectory, snapshotDate: "2026-07-07", snapshotName: "web-features-test" }); + + for (const candidate of [ + { ...dataset, unexpected: true }, + { ...dataset, snapshot: { ...dataset.snapshot, unexpected: true } }, + { ...dataset, featureRows: [{ ...dataset.featureRows[0], unexpected: true }] }, + { ...dataset, compatRows: [{ ...dataset.compatRows[0], unexpected: true }] }, + ]) { + writeJsonFile(datasetPath, candidate); + await loadBaselineDataset(datasetPath, "web-features-test"); + await assert.rejects(verifyWebFeaturesDataset({ repoRoot: tempDirectory, dataset: candidate }), /does not match the pinned web-features package extraction/); + } +}); + test("web-features extractor fails closed when by_compat_key is missing instead of inheriting feature status", async () => { const tempDirectory = createTempDirectory(tempDirectories); const feature = validFeature(); @@ -191,7 +453,7 @@ test("web-features extractor fails closed when compat_features changes shape", a snapshotDate: "2026-07-07", snapshotName: "web-features-test", }), - /non-array compat_features/, + /compat_features must be an array of strings/, ); const renamedDirectory = createTempDirectory(tempDirectories); @@ -217,7 +479,7 @@ test("web-features extractor fails closed when compat_features changes shape", a snapshotDate: "2026-07-07", snapshotName: "web-features-test", }), - /missing the features map/, + /features map must be an object/, ); }); @@ -259,6 +521,7 @@ test("dataset loader rejects checked-in rows with unsupported baseline statuses" { compatKey: "javascript.builtins.Widget.good", featureId: "widget-helpers", + featureName: "Widget helpers", baselineStatus: "widely", }, ], @@ -266,7 +529,7 @@ test("dataset loader rejects checked-in rows with unsupported baseline statuses" await assert.rejects( loadBaselineDataset(datasetPath, "web-features-test"), - /unsupported baselineStatus "widely"/, + /baselineStatus has unsupported baseline status "widely"/, ); }); @@ -280,6 +543,7 @@ test("dataset loader validates Baseline dates against the snapshot", async () => ["2027-01-01", /is after snapshot 2026-07-07/], [undefined, /is not a valid Baseline date/], ]; + for (const [baselineLowDate, expectedError] of invalidDates) { writeJsonFile(datasetPath, { snapshot: { name: "web-features-test", baselineDate: "2026-07-07" }, @@ -287,8 +551,9 @@ test("dataset loader validates Baseline dates against the snapshot", async () => compatRows: [{ compatKey: "javascript.builtins.Widget.good", featureId: "widget-helpers", + featureName: "Widget helpers", baselineStatus: "high", - ...(baselineLowDate ? { baselineLowDate } : {}), + baselineLowDate, }], }); await assert.rejects(