Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
84 changes: 28 additions & 56 deletions lib/classifier.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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[]; }}
*/
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -288,7 +284,7 @@ export async function classifyManifest(options) {

/**
* @param {{
* libCompatRows: any[];
* libCompatRows: CompatRow[];
* baselineTarget: string;
* inventory: import("./surface-inventory.mjs").SurfaceInventory;
* compatManagementRegistry: CompatManagementRegistry;
Expand Down Expand Up @@ -541,7 +537,7 @@ function getCompatManagementReport(options) {

/**
* @param {{
* compatRow: any;
* compatRow: CompatRow;
* baselineTarget: string;
* inventory: import("./surface-inventory.mjs").SurfaceInventory;
* compatManagementRegistry: CompatManagementRegistry;
Expand Down Expand Up @@ -805,7 +801,7 @@ function resolveCompatRoot(inventory, compatSegments, compatManagement, typedArr

/**
* @param {{
* compatRow: any;
* compatRow: CompatRow;
* compatRoot: string;
* qualifierSegments: string[];
* includeInTarget: boolean;
Expand Down Expand Up @@ -920,7 +916,7 @@ function classifyConstructorRow(options) {

/**
* @param {{
* compatRow: any;
* compatRow: CompatRow;
* compatRoot: string;
* memberName: string;
* qualifierSegments: string[];
Expand Down Expand Up @@ -1063,7 +1059,7 @@ function classifyMemberRow(options) {

/**
* @param {{
* compatRow: any;
* compatRow: CompatRow;
* compatRoot: string;
* tailSegments: string[];
* inventory: import("./surface-inventory.mjs").SurfaceInventory;
Expand Down Expand Up @@ -1254,7 +1250,7 @@ function hasReviverContextParameter(unit) {
}

/**
* @param {any} compatRow
* @param {CompatRow} compatRow
* @param {string} compatRoot
* @param {boolean} includeInTarget
* @param {{
Expand All @@ -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,
Expand All @@ -1292,6 +1289,12 @@ function createClassifiedRow(compatRow, compatRoot, includeInTarget, resolution,
expectedResolutionKinds: compatManagement.expectedResolutionKinds,
} : undefined,
};

if (compatRow.featureMemberships) {
classifiedRow.featureMemberships = compatRow.featureMemberships;
}

return classifiedRow;
}

/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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[];
Expand All @@ -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
*/

/**
Expand Down
187 changes: 187 additions & 0 deletions lib/compat-rows.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// @ts-check

import { compareStringsCaseSensitive } from "./shared.mjs";

/** @type {ReadonlyArray<keyof CompatFacts>} */
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<string, unknown>} */
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<string, unknown>} */
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<string, CompatRow[]>} */
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<string, FeatureMembership>} */
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 */
Loading
Loading