From 889de6dbf435746abb02846a00314214d1f6885e Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 3 Aug 2026 17:10:39 -0400 Subject: [PATCH] test: guard the public feature graph --- .github/workflows/ci.yml | 3 + astro.config.mjs | 22 +-- docs/components/apidoc.astro | 2 +- docs/content/docs/api-surface.mdx | 34 ++++ docs/llm.js | 15 +- docs/pages/apidocs/[API]/[Module].astro | 22 --- docs/utils.js | 80 ++-------- package.json | 5 +- scripts/check-features.mjs | 199 ++++++++++++++++++++++++ 9 files changed, 262 insertions(+), 120 deletions(-) delete mode 100644 docs/pages/apidocs/[API]/[Module].astro create mode 100644 scripts/check-features.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3879b7e3..d5d266eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: - name: Rebuild ReScript code run: npm run build + - name: Check public feature builds + run: npm run check:features + - name: Run tests run: npm test diff --git a/astro.config.mjs b/astro.config.mjs index fd29d3ba..a4a725d3 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -7,24 +7,10 @@ const rescriptTM = JSON.parse( readFileSync("./docs/assets/rescript.tmLanguage.json", "utf-8"), ); -const apiSidebarItems = apiModules.map(({ moduleName, link, items }) => { - const nestedItems = Object.values(items).map(({ moduleName, link }) => ({ - label: moduleName, - link, - })); - - return { - label: moduleName, - collapsed: true, - items: [ - { - label: `Overview`, - link, - }, - ...nestedItems, - ], - }; -}); +const apiSidebarItems = apiModules.map(({ moduleName, link }) => ({ + label: moduleName, + link, +})); export default defineConfig({ srcDir: "docs", diff --git a/docs/components/apidoc.astro b/docs/components/apidoc.astro index f437e260..f140fe5d 100644 --- a/docs/components/apidoc.astro +++ b/docs/components/apidoc.astro @@ -1,5 +1,5 @@ --- -import { apiModules, getDoc } from "../utils"; +import { getDoc } from "../utils"; import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro"; import Value from "./value.astro"; import Type from "./type.astro"; diff --git a/docs/content/docs/api-surface.mdx b/docs/content/docs/api-surface.mdx index 75c225d5..cc80575a 100644 --- a/docs/content/docs/api-surface.mdx +++ b/docs/content/docs/api-surface.mdx @@ -41,6 +41,40 @@ Generated implementation modules such as `DomTypes`, `FetchTypes`, `EventTypes`, module's `t` type when it has one, or use a dedicated public type module. Otherwise, let the value type be inferred from constructors and accessors. +## Consumer feature bundles + +Feature names select related bindings and their transitive dependencies. They do not add +another namespace segment. For example, selecting `WebAPI.Fetch` enables flat modules such +as `WebAPI.Fetch`, `WebAPI.Request`, `WebAPI.Response`, and `WebAPI.Headers`. + +```json +{ + "dependencies": [ + { + "name": "@rescript/webapi", + "features": ["WebAPI.Fetch", "WebAPI.HTML"] + } + ] +} +``` + +The supported feature bundles are: + +```text +WebAPI.DOM WebAPI.Event WebAPI.DOMPlatform +WebAPI.DOMNodes WebAPI.File WebAPI.HTML +WebAPI.Window WebAPI.CSSOM WebAPI.CSSFontLoading +WebAPI.Geometry WebAPI.SVG WebAPI.Animation +WebAPI.Device WebAPI.Navigator WebAPI.Canvas +WebAPI.URL WebAPI.Fetch WebAPI.UIEvents +WebAPI.Observers WebAPI.Media WebAPI.WebAudio +WebAPI.Storage WebAPI.Messaging WebAPI.Workers +WebAPI.Crypto WebAPI.Performance WebAPI.ViewTransitions +``` + +The package owns each bundle as one internal source folder. Those internal folder feature +names are an implementation detail; consumers should use only the qualified names above. + ## Fetch Use `WebAPI.Fetch.fetch` for string URLs and `WebAPI.Fetch.fetchWithRequest` diff --git a/docs/llm.js b/docs/llm.js index 14ae7083..045342da 100644 --- a/docs/llm.js +++ b/docs/llm.js @@ -2,7 +2,6 @@ import * as path from "node:path"; import { exec } from "node:child_process"; import { promisify } from "node:util"; import fs from "node:fs/promises"; -import { featureSpecs } from "../scripts/unmonorepo/feature-spec.mjs"; const execAsync = promisify(exec); @@ -95,12 +94,11 @@ Module: ${moduleName}${typeString}${functionString} `; } -const specByDir = new Map(featureSpecs.map((spec) => [spec.dirName, spec])); const rootDir = path.join(import.meta.dirname, ".."); const rootConfig = JSON.parse(await fs.readFile(path.join(rootDir, "rescript.json"), "utf-8")); const publicModulesBySourceDir = new Map( rootConfig.sources - .filter((source) => typeof source === "object") + .filter((source) => source !== null && typeof source === "object") .filter((source) => source.dir?.startsWith("src/") && Array.isArray(source.public)) .map((source) => [source.dir, new Set(source.public)]), ); @@ -130,16 +128,7 @@ function isPublicFile(filePath) { } function moduleNameForFile(relativePath) { - const [, dirName, fileName] = relativePath.split(path.sep); - const spec = specByDir.get(dirName); - - if (!spec) { - throw new Error(`Unsupported source directory for documentation: ${relativePath}`); - } - - const leafName = path.basename(fileName, ".res"); - - return `WebAPI.${leafName}`; + return `WebAPI.${path.basename(relativePath, ".res")}`; } const pattern = "../src/*/**/*.res"; diff --git a/docs/pages/apidocs/[API]/[Module].astro b/docs/pages/apidocs/[API]/[Module].astro deleted file mode 100644 index 286b09a9..00000000 --- a/docs/pages/apidocs/[API]/[Module].astro +++ /dev/null @@ -1,22 +0,0 @@ ---- -import { apiModules, getDoc } from "../../../utils"; -import APIDoc from "../../../components/apidoc.astro"; - -export async function getStaticPaths() { - return apiModules.flatMap((apiModule) => { - return Object.values(apiModule.items).map((typeModule) => { - return { - params: { - API: apiModule.apiRouteParameter, - Module: typeModule.apiRouteParameter, - }, - props: { parentModule: apiModule, currentModule: typeModule }, - }; - }); - }); -} - -const { filePath, moduleName, link } = Astro.props.currentModule; ---- - - diff --git a/docs/utils.js b/docs/utils.js index 63ef9954..9613a062 100644 --- a/docs/utils.js +++ b/docs/utils.js @@ -3,16 +3,16 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; import { readdirSync, existsSync, readFileSync } from "fs"; import { micromark } from "micromark"; -import { featureSpecs } from "../scripts/unmonorepo/feature-spec.mjs"; const execAsync = promisify(exec); const rootDir = process.cwd(); const rootConfig = JSON.parse(readFileSync(path.join(rootDir, "rescript.json"), "utf8")); -const publicModulesBySourceDir = new Map( - rootConfig.sources - .filter((source) => typeof source === "object") - .filter((source) => source.dir?.startsWith("src/") && Array.isArray(source.public)) - .map((source) => [source.dir, new Set(source.public)]), +const publicSourceEntries = rootConfig.sources.filter( + (source) => + source !== null && + typeof source === "object" && + source.dir?.startsWith("src/") && + Array.isArray(source.public), ); function toKebabCase(input) { @@ -32,64 +32,16 @@ export function createTypeModuleLink(parentModuleLink, typeName) { return `${parentModuleLink}/${toKebabCase(typeName)}`; } -function mapTypeModules(parentModuleLink, file, spec) { - const folder = path.dirname(file); - - if (!existsSync(folder)) { - return []; - } - - const publicModules = publicModulesBySourceDir.get(spec.sourceDir) ?? new Set(); - const typesFileName = `${spec.internalPrefix}Types.res`; - const files = readdirSync(folder); - return files - .filter((f) => f.endsWith(".res") && f !== typesFileName) - .filter((file) => publicModules.has(file.replace("$", "").replace(".res", ""))) - .map((file) => { - const filePath = path.join(folder, file); - - const leafName = file.replace("$", "").replace(".res", ""); - const moduleName = leafName; - const apiRouteParameter = toKebabCase(moduleName); - const link = createTypeModuleLink(parentModuleLink, moduleName); - const typeName = moduleName[0].toLocaleLowerCase() + moduleName.slice(1); - - return [ - typeName, - { - filePath, - moduleName, - link, - apiRouteParameter, - }, - ]; - }); -} - -function mapRescriptFile(srcDir, file, spec) { - const filePath = path.join(srcDir, file); - const moduleName = spec.publicModule; - const link = createAPIModuleLink(moduleName); - const items = Object.fromEntries(mapTypeModules(link, filePath, spec)); - - return { - filePath, - moduleName, - link, - apiRouteParameter: toKebabCase(moduleName), - items, - }; -} - -const srcRoot = path.resolve(process.cwd(), "src"); -export const apiModules = featureSpecs - .map((spec) => ({ - spec, - srcDir: path.join(srcRoot, spec.dirName), - typesFileName: `${spec.internalPrefix}Types.res`, - })) - .filter(({ srcDir, typesFileName }) => existsSync(path.join(srcDir, typesFileName))) - .map(({ spec, srcDir, typesFileName }) => mapRescriptFile(srcDir, typesFileName, spec)) +export const apiModules = publicSourceEntries + .flatMap((source) => + source.public.map((moduleName) => ({ + filePath: path.join(rootDir, source.dir, `${moduleName}.res`), + moduleName, + link: createAPIModuleLink(moduleName), + apiRouteParameter: toKebabCase(moduleName), + })), + ) + .filter(({ filePath }) => existsSync(filePath)) .sort((a, b) => a.moduleName.localeCompare(b.moduleName)); async function getRescriptDoc(absoluteFilePath) { diff --git a/package.json b/package.json index 89f7b9f4..2cc623c9 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,9 @@ "scripts": { "test": "node tests/index.js", "build": "rescript", - "format": "rescript format && oxfmt ./tests/index.js ./package.json ./docs && prettier --write ./docs/pages", - "format:check": "rescript format --check && oxfmt ./tests/index.js ./package.json ./docs --check && prettier --check ./docs/pages", + "check:features": "node scripts/check-features.mjs", + "format": "rescript format && oxfmt ./tests/index.js ./scripts/check-features.mjs ./package.json ./docs && prettier --write ./docs/pages", + "format:check": "rescript format --check && oxfmt ./tests/index.js ./scripts/check-features.mjs ./package.json ./docs --check && prettier --check ./docs/pages", "docs": "astro dev", "prebuild:docs": "node docs/llm.js", "build:docs": "astro build" diff --git a/scripts/check-features.mjs b/scripts/check-features.mjs new file mode 100644 index 00000000..7fc0132e --- /dev/null +++ b/scripts/check-features.mjs @@ -0,0 +1,199 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const configPath = path.join(repoRoot, "rescript.json"); + +const expectedFeatureOwners = new Map([ + ["WebAPI.DOM", "DOM"], + ["WebAPI.Event", "Event"], + ["WebAPI.DOMPlatform", "DOMPlatform"], + ["WebAPI.DOMNodes", "DOMNodes"], + ["WebAPI.File", "File"], + ["WebAPI.HTML", "HTML"], + ["WebAPI.Window", "Window"], + ["WebAPI.CSSOM", "CSSOM"], + ["WebAPI.CSSFontLoading", "CSSFontLoading"], + ["WebAPI.Geometry", "Geometry"], + ["WebAPI.SVG", "SVG"], + ["WebAPI.Animation", "Animation"], + ["WebAPI.Device", "Device"], + ["WebAPI.Navigator", "Navigator"], + ["WebAPI.Canvas", "Canvas"], + ["WebAPI.URL", "URL"], + ["WebAPI.Fetch", "Fetch"], + ["WebAPI.UIEvents", "UIEvents"], + ["WebAPI.Observers", "Observers"], + ["WebAPI.Media", "Media"], + ["WebAPI.WebAudio", "WebAudio"], + ["WebAPI.Storage", "Storage"], + ["WebAPI.Messaging", "Messaging"], + ["WebAPI.Workers", "Workers"], + ["WebAPI.Crypto", "Crypto"], + ["WebAPI.Performance", "Performance"], + ["WebAPI.ViewTransitions", "ViewTransitions"], +]); + +const uniqueDuplicates = (values) => [ + ...new Set(values.filter((value, index) => values.indexOf(value) !== index)), +]; + +const sameMembers = (left, right) => + left.length === right.length && left.every((value) => right.includes(value)); + +const readConfig = () => { + try { + return { _tag: "Success", value: JSON.parse(readFileSync(configPath, "utf8")) }; + } catch (error) { + return { + _tag: "Failure", + message: error instanceof Error ? error.message : String(error), + }; + } +}; + +const validateFeatureNames = (featureEntries) => { + const actualNames = featureEntries.map(([name]) => name); + const expectedNames = [...expectedFeatureOwners.keys()]; + + return sameMembers(actualNames, expectedNames) + ? [] + : [ + `Expected exactly these ${expectedNames.length} public features:\n${expectedNames.join("\n")}\n\nReceived:\n${actualNames.join("\n")}`, + ]; +}; + +const validateSources = (sourceEntries) => { + const sourceFeatures = sourceEntries.map((source) => source.feature); + const expectedInternalFeatures = [...expectedFeatureOwners.values()]; + const duplicateFeatures = uniqueDuplicates(sourceFeatures); + const qualifiedFeatures = sourceFeatures.filter((feature) => feature.startsWith("WebAPI.")); + const missingDirectories = sourceEntries + .filter((source) => !existsSync(path.join(repoRoot, source.dir))) + .map((source) => source.dir); + + return [ + ...(sameMembers(sourceFeatures, expectedInternalFeatures) + ? [] + : ["Source features do not match the 27 expected internal folder features."]), + ...(duplicateFeatures.length === 0 + ? [] + : [`Duplicate source features: ${duplicateFeatures.join(", ")}`]), + ...(qualifiedFeatures.length === 0 + ? [] + : [`Source features must be unqualified: ${qualifiedFeatures.join(", ")}`]), + ...(missingDirectories.length === 0 + ? [] + : [`Missing source directories: ${missingDirectories.join(", ")}`]), + ]; +}; + +const validateFeatureOwners = (featureEntries, sourceEntries) => { + const internalFeatures = new Set(sourceEntries.map((source) => source.feature)); + + return featureEntries.flatMap(([featureName, expansion]) => { + if (!Array.isArray(expansion)) { + return [`${featureName} must expand to an array.`]; + } + + const directInternalFeatures = expansion.filter((feature) => internalFeatures.has(feature)); + const expectedOwner = expectedFeatureOwners.get(featureName); + + return directInternalFeatures.length === 1 && directInternalFeatures[0] === expectedOwner + ? [] + : [ + `${featureName} must directly include only its owning internal feature ${expectedOwner}; received ${directInternalFeatures.join(", ") || "none"}.`, + ]; + }); +}; + +const validatePublicModules = (sourceEntries) => { + const publicModules = sourceEntries.flatMap((source) => + (source.public ?? []).map((moduleName) => ({ moduleName, sourceDir: source.dir })), + ); + const duplicateModules = uniqueDuplicates(publicModules.map(({ moduleName }) => moduleName)); + const missingModules = publicModules + .filter( + ({ moduleName, sourceDir }) => + !existsSync(path.join(repoRoot, sourceDir, `${moduleName}.res`)), + ) + .map(({ moduleName, sourceDir }) => `${sourceDir}/${moduleName}.res`); + + return [ + ...(duplicateModules.length === 0 + ? [] + : [`Duplicate public modules: ${duplicateModules.join(", ")}`]), + ...(missingModules.length === 0 + ? [] + : [`Missing public module files: ${missingModules.join(", ")}`]), + ]; +}; + +const validateConfig = (config) => { + const featureEntries = Object.entries(config.features ?? {}); + const sourceEntries = (config.sources ?? []).filter( + (source) => source !== null && typeof source === "object" && typeof source.feature === "string", + ); + + return [ + ...validateFeatureNames(featureEntries), + ...validateSources(sourceEntries), + ...validateFeatureOwners(featureEntries, sourceEntries), + ...validatePublicModules(sourceEntries), + ]; +}; + +const rescriptExecutable = path.join( + repoRoot, + "node_modules", + ".bin", + process.platform === "win32" ? "rescript.cmd" : "rescript", +); + +const runRescript = (args) => + spawnSync(rescriptExecutable, args, { + cwd: repoRoot, + encoding: "utf8", + }); + +const formatProcessFailure = (featureName, command, result) => + [`${featureName} failed during ${command}.`, result.stdout?.trim(), result.stderr?.trim()] + .filter(Boolean) + .join("\n"); + +const compileFeature = (featureName) => { + const cleanResult = runRescript(["clean"]); + if (cleanResult.status !== 0) { + return { _tag: "Failure", message: formatProcessFailure(featureName, "clean", cleanResult) }; + } + + const buildResult = runRescript(["build", "--prod", "--features", featureName]); + return buildResult.status === 0 + ? { _tag: "Success" } + : { _tag: "Failure", message: formatProcessFailure(featureName, "build", buildResult) }; +}; + +const configResult = readConfig(); +if (configResult._tag === "Failure") { + console.error(`Unable to read rescript.json: ${configResult.message}`); + process.exit(1); +} + +const validationErrors = validateConfig(configResult.value); +if (validationErrors.length > 0) { + console.error(validationErrors.join("\n\n")); + process.exit(1); +} + +console.log(`Validated ${expectedFeatureOwners.size} public feature definitions.`); + +for (const featureName of expectedFeatureOwners.keys()) { + const result = compileFeature(featureName); + if (result._tag === "Failure") { + console.error(result.message); + process.exit(1); + } + console.log(`[ok] ${featureName}`); +}