From 1a96ed3493dc58757a89db21fd064370540941d7 Mon Sep 17 00:00:00 2001 From: Ryuya Date: Fri, 2 Oct 2026 03:15:13 -0700 Subject: [PATCH 1/4] fix: support shared compatibility keys --- CONTRIBUTING.md | 4 + lib/classifier.mjs | 2 + lib/compat-rows.mjs | 112 ++++++++++++++++++++ lib/dataset-loader.mjs | 64 ++++++----- lib/web-features-dataset.mjs | 4 +- test/classifier.test.mjs | 4 +- test/compat-overlap.test.mjs | 105 +++++++++++++++++++ test/web-features-dataset.test.mjs | 163 +++++++++++++++++++++++++++++ 8 files changed, 425 insertions(+), 33 deletions(-) create mode 100644 lib/compat-rows.mjs create mode 100644 test/compat-overlap.test.mjs 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..cb41d0a 100644 --- a/lib/classifier.mjs +++ b/lib/classifier.mjs @@ -1270,6 +1270,7 @@ function createClassifiedRow(compatRow, compatRoot, includeInTarget, resolution, compatKey: compatRow.compatKey, featureId: compatRow.featureId, featureName: compatRow.featureName, + ...(compatRow.featureMemberships ? { featureMemberships: compatRow.featureMemberships } : {}), baselineStatus: compatRow.baselineStatus, baselineLowDate: compatRow.baselineLowDate, baselineHighDate: compatRow.baselineHighDate, @@ -1537,6 +1538,7 @@ function isRegistryManagedResolutionKind(resolutionKind) { * compatKey: string; * featureId: string; * featureName: string; + * featureMemberships?: import("./compat-rows.mjs").FeatureMembership[]; * baselineStatus: string | boolean | undefined; * baselineLowDate?: string; * baselineHighDate?: string; diff --git a/lib/compat-rows.mjs b/lib/compat-rows.mjs new file mode 100644 index 0000000..c924f20 --- /dev/null +++ b/lib/compat-rows.mjs @@ -0,0 +1,112 @@ +// @ts-check + +import { compareStringsCaseSensitive } from "./shared.mjs"; + +const COMPAT_FACT_FIELDS = ["baselineStatus", "baselineLowDate", "baselineHighDate"]; + +/** + * @param {any} row + * @returns {FeatureMembership[]} + */ +export function getCompatFeatureMemberships(row) { + if (row.featureMemberships !== undefined) { + if (!Array.isArray(row.featureMemberships) || row.featureMemberships.length < 2) { + throw new Error(`Compat row ${row.compatKey} has invalid featureMemberships`); + } + for (const membership of row.featureMemberships) { + validateFeatureMembership(membership, row.compatKey); + } + return row.featureMemberships; + } + return [{ + featureId: row.featureId, + featureName: row.featureName, + snapshot: row.snapshot ?? [], + group: row.group ?? [], + }]; +} + +/** + * @param {any} membership + * @param {string} compatKey + */ +function validateFeatureMembership(membership, compatKey) { + if (!membership || typeof membership !== "object" || Array.isArray(membership)) { + throw new Error(`Compat row ${compatKey} has an invalid feature membership`); + } + for (const field of ["featureId", "featureName"]) { + if (typeof membership[field] !== "string" || !membership[field]) { + throw new Error(`Compat row ${compatKey} has a feature membership with invalid ${field}`); + } + } + for (const field of ["snapshot", "group"]) { + if (!Array.isArray(membership[field]) || membership[field].some((/** @type {any} */ value) => typeof value !== "string")) { + throw new Error(`Compat row ${compatKey} has a feature membership with invalid ${field}`); + } + } +} + +/** + * @param {any[]} rows + * @returns {any[]} + */ +export function normalizeCompatRows(rows) { + /** @type {Map} */ + const rowsByKey = new Map(); + for (const row of rows) { + 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 (typeof membership.featureId !== "string" || !membership.featureId) { + throw new Error(`Compat row ${compatKey} has a feature membership without featureId`); + } + 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)); + const firstMembership = featureMemberships[0]; + return { + ...matchingRows[0], + ...firstMembership, + sourceRefs: [...sourceRefs].sort(compareStringsCaseSensitive), + featureMemberships, + }; + }); +} + +/** + * @typedef {{ + * featureId: string; + * featureName: string; + * snapshot: string[]; + * group: string[]; + * }} FeatureMembership + */ diff --git a/lib/dataset-loader.mjs b/lib/dataset-loader.mjs index c48a5ae..c8fdf47 100644 --- a/lib/dataset-loader.mjs +++ b/lib/dataset-loader.mjs @@ -1,6 +1,7 @@ // @ts-check import { readFile } from "node:fs/promises"; +import { normalizeCompatRows } from "./compat-rows.mjs"; /** * @param {string} filePath @@ -51,45 +52,50 @@ export async function loadBaselineDataset(filePath, expectedSnapshot, expectedBa 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}`); - } - // 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) { - throw new Error( - `Dataset ${filePath} compat row ${compatRow.compatKey} has unsupported baselineStatus ` - + `${JSON.stringify(compatRow.baselineStatus)} (expected "high", "low", or false)`, - ); - } - 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); + validateCompatRow(compatRow, filePath, snapshotDate); } + const compatRows = normalizeCompatRows(dataset.compatRows); + const compatRowByKey = new Map(compatRows.map(row => [row.compatKey, row])); + return { ...dataset, + compatRows, featureRowById, compatRowByKey, }; } +/** + * @param {any} compatRow + * @param {string} filePath + * @param {string | undefined} snapshotDate + */ +function validateCompatRow(compatRow, filePath, snapshotDate) { + if (!compatRow.compatKey) { + throw new Error(`Dataset ${filePath} has a compat row without compatKey`); + } + if (compatRow.baselineStatus !== "high" && compatRow.baselineStatus !== "low" && compatRow.baselineStatus !== false) { + throw new Error( + `Dataset ${filePath} compat row ${compatRow.compatKey} has unsupported baselineStatus ` + + `${JSON.stringify(compatRow.baselineStatus)} (expected "high", "low", or false)`, + ); + } + 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}`, + ); + } + } +} + /** * @param {unknown} value * @param {string} label diff --git a/lib/web-features-dataset.mjs b/lib/web-features-dataset.mjs index d6efee4..68f499b 100644 --- a/lib/web-features-dataset.mjs +++ b/lib/web-features-dataset.mjs @@ -6,6 +6,7 @@ import { writeFile, } from "node:fs/promises"; import path from "node:path"; +import { normalizeCompatRows } from "./compat-rows.mjs"; import { readInstalledPackageJson, resolveInstalledPackageFile } from "./installed-package.mjs"; import { compareStringsCaseSensitive } from "./shared.mjs"; @@ -141,7 +142,6 @@ export async function buildWebFeaturesDataset(options) { } featureRows.sort((left, right) => compareStringsCaseSensitive(left.featureId, right.featureId)); - compatRows.sort((left, right) => compareStringsCaseSensitive(left.compatKey, right.compatKey)); return { snapshot: { @@ -152,7 +152,7 @@ export async function buildWebFeaturesDataset(options) { webFeaturesPackageVersion: webFeaturesPackageJson.version, }, featureRows, - compatRows, + compatRows: normalizeCompatRows(compatRows), }; } 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..f8e87a1 --- /dev/null +++ b/test/compat-overlap.test.mjs @@ -0,0 +1,105 @@ +// @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..fea2aa3 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 { getCompatFeatureMemberships } from "../lib/compat-rows.mjs"; import { buildWebFeaturesDataset, datasetsEqualIgnoringDate, @@ -138,6 +139,168 @@ 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/, + ); + + 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; + 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) { + row.featureMemberships = featureMemberships; + assert.throws(() => getCompatFeatureMemberships(row), /Compat row .* invalid/); + writeJsonFile(datasetPath, dataset); + await assert.rejects(loadBaselineDataset(datasetPath, "web-features-test"), /Compat row .* invalid/); + } +}); + test("checked-in dataset must exactly match the pinned package extraction", async () => { const tempDirectory = createTempDirectory(tempDirectories); installWebFeaturesFixture(tempDirectory, { "widget-helpers": validFeature() }); From c48b8966f834da8c895158e2798ae83f3352d4ac Mon Sep 17 00:00:00 2001 From: Ryuya Date: Sun, 4 Oct 2026 01:17:18 -0700 Subject: [PATCH 2/4] Decode compatibility data at input boundaries --- lib/compat-rows.mjs | 169 +++++++++++++++------ lib/dataset-loader.mjs | 167 ++++++++++++++++----- lib/web-features-dataset.mjs | 190 +++++++++++------------- scripts/verify-web-features-dataset.mjs | 13 +- test/web-features-dataset.test.mjs | 126 ++++++++++++++-- 5 files changed, 463 insertions(+), 202 deletions(-) diff --git a/lib/compat-rows.mjs b/lib/compat-rows.mjs index c924f20..e8d80b7 100644 --- a/lib/compat-rows.mjs +++ b/lib/compat-rows.mjs @@ -2,23 +2,14 @@ import { compareStringsCaseSensitive } from "./shared.mjs"; +/** @type {ReadonlyArray} */ const COMPAT_FACT_FIELDS = ["baselineStatus", "baselineLowDate", "baselineHighDate"]; -/** - * @param {any} row - * @returns {FeatureMembership[]} - */ +const BASELINE_DATE_FIELDS = /** @type {const} */ (["baselineLowDate", "baselineHighDate"]); + +/** @param {CompatFeatureMetadata} row @returns {FeatureMembership[]} */ export function getCompatFeatureMemberships(row) { - if (row.featureMemberships !== undefined) { - if (!Array.isArray(row.featureMemberships) || row.featureMemberships.length < 2) { - throw new Error(`Compat row ${row.compatKey} has invalid featureMemberships`); - } - for (const membership of row.featureMemberships) { - validateFeatureMembership(membership, row.compatKey); - } - return row.featureMemberships; - } - return [{ + return row.featureMemberships ?? [{ featureId: row.featureId, featureName: row.featureName, snapshot: row.snapshot ?? [], @@ -26,34 +17,118 @@ export function getCompatFeatureMemberships(row) { }]; } -/** - * @param {any} membership - * @param {string} compatKey - */ -function validateFeatureMembership(membership, compatKey) { - if (!membership || typeof membership !== "object" || Array.isArray(membership)) { - throw new Error(`Compat row ${compatKey} has an invalid feature membership`); +/** @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}`); + } } - for (const field of ["featureId", "featureName"]) { - if (typeof membership[field] !== "string" || !membership[field]) { - throw new Error(`Compat row ${compatKey} has a feature membership with invalid ${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`); } - for (const field of ["snapshot", "group"]) { - if (!Array.isArray(membership[field]) || membership[field].some((/** @type {any} */ value) => typeof value !== "string")) { - throw new Error(`Compat row ${compatKey} has a feature membership with invalid ${field}`); + + 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 {any[]} rows - * @returns {any[]} - */ +/** @param {unknown[]} rows @returns {CompatRow[]} */ export function normalizeCompatRows(rows) { - /** @type {Map} */ + /** @type {Map} */ const rowsByKey = new Map(); - for (const row of rows) { + + for (const value of rows) { + const row = parseCompatRow(value); const members = rowsByKey.get(row.compatKey) ?? []; members.push(row); rowsByKey.set(row.compatKey, members); @@ -65,24 +140,26 @@ export function normalizeCompatRows(rows) { /** @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 (typeof membership.featureId !== "string" || !membership.featureId) { - throw new Error(`Compat row ${compatKey} has a feature membership without featureId`); - } 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 ?? []) { + + for (const sourceRef of row.sourceRefs) { sourceRefs.add(sourceRef); } } @@ -90,23 +167,21 @@ export function normalizeCompatRows(rows) { if (memberships.size === 1) { return matchingRows[0]; } + const featureMemberships = [...memberships.values()] .sort((left, right) => compareStringsCaseSensitive(left.featureId, right.featureId)); - const firstMembership = featureMemberships[0]; + return { ...matchingRows[0], - ...firstMembership, + ...featureMemberships[0], sourceRefs: [...sourceRefs].sort(compareStringsCaseSensitive), featureMemberships, }; }); } -/** - * @typedef {{ - * featureId: string; - * featureName: string; - * snapshot: string[]; - * group: string[]; - * }} FeatureMembership - */ +/** @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 c8fdf47..75d2af1 100644 --- a/lib/dataset-loader.mjs +++ b/lib/dataset-loader.mjs @@ -1,7 +1,14 @@ // @ts-check import { readFile } from "node:fs/promises"; -import { normalizeCompatRows } from "./compat-rows.mjs"; +import { + getCompatFeatureMemberships, + normalizeCompatRows, + parseBaselineStatus, + readDataRecord, + readDataString, + readDataStringArray, +} from "./compat-rows.mjs"; /** * @param {string} filePath @@ -10,83 +17,91 @@ import { normalizeCompatRows } from "./compat-rows.mjs"; * @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); } - for (const compatRow of dataset.compatRows) { - validateCompatRow(compatRow, filePath, snapshotDate); + 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}`); + } + } } - const compatRows = normalizeCompatRows(dataset.compatRows); const compatRowByKey = new Map(compatRows.map(row => [row.compatKey, row])); - return { - ...dataset, - compatRows, - featureRowById, - compatRowByKey, - }; + return { snapshot, featureRows, compatRows, featureRowById, compatRowByKey }; } /** - * @param {any} compatRow + * @param {import("./compat-rows.mjs").CompatRow} compatRow * @param {string} filePath * @param {string | undefined} snapshotDate */ -function validateCompatRow(compatRow, filePath, snapshotDate) { - if (!compatRow.compatKey) { - throw new Error(`Dataset ${filePath} has a compat row without compatKey`); - } - if (compatRow.baselineStatus !== "high" && compatRow.baselineStatus !== "low" && compatRow.baselineStatus !== false) { - throw new Error( - `Dataset ${filePath} compat row ${compatRow.compatKey} has unsupported baselineStatus ` - + `${JSON.stringify(compatRow.baselineStatus)} (expected "high", "low", or false)`, - ); - } +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} baselineLowDate ` @@ -94,6 +109,19 @@ function validateCompatRow(compatRow, filePath, snapshotDate) { ); } } + + 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`); + } + } } /** @@ -104,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); } @@ -113,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) @@ -120,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 68f499b..66ce2bd 100644 --- a/lib/web-features-dataset.mjs +++ b/lib/web-features-dataset.mjs @@ -6,16 +6,17 @@ import { writeFile, } from "node:fs/promises"; import path from "node:path"; -import { normalizeCompatRows } from "./compat-rows.mjs"; +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; @@ -27,115 +28,99 @@ 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], }); } @@ -157,21 +142,20 @@ export async function buildWebFeaturesDataset(options) { } /** - * @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/web-features-dataset.test.mjs b/test/web-features-dataset.test.mjs index fea2aa3..be428aa 100644 --- a/test/web-features-dataset.test.mjs +++ b/test/web-features-dataset.test.mjs @@ -10,7 +10,7 @@ import { writeJsonFile, } from "./helpers.mjs"; import { loadBaselineDataset } from "../lib/dataset-loader.mjs"; -import { getCompatFeatureMemberships } from "../lib/compat-rows.mjs"; +import { normalizeCompatRows, parseCompatRow } from "../lib/compat-rows.mjs"; import { buildWebFeaturesDataset, datasetsEqualIgnoringDate, @@ -22,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 { @@ -34,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, }; } @@ -51,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" }), @@ -141,26 +144,31 @@ test("web-features extractor accepts well-formed per-key statuses", async () => 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); @@ -193,6 +201,7 @@ test("overlapping keys retain every feature membership and use per-key facts", a test("overlapping keys reject conflicting per-key statuses and dates", async () => { const tempDirectory = createTempDirectory(tempDirectories); + /** @type {Array<[string, string | undefined]>} */ const conflicts = [ ["baseline", "low"], @@ -200,6 +209,7 @@ test("overlapping keys reject conflicting per-key statuses and dates", async () ["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; @@ -237,24 +247,29 @@ test("dataset loader rejects conflicts and duplicate memberships in overlapping 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( @@ -267,13 +282,17 @@ 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, {}, @@ -293,17 +312,79 @@ test("explicit feature memberships reject malformed provenance", async () => { [memberships[0], { ...memberships[1], group: null }], [memberships[0], { ...memberships[1], group: [42] }], ]; + for (const featureMemberships of invalidValues) { - row.featureMemberships = featureMemberships; - assert.throws(() => getCompatFeatureMemberships(row), /Compat row .* invalid/); - writeJsonFile(datasetPath, dataset); - await assert.rejects(loadBaselineDataset(datasetPath, "web-features-test"), /Compat row .* invalid/); + 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", @@ -319,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(); @@ -354,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); @@ -380,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/, ); }); @@ -422,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", }, ], @@ -429,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"/, ); }); @@ -443,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" }, @@ -450,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( From 52b2cb7acc77a4b8529d9956c66f8399c6f688ca Mon Sep 17 00:00:00 2001 From: Ryuya Date: Sun, 4 Oct 2026 01:26:41 -0700 Subject: [PATCH 3/4] Preserve decoded types through classification --- lib/classifier.mjs | 84 +++++++++++++++------------------------------- 1 file changed, 27 insertions(+), 57 deletions(-) diff --git a/lib/classifier.mjs b/lib/classifier.mjs index cb41d0a..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,16 +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, - ...(compatRow.featureMemberships ? { featureMemberships: compatRow.featureMemberships } : {}), 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, @@ -1293,6 +1289,12 @@ function createClassifiedRow(compatRow, compatRoot, includeInTarget, resolution, expectedResolutionKinds: compatManagement.expectedResolutionKinds, } : undefined, }; + + if (compatRow.featureMemberships) { + classifiedRow.featureMemberships = compatRow.featureMemberships; + } + + return classifiedRow; } /** @@ -1470,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) { @@ -1539,7 +1541,7 @@ function isRegistryManagedResolutionKind(resolutionKind) { * featureId: string; * featureName: string; * featureMemberships?: import("./compat-rows.mjs").FeatureMembership[]; - * baselineStatus: string | boolean | undefined; + * baselineStatus: CompatRow["baselineStatus"]; * baselineLowDate?: string; * baselineHighDate?: string; * sourceRefs: string[]; @@ -1557,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 */ /** From 4a63f6e751d5d29f15efe90f5104949a134b2915 Mon Sep 17 00:00:00 2001 From: Ryuya Date: Sun, 4 Oct 2026 01:31:23 -0700 Subject: [PATCH 4/4] Align compatibility test spacing --- test/compat-overlap.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/compat-overlap.test.mjs b/test/compat-overlap.test.mjs index f8e87a1..231e65e 100644 --- a/test/compat-overlap.test.mjs +++ b/test/compat-overlap.test.mjs @@ -39,17 +39,21 @@ test("feature overlaps preserve baseline, allowlist, and year declarations witho "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 */ @@ -72,6 +76,7 @@ test("feature overlaps preserve baseline, allowlist, and year declarations witho /** @param {{ compatKey: string; }} row */ row => row.compatKey === originalRow.compatKey, ); + assert.equal(matchingRows.length, 1); const classified = matchingRows[0]; assert.equal(classified.featureId, addedFeature.featureId);