From 6fb4acb0b40b0f673834f4833dbeb51b251598ad Mon Sep 17 00:00:00 2001 From: Ryuya Date: Wed, 22 Jul 2026 20:25:57 -0700 Subject: [PATCH] fix: harden baseline generation and releases --- .github/workflows/package-dry-run.yml | 13 +- .github/workflows/release.yml | 147 ++++--- .github/workflows/test-typescript-go.yml | 9 +- .github/workflows/test-typescript.yml | 23 +- .github/workflows/typescript-update.yml | 3 + .github/workflows/validate.yml | 3 + .github/workflows/weekly-update.yml | 3 + .gitignore | 4 +- README.md | 15 +- deploy/deployChangedPackage.mjs | 155 ------- deploy/package-lib.mjs | 393 +++++++++++++----- deploy/prepareReleaseArtifact.mjs | 90 ++++ deploy/publishReleaseArtifact.mjs | 149 +++++++ deploy/readmes/baseline.md | 17 +- deploy/release-artifact.mjs | 289 +++++++++++++ deploy/trusted-executable.mjs | 50 +++ deploy/verifyReleaseArtifact.mjs | 30 ++ derived/current/classification.json | 2 +- derived/current/compat-management-report.json | 2 +- derived/current/generation.json | 39 +- .../allow/_support/array-fromasync.d.ts | 27 -- .../allow/_support/intl-duration-format.d.ts | 1 - .../allow/_support/intl-segmenter.d.ts | 1 - .../allow/_support/promise-withresolvers.d.ts | 1 - .../current/allow/_support/set-methods.d.ts | 1 - .../current/allow/array-fromasync/index.d.ts | 3 - .../current/allow/array-group/index.d.ts | 1 - .../current/allow/atomics-pause/index.d.ts | 1 - .../allow/atomics-wait-async/index.d.ts | 1 - .../current/allow/float16array/index.d.ts | 1 - .../current/allow/getorinsert/index.d.ts | 1 - .../allow/intl-duration-format/index.d.ts | 1 - .../current/allow/intl-segmenter/index.d.ts | 1 - .../current/allow/promise-try/index.d.ts | 1 - .../allow/promise-withresolvers/index.d.ts | 1 - .../current/allow/regexp-escape/index.d.ts | 1 - .../allow/resizable-buffers/index.d.ts | 1 - .../current/allow/set-methods/index.d.ts | 1 - .../allow/transferable-arraybuffer/index.d.ts | 1 - .../allow/uint8array-base64-hex/index.d.ts | 1 - generated/current/baseline.d.ts | 23 +- generated/current/year/2020/index.d.ts | 23 +- generated/current/year/2021/index.d.ts | 23 +- generated/current/year/2022/index.d.ts | 23 +- generated/current/year/2023/index.d.ts | 23 +- generated/current/year/2024/index.d.ts | 19 +- generated/current/year/2025/index.d.ts | 19 +- lib/classifier.mjs | 23 +- lib/compat-management-registry.mjs | 38 ++ lib/dataset-loader.mjs | 12 +- lib/generator.mjs | 325 ++++++++++++--- lib/manifest-snapshot.mjs | 8 +- lib/negative-probes.mjs | 6 + lib/shared.mjs | 65 ++- lib/surface-inventory.mjs | 120 +++++- lib/typescript-upstream.mjs | 75 ++++ lib/web-features-dataset.mjs | 54 ++- manifests/baseline-js.json | 14 +- package.json | 8 +- registry/compat-management.json | 105 +++++ registry/compat-management.schema.json | 51 +++ scripts/test-typescript-integration.mjs | 85 ++-- scripts/verify-web-features-dataset.mjs | 28 ++ test/allowlist.test.mjs | 6 +- test/classifier.test.mjs | 6 +- test/compat-management-schema.test.mjs | 15 + test/excluded-units.test.mjs | 164 ++++++++ test/generate.test.mjs | 88 ++++ test/helpers.mjs | 36 +- test/managed-output-path.test.mjs | 73 ++++ test/package-metadata.test.mjs | 65 +++ test/packed-consumer-smoke.test.mjs | 40 +- test/release-artifact.test.mjs | 217 ++++++++++ test/surface-inventory.test.mjs | 25 ++ test/type-only-consumer.test.mjs | 8 + test/typescript-upstream.test.mjs | 62 ++- test/web-features-dataset.test.mjs | 84 ++++ test/workflow-pins.test.mjs | 33 ++ test/year-entrypoints.test.mjs | 12 +- 79 files changed, 3017 insertions(+), 571 deletions(-) delete mode 100644 deploy/deployChangedPackage.mjs create mode 100644 deploy/prepareReleaseArtifact.mjs create mode 100644 deploy/publishReleaseArtifact.mjs create mode 100644 deploy/release-artifact.mjs create mode 100644 deploy/trusted-executable.mjs create mode 100644 deploy/verifyReleaseArtifact.mjs delete mode 100644 generated/current/allow/_support/array-fromasync.d.ts create mode 100644 scripts/verify-web-features-dataset.mjs create mode 100644 test/managed-output-path.test.mjs create mode 100644 test/release-artifact.test.mjs diff --git a/.github/workflows/package-dry-run.yml b/.github/workflows/package-dry-run.yml index 7397b0b..e156714 100644 --- a/.github/workflows/package-dry-run.yml +++ b/.github/workflows/package-dry-run.yml @@ -36,14 +36,17 @@ jobs: - name: Validate compat-management schema run: npm run validate:registry + - name: Verify pinned web-features dataset + run: npm run verify:dataset + - name: Regenerate baseline run: npm run generate - - name: Stage publishable baseline package and create npm tarball - run: npm run pack:baseline:tarball + - name: Compute release decision and prepare exact tarball + run: npm run release:dry-run - name: Run packed consumer smoke + if: ${{ hashFiles('release-artifact/package.tgz') != '' }} + env: + BASELINE_PACKAGE_TARBALL: release-artifact/package.tgz run: node --test test/packed-consumer-smoke.test.mjs - - - name: Compute release decision - run: npm run release:dry-run diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d0056a..8474dcf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,16 +4,10 @@ on: workflow_dispatch: inputs: version: - description: "Explicit package version for reviewed Baseline year contract changes" + description: "Explicit version for reviewed declaration or year contract changes" required: false type: string -permissions: - contents: write - # Issue an OIDC token for npm provenance (Sigstore). - id-token: write - -# Serialize concurrent release runs (don't cancel). concurrency: group: release cancel-in-progress: false @@ -23,11 +17,14 @@ defaults: shell: bash jobs: - release: + verify: runs-on: ubuntu-latest - # Create a `release` environment in repo settings with required reviewers - # to gate publishes behind human approval (see README). - environment: release + timeout-minutes: 90 + permissions: + contents: read + outputs: + changed: ${{ steps.release-plan.outputs.changed }} + artifact-integrity: ${{ steps.prepare-artifact.outputs.artifact-integrity }} steps: - name: Ensure release runs from main @@ -39,53 +36,66 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "22" + node-version: "24" cache: "npm" - - # OIDC publishing needs npm >= 11.5.1; Node 22 bundles npm 10. - - name: Upgrade npm - run: npm install -g npm@latest - + - name: Resolve trusted release tools + id: release-tools + run: | + echo "node=$(command -v node)" >> "$GITHUB_OUTPUT" + echo "npm=$(command -v npm)" >> "$GITHUB_OUTPUT" + echo "tar=$(command -v tar)" >> "$GITHUB_OUTPUT" - run: npm ci + - run: npm run validate - - name: Run static JS checks - run: npm run lint - - - name: Validate compat-management schema - run: npm run validate:registry - - - name: Regenerate baseline - run: npm run generate - - # Block publishing when checked-in artifacts don't match the regenerated - # output (i.e. publishing unreviewed content). - name: Verify checked-in generated artifacts run: | git diff --exit-code -- derived/current generated/current - status="$(git status --porcelain --untracked-files=all -- derived/current generated/current)" - if [[ -n "$status" ]]; then - printf '%s\n' "$status" - exit 1 - fi - - - name: Run validation and smoke tests - run: npm test + test -z "$(git status --porcelain --untracked-files=all -- derived/current generated/current)" - name: Checkout pinned TypeScript source run: npm run checkout:typescript-source -- --out .tmp/TypeScript --force - - name: Run pinned TypeScript integration gate - run: npm run test:typescript:full -- --typescript-dir ./.tmp/TypeScript --summary-out .tmp/typescript-integration-summary.md --baseline-diff-out .tmp/typescript-baseline-changes.diff --focused-baselines-out .tmp/typescript-focused-artifact --local-baselines-out .tmp/typescript-raw-local-baselines + - name: Run blocking TypeScript integration + run: npm run test:typescript:full -- --typescript-dir ./.tmp/TypeScript --summary-out typescript-integration-artifacts/summary.md --baseline-diff-out typescript-integration-artifacts/baseline-changes.diff --focused-baselines-out typescript-integration-artifacts/focused --local-baselines-out typescript-integration-artifacts/raw-local-baselines - - name: Stage publishable baseline package + - name: Prepare immutable release artifact + id: prepare-artifact env: RELEASE_VERSION: ${{ inputs.version }} + RELEASE_GIT_EXECUTABLE: /usr/bin/git + RELEASE_NODE_EXECUTABLE: ${{ steps.release-tools.outputs.node }} + RELEASE_NPM_EXECUTABLE: ${{ steps.release-tools.outputs.npm }} + RELEASE_TAR_EXECUTABLE: ${{ steps.release-tools.outputs.tar }} run: | args=() if [[ -n "$RELEASE_VERSION" ]]; then args+=(--version "$RELEASE_VERSION") fi - npm run pack:baseline -- "${args[@]}" + "$RELEASE_NODE_EXECUTABLE" deploy/prepareReleaseArtifact.mjs "${args[@]}" + + - name: Test exact release tarball + if: ${{ hashFiles('release-artifact/package.tgz') != '' }} + env: + BASELINE_PACKAGE_TARBALL: release-artifact/package.tgz + RELEASE_NODE_EXECUTABLE: ${{ steps.release-tools.outputs.node }} + RELEASE_NPM_EXECUTABLE: ${{ steps.release-tools.outputs.npm }} + run: "$RELEASE_NODE_EXECUTABLE" --test test/packed-consumer-smoke.test.mjs + + - name: Verify immutable release artifact + id: release-plan + env: + RELEASE_NODE_EXECUTABLE: ${{ steps.release-tools.outputs.node }} + RELEASE_TAR_EXECUTABLE: ${{ steps.release-tools.outputs.tar }} + EXPECTED_ARTIFACT_INTEGRITY: ${{ steps.prepare-artifact.outputs.artifact-integrity }} + run: "$RELEASE_NODE_EXECUTABLE" deploy/verifyReleaseArtifact.mjs --artifact-dir release-artifact + + - name: Upload release artifact + if: steps.release-plan.outputs.changed == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: npm-release-artifact + path: release-artifact + if-no-files-found: error - name: Upload integration summary and focused artifacts if: always() @@ -93,30 +103,61 @@ jobs: with: name: release-typescript-integration path: | - .tmp/typescript-integration-summary.md - .tmp/typescript-baseline-changes.diff - .tmp/typescript-focused-artifact - if-no-files-found: ignore + typescript-integration-artifacts/summary.md + typescript-integration-artifacts/baseline-changes.diff + typescript-integration-artifacts/focused + if-no-files-found: error - name: Upload raw local baselines if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-typescript-integration-raw-local-baselines - path: .tmp/typescript-raw-local-baselines - if-no-files-found: ignore + path: typescript-integration-artifacts/raw-local-baselines + if-no-files-found: error - # Tokenless publish via OIDC trusted publishing; provenance only on public repos. - - name: Publish changed package and create GitHub release + publish: + needs: verify + if: needs.verify.outputs.changed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: release + permissions: + contents: write + id-token: write + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - name: Resolve trusted publish tools + id: publish-tools + run: | + echo "node=$(command -v node)" >> "$GITHUB_OUTPUT" + echo "npm=$(command -v npm)" >> "$GITHUB_OUTPUT" + + - name: Require npm trusted-publishing support + env: + RELEASE_NPM_EXECUTABLE: ${{ steps.publish-tools.outputs.npm }} + run: test "$(printf '11.5.1\n%s\n' "$("$RELEASE_NPM_EXECUTABLE" --version)" | sort -V | head -n1)" = "11.5.1" + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: npm-release-artifact + path: release-artifact + + - name: Publish verified tarball and create release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_VERSION: ${{ inputs.version }} + EXPECTED_ARTIFACT_INTEGRITY: ${{ needs.verify.outputs.artifact-integrity }} + RELEASE_NODE_EXECUTABLE: ${{ steps.publish-tools.outputs.node }} + RELEASE_NPM_EXECUTABLE: ${{ steps.publish-tools.outputs.npm }} + RELEASE_TAR_EXECUTABLE: /usr/bin/tar run: | args=() - if [[ -n "$RELEASE_VERSION" ]]; then - args+=(--version "$RELEASE_VERSION") - fi if [[ "${{ github.event.repository.private }}" != "true" ]]; then args+=(--provenance) fi - npm run release:publish -- "${args[@]}" + "$RELEASE_NODE_EXECUTABLE" deploy/publishReleaseArtifact.mjs --artifact-dir release-artifact "${args[@]}" diff --git a/.github/workflows/test-typescript-go.yml b/.github/workflows/test-typescript-go.yml index 4fd46b0..41fb1fb 100644 --- a/.github/workflows/test-typescript-go.yml +++ b/.github/workflows/test-typescript-go.yml @@ -36,6 +36,9 @@ jobs: cache: "npm" - run: npm ci + - name: Verify pinned web-features dataset + run: npm run verify:dataset + - name: Regenerate baseline run: npm run generate @@ -56,12 +59,12 @@ jobs: cache-dependency-path: .tmp/typescript-go/go.sum - name: Run tsgo --lib baseline integration - run: npm run test:typescript-go -- --typescript-go-dir .tmp/typescript-go --out .tmp/typescript-go-integration-summary.md + run: npm run test:typescript-go -- --typescript-go-dir .tmp/typescript-go --out typescript-integration-artifacts/typescript-go-summary.md - name: Upload integration summary if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: typescript-go-integration - path: .tmp/typescript-go-integration-summary.md - if-no-files-found: ignore + path: typescript-integration-artifacts/typescript-go-summary.md + if-no-files-found: error diff --git a/.github/workflows/test-typescript.yml b/.github/workflows/test-typescript.yml index 3f8d34a..f2dc0a9 100644 --- a/.github/workflows/test-typescript.yml +++ b/.github/workflows/test-typescript.yml @@ -32,11 +32,14 @@ jobs: cache: "npm" - run: npm ci + - name: Verify pinned web-features dataset + run: npm run verify:dataset + - name: Regenerate baseline run: npm run generate - # PRs run a gate of blocking checks only; push to main / dispatch runs the - # full suite with diagnostics. + # PRs run the focused gate; push to main / dispatch also requires the full + # suite to pass after accepting the generated baselines. - name: Select integration mode id: mode run: | @@ -70,7 +73,7 @@ jobs: run: npm run checkout:typescript-source -- --out .tmp/TypeScript --force - name: Run TypeScript integration checks - run: npm run test:typescript:${{ steps.mode.outputs.mode }} -- --typescript-dir ./.tmp/TypeScript --summary-out .tmp/typescript-integration-summary.md --baseline-diff-out .tmp/typescript-baseline-changes.diff --focused-baselines-out .tmp/typescript-focused-artifact --local-baselines-out .tmp/typescript-raw-local-baselines + run: npm run test:typescript:${{ steps.mode.outputs.mode }} -- --typescript-dir ./.tmp/TypeScript --summary-out typescript-integration-artifacts/summary.md --baseline-diff-out typescript-integration-artifacts/baseline-changes.diff --focused-baselines-out typescript-integration-artifacts/focused --local-baselines-out typescript-integration-artifacts/raw-local-baselines - name: Upload integration summary and focused artifacts if: always() @@ -78,15 +81,15 @@ jobs: with: name: typescript-integration path: | - .tmp/typescript-integration-summary.md - .tmp/typescript-baseline-changes.diff - .tmp/typescript-focused-artifact - if-no-files-found: ignore + typescript-integration-artifacts/summary.md + typescript-integration-artifacts/baseline-changes.diff + typescript-integration-artifacts/focused + if-no-files-found: error - name: Upload raw local baselines - if: always() + if: always() && steps.mode.outputs.mode == 'full' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: typescript-integration-raw-local-baselines - path: .tmp/typescript-raw-local-baselines - if-no-files-found: ignore + path: typescript-integration-artifacts/raw-local-baselines + if-no-files-found: error diff --git a/.github/workflows/typescript-update.yml b/.github/workflows/typescript-update.yml index 98913e6..6a2e4f8 100644 --- a/.github/workflows/typescript-update.yml +++ b/.github/workflows/typescript-update.yml @@ -37,6 +37,9 @@ jobs: - name: Pin latest TypeScript toolchain run: npm run update:typescript-toolchain + - name: Verify pinned web-features dataset + run: npm run verify:dataset + - name: Regenerate baseline run: npm run generate diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 80a9a9a..ed33dff 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -36,6 +36,9 @@ jobs: - name: Validate compat-management schema run: npm run validate:registry + - name: Verify pinned web-features dataset + run: npm run verify:dataset + - name: Regenerate baseline run: npm run generate diff --git a/.github/workflows/weekly-update.yml b/.github/workflows/weekly-update.yml index 68388d0..a77c437 100644 --- a/.github/workflows/weekly-update.yml +++ b/.github/workflows/weekly-update.yml @@ -43,6 +43,9 @@ jobs: - name: Validate compat-management schema run: npm run validate:registry + - name: Verify pinned web-features dataset + run: npm run verify:dataset + - name: Regenerate baseline run: npm run generate diff --git a/.gitignore b/.gitignore index f880706..e728540 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ deploy/generated/ coverage/ npm-debug.log* .DS_Store -.tmp \ No newline at end of file +.tmp +release-artifact/ +typescript-integration-artifacts/ diff --git a/README.md b/README.md index 5e909c5..72c9034 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,34 @@ Generates `baseline.d.ts`, a TypeScript lib for TypeScript-declarable JavaScript features that are [Baseline widely available](https://web.dev/baseline). It currently classifies `javascript.builtins.*` and the `arguments` object from `web-features`. -## Using the lib +## Best-practice setup -Stock TypeScript doesn't treat `"baseline"` as a built-in `lib` yet, so install the package and list it under `compilerOptions.types`: +Stock TypeScript doesn't treat `"baseline"` as a built-in `lib` yet. Install the current supported TypeScript major with this package: ```sh -npm install --save-dev typescript-baseline-lib +npm install --save-dev typescript@^7 typescript-baseline-lib ``` +Use the package as the complete global lib: + ```json { "compilerOptions": { "noLib": true, + "strict": true, "types": ["typescript-baseline-lib"] } } ``` +```sh +npx tsc --noEmit +``` + Now only the supported Baseline widely available JavaScript surfaces type-check. APIs that haven't reached Baseline yet (`Promise.withResolvers`, `Array.fromAsync` until it promotes, and so on) are reported as errors. The end goal is first-class `--lib baseline` support upstream in TypeScript. +This package replaces TypeScript's default libs; do not set `compilerOptions.lib` or combine it with the standard `es*` libs. Add other ambient type packages to `types` only when the project needs them. Those packages can require APIs that are intentionally outside the selected Baseline target. The generator preserves audited erased compiler-support declarations, but it does not add unavailable runtime APIs merely to satisfy a third-party package. + ## Allow a polyfilled feature When the runtime loads an audited polyfill, add its generated web-features entry after the base package. For example, core-js can provide `Promise.withResolvers` at runtime: diff --git a/deploy/deployChangedPackage.mjs b/deploy/deployChangedPackage.mjs deleted file mode 100644 index ae0b1c7..0000000 --- a/deploy/deployChangedPackage.mjs +++ /dev/null @@ -1,155 +0,0 @@ -// @ts-check - -import { - mkdir, - writeFile, -} from "node:fs/promises"; -import path from "node:path"; -import { - collectReleasePlans, - publishReleasePlan, -} from "./package-lib.mjs"; - -const args = parseArgs(process.argv.slice(2)); - -// A real publish is an irreversible external release, so don't run it unless -// in CI (e.g. GitHub Actions) or given an explicit --yes. This structurally -// prevents accidentally publishing to npm just by running -// `node deploy/deployChangedPackage.mjs`. -if (!args.dryRun && !args.yes && process.env.CI !== "true") { - throw new Error( - "Refusing to publish outside CI without explicit confirmation. " - + "Re-run with --dry-run to preview, or pass --yes to publish from a local environment.", - ); -} - -const releasePlans = await collectReleasePlans({ - packageId: args.package, - versionOverride: args.version, - preview: args.dryRun, -}); - -/** @type {Array>} */ -const publishSummary = []; -for (const releasePlan of releasePlans) { - const publishResult = await publishReleasePlan(releasePlan, { - dryRun: args.dryRun, - provenance: args.provenance, - createGitHubRelease: args.githubRelease, - githubRepository: process.env.GITHUB_REPOSITORY, - githubToken: process.env.GITHUB_TOKEN, - githubSha: process.env.GITHUB_SHA, - }); - - publishSummary.push({ - packageName: releasePlan.packageConfig.name, - packageVersion: releasePlan.packageVersion, - publishedVersion: releasePlan.publishedVersion, - changed: releasePlan.changed, - changedFiles: releasePlan.changedFiles, - removedFiles: releasePlan.removedFiles, - published: publishResult.published, - releaseCreated: publishResult.releaseCreated, - stageDirectory: releasePlan.stageDirectory, - }); - - console.log(`Package: ${releasePlan.packageConfig.name}`); - console.log(`Next version: ${releasePlan.packageVersion}`); - console.log(`Latest published: ${releasePlan.publishedVersion ?? "none"}`); - console.log(`Changed: ${releasePlan.changed ? "yes" : "no"}`); - if (releasePlan.requiredVersionBump && !args.version) { - console.log(`Reviewed release version required: ${releasePlan.requiredVersionBump} bump`); - } - if (releasePlan.changedFiles.length) { - console.log(`Changed files: ${releasePlan.changedFiles.join(", ")}`); - } - if (releasePlan.removedFiles.length) { - console.log(`Removed files: ${releasePlan.removedFiles.join(", ")}`); - } - if (args.dryRun) { - console.log("Publish: dry-run"); - } -} - -if (args.notesOut) { - await mkdir(path.dirname(args.notesOut), { recursive: true }); - const notesText = releasePlans.map(releasePlan => releasePlan.notesMarkdown).join("\n---\n\n"); - await writeFile(args.notesOut, `${notesText}\n`); -} - -if (args.summaryOut) { - await mkdir(path.dirname(args.summaryOut), { recursive: true }); - await writeFile(args.summaryOut, `${JSON.stringify(publishSummary, undefined, 2)}\n`); -} - -/** - * @param {string[]} argv - */ -function parseArgs(argv) { - /** @type {{ package?: string; version?: string; notesOut?: string; summaryOut?: string; dryRun?: boolean; yes?: boolean; provenance?: boolean; githubRelease?: boolean; }} */ - const args = {}; - - for (let index = 0; index < argv.length; index++) { - const current = argv[index]; - switch (current) { - case "--package": - args.package = requireValue(argv[++index], current); - break; - case "--version": - args.version = requireValue(argv[++index], current); - break; - case "--notes-out": - args.notesOut = path.resolve(requireValue(argv[++index], current)); - break; - case "--summary-out": - args.summaryOut = path.resolve(requireValue(argv[++index], current)); - break; - case "--dry-run": - args.dryRun = true; - break; - case "--yes": - args.yes = true; - break; - case "--provenance": - args.provenance = true; - break; - case "--github-release": - args.githubRelease = true; - break; - case "--help": - case "-h": - printUsageAndExit(); - break; - default: - throw new Error(`Unknown argument: ${current}`); - } - } - - return args; -} - -/** - * @param {string | undefined} value - * @param {string} flagName - */ -function requireValue(value, flagName) { - if (!value) { - throw new Error(`Missing value for ${flagName}`); - } - return value; -} - -function printUsageAndExit() { - console.log(`Usage: - node deploy/deployChangedPackage.mjs [--package ] [--version ] [--dry-run] [--yes] [--provenance] [--notes-out ] [--summary-out ] [--github-release] - -Notes: - A real publish runs only in CI (CI=true) or when --yes is passed. - --provenance attaches npm provenance via GitHub Actions OIDC. - -Examples: - node deploy/deployChangedPackage.mjs --dry-run - node deploy/deployChangedPackage.mjs --yes --notes-out .tmp/package-release-notes.md --summary-out .tmp/package-release-summary.json -`); - process.exit(0); -} diff --git a/deploy/package-lib.mjs b/deploy/package-lib.mjs index 04e4e13..873115f 100644 --- a/deploy/package-lib.mjs +++ b/deploy/package-lib.mjs @@ -13,9 +13,11 @@ import { } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import ts from "typescript-strada"; import { retryAsync, retrySync } from "../lib/net-retry.mjs"; import { compareYearContracts } from "../lib/year-contracts.mjs"; import { packages, repoRoot } from "./package-registry.mjs"; +import { resolveReleaseExecutable } from "./trusted-executable.mjs"; /** * @param {{ @@ -134,52 +136,6 @@ export async function collectReleasePlans(options = {}) { return releasePlans; } -/** - * @param {ReleasePlan} releasePlan - * @param {{ dryRun?: boolean; provenance?: boolean; createGitHubRelease?: boolean; githubRepository?: string; githubToken?: string; githubSha?: string; }} [options] - */ -export async function publishReleasePlan(releasePlan, options = {}) { - if (!releasePlan.changed) { - return { - published: false, - releaseCreated: false, - }; - } - - if (options.dryRun) { - return { - published: false, - releaseCreated: false, - }; - } - - const publishArgs = ["publish", "--access", "public"]; - if (options.provenance) { - // Use GitHub Actions OIDC to cryptographically bind the published - // artifact to its source repository, workflow, and commit. - publishArgs.push("--provenance"); - } - execFileSync("npm", publishArgs, { - cwd: releasePlan.stageDirectory, - stdio: "inherit", - }); - - let releaseCreated = false; - if (options.createGitHubRelease && options.githubRepository && options.githubToken) { - releaseCreated = await createGitHubRelease({ - releasePlan, - githubRepository: options.githubRepository, - githubToken: options.githubToken, - githubSha: options.githubSha, - }); - } - - return { - published: true, - releaseCreated, - }; -} - /** * @param {PackageRegistryEntry} packageConfig * @param {CurrentSnapshot} snapshot @@ -331,16 +287,20 @@ async function buildReleasePlan(stageSummary, reviewedVersion, preview) { stagedSnapshot.get("reports/generation.json"), ); assertNoRemovedYearEntryPoints(removedFiles); - const requiredVersionBump = assertYearContractsPreserved( + const yearVersionBump = assertYearContractsPreserved( published.snapshot.get("reports/generation.json"), stagedSnapshot.get("reports/generation.json"), - { - reviewedVersion, - preview, - publishedVersion: published.version, - stagedVersion: stageSummary.packageVersion, - }, + { preview: true }, ); + const declarationVersionBump = getDeclarationContractImpact(published.snapshot, stagedSnapshot); + const requiredVersionBump = maximumVersionBump(yearVersionBump, declarationVersionBump); + assertReleaseVersionReview({ + requiredVersionBump, + reviewedVersion, + preview, + publishedVersion: published.version, + stagedVersion: stageSummary.packageVersion, + }); const changed = !published.version || changedFiles.length > 0 || removedFiles.length > 0; return { @@ -363,6 +323,265 @@ async function buildReleasePlan(stageSummary, reviewedVersion, preview) { }; } +/** + * @param {Map} publishedSnapshot + * @param {Map} stagedSnapshot + * @returns {"major" | "minor" | undefined} + */ +export function getDeclarationContractImpact(publishedSnapshot, stagedSnapshot) { + if (!publishedSnapshot.size) { + return undefined; + } + /** @type {"minor" | undefined} */ + let additiveImpact; + for (const [relativePath, publishedText] of publishedSnapshot) { + if (!relativePath.endsWith(".d.ts")) { + continue; + } + const stagedText = stagedSnapshot.get(relativePath); + if (stagedText === undefined) { + return "major"; + } + if (stagedText !== publishedText) { + if (!isSafeDeclarationAddition(publishedText, stagedText)) { + return "major"; + } + additiveImpact = "minor"; + } + } + if (publicTypeRoutingChanged(publishedSnapshot.get("package.json"), stagedSnapshot.get("package.json"))) { + return "major"; + } + if (additiveImpact) { + return additiveImpact; + } + return [...stagedSnapshot.keys()].some( + relativePath => relativePath.endsWith(".d.ts") && !publishedSnapshot.has(relativePath), + ) + ? "minor" + : undefined; +} + +/** + * @param {string} previousText + * @param {string} nextText + */ +function isSafeDeclarationAddition(previousText, nextText) { + if (!isLineSubsequence(previousText, nextText)) { + return false; + } + const previousSourceFile = ts.createSourceFile("previous.d.ts", previousText, ts.ScriptTarget.Latest, true); + const nextSourceFile = ts.createSourceFile("next.d.ts", nextText, ts.ScriptTarget.Latest, true); + if ( + ts.isExternalModule(previousSourceFile) !== ts.isExternalModule(nextSourceFile) + || previousSourceFile.hasNoDefaultLib !== nextSourceFile.hasNoDefaultLib + ) { + return false; + } + + return declarationListPreserved( + previousSourceFile.statements, + nextSourceFile.statements, + previousSourceFile, + nextSourceFile, + ); +} + +/** + * @param {readonly import("typescript-strada").Node[]} previousNodes + * @param {readonly import("typescript-strada").Node[]} nextNodes + * @param {import("typescript-strada").SourceFile} previousSourceFile + * @param {import("typescript-strada").SourceFile} nextSourceFile + */ +function declarationListPreserved(previousNodes, nextNodes, previousSourceFile, nextSourceFile) { + const unmatchedNodes = [...nextNodes]; + for (const previousNode of previousNodes) { + const key = getDeclarationNodeKey(previousNode, previousSourceFile); + const exactIndex = unmatchedNodes.findIndex(nextNode => ( + getDeclarationNodeKey(nextNode, nextSourceFile) === key + && previousNode.getText(previousSourceFile) === nextNode.getText(nextSourceFile) + )); + const compatibleIndex = exactIndex >= 0 + ? exactIndex + : unmatchedNodes.findIndex(nextNode => ( + getDeclarationNodeKey(nextNode, nextSourceFile) === key + && declarationNodePreserved(previousNode, nextNode, previousSourceFile, nextSourceFile) + )); + if (compatibleIndex < 0) { + return false; + } + unmatchedNodes.splice(compatibleIndex, 1); + } + return true; +} + +/** + * @param {import("typescript-strada").Node} previousNode + * @param {import("typescript-strada").Node} nextNode + * @param {import("typescript-strada").SourceFile} previousSourceFile + * @param {import("typescript-strada").SourceFile} nextSourceFile + */ +function declarationNodePreserved(previousNode, nextNode, previousSourceFile, nextSourceFile) { + if (previousNode.kind !== nextNode.kind) { + return false; + } + const previousContainer = getDeclarationContainer(previousNode, previousSourceFile); + const nextContainer = getDeclarationContainer(nextNode, nextSourceFile); + return Boolean( + previousContainer + && nextContainer + && previousContainer.prefix === nextContainer.prefix + && previousContainer.suffix === nextContainer.suffix + && declarationListPreserved( + previousContainer.children, + nextContainer.children, + previousSourceFile, + nextSourceFile, + ), + ); +} + +/** + * @param {import("typescript-strada").Node} node + * @param {import("typescript-strada").SourceFile} sourceFile + */ +function getDeclarationContainer(node, sourceFile) { + if (ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node)) { + return createDeclarationContainer(node, node.members, node.members.pos, node.members.end, sourceFile); + } + if (ts.isEnumDeclaration(node)) { + return createDeclarationContainer(node, node.members, node.members.pos, node.members.end, sourceFile); + } + if (ts.isModuleDeclaration(node) && node.body) { + if (ts.isModuleBlock(node.body)) { + return createDeclarationContainer(node, node.body.statements, node.body.statements.pos, node.body.statements.end, sourceFile); + } + return createDeclarationContainer(node, [node.body], node.body.getStart(sourceFile), node.body.end, sourceFile); + } + if (ts.isVariableStatement(node) && node.declarationList.declarations.length === 1) { + const type = node.declarationList.declarations[0].type; + if (type && ts.isTypeLiteralNode(type)) { + return createDeclarationContainer(node, type.members, type.members.pos, type.members.end, sourceFile); + } + } + return undefined; +} + +/** + * @param {import("typescript-strada").Node} node + * @param {readonly import("typescript-strada").Node[]} children + * @param {number} childrenStart + * @param {number} childrenEnd + * @param {import("typescript-strada").SourceFile} sourceFile + */ +function createDeclarationContainer(node, children, childrenStart, childrenEnd, sourceFile) { + return { + children, + prefix: sourceFile.text.slice(node.getStart(sourceFile), childrenStart), + suffix: sourceFile.text.slice(childrenEnd, node.end), + }; +} + +/** + * @param {import("typescript-strada").Node} node + * @param {import("typescript-strada").SourceFile} sourceFile + */ +function getDeclarationNodeKey(node, sourceFile) { + if ( + ts.isClassDeclaration(node) + || ts.isEnumDeclaration(node) + || ts.isFunctionDeclaration(node) + || ts.isInterfaceDeclaration(node) + || ts.isModuleDeclaration(node) + || ts.isTypeAliasDeclaration(node) + ) { + return `${node.kind}:${node.name?.getText(sourceFile) ?? ""}`; + } + if (ts.isVariableStatement(node)) { + return `${node.kind}:${node.declarationList.declarations + .map(declaration => declaration.name.getText(sourceFile)) + .join(",")}`; + } + return `${node.kind}:${node.getText(sourceFile)}`; +} + +/** + * @param {string} previousText + * @param {string} nextText + */ +function isLineSubsequence(previousText, nextText) { + const previousLines = previousText.split("\n"); + const nextLines = nextText.split("\n"); + let previousIndex = 0; + for (const line of nextLines) { + if (line === previousLines[previousIndex]) { + previousIndex++; + } + } + return previousIndex === previousLines.length; +} + +/** + * @param {string | undefined} publishedPackageJsonText + * @param {string | undefined} stagedPackageJsonText + */ +function publicTypeRoutingChanged(publishedPackageJsonText, stagedPackageJsonText) { + if (!publishedPackageJsonText || !stagedPackageJsonText) { + return publishedPackageJsonText !== stagedPackageJsonText; + } + const selectRouting = (/** @type {string} */ packageJsonText) => { + const packageJson = JSON.parse(packageJsonText); + return JSON.stringify({ + types: packageJson.types, + typesVersions: packageJson.typesVersions, + exports: packageJson.exports, + }); + }; + return selectRouting(publishedPackageJsonText) !== selectRouting(stagedPackageJsonText); +} + +/** + * @param {"major" | "minor" | undefined} left + * @param {"major" | "minor" | undefined} right + * @returns {"major" | "minor" | undefined} + */ +function maximumVersionBump(left, right) { + return left === "major" || right === "major" + ? "major" + : left === "minor" || right === "minor" + ? "minor" + : undefined; +} + +/** + * @param {{ + * requiredVersionBump: "major" | "minor" | undefined; + * reviewedVersion: boolean; + * preview: boolean; + * publishedVersion: string | undefined; + * stagedVersion: string; + * }} options + */ +function assertReleaseVersionReview(options) { + if (!options.requiredVersionBump) { + return; + } + if (!options.reviewedVersion) { + if (options.preview) { + return; + } + throw new Error( + `Published declaration contracts require review (${options.requiredVersionBump}); ` + + "pass an explicit --version after inspecting the package diff", + ); + } + assertVersionBump( + options.publishedVersion, + options.stagedVersion, + options.requiredVersionBump, + ); +} + /** * @param {string[]} removedFiles */ @@ -455,12 +674,14 @@ function assertVersionBump(publishedVersion, stagedVersion, requiredBump) { const published = parseVersion(publishedVersion); const staged = parseVersion(stagedVersion); const sufficient = requiredBump === "major" - ? staged.major > published.major + ? published.major === 0 + ? staged.major > 0 || (staged.major === 0 && staged.minor > published.minor) + : staged.major > published.major : staged.major > published.major || (staged.major === published.major && staged.minor > published.minor); if (!sufficient) { throw new Error( - `Baseline year contract changes require a ${requiredBump} version increase from ${publishedVersion}; got ${stagedVersion}`, + `Public declaration contract changes require a ${requiredBump} version increase from ${publishedVersion}; got ${stagedVersion}`, ); } } @@ -474,6 +695,9 @@ export function assertExplicitVersionIncrease(publishedVersion, stagedVersion) { throw new Error("Explicit package version is missing"); } const staged = parseVersion(stagedVersion); + if (staged.prerelease) { + throw new Error(`Explicit release versions must be stable; got ${stagedVersion}`); + } if (!publishedVersion) { return; } @@ -633,18 +857,22 @@ async function getPublishedPackageState(packageConfig) { const tempDirectory = await mkdtemp(path.join(os.tmpdir(), "ts-baseline-published-package-")); try { // Fetching the published tarball is read-only, so it's safe to retry through registry flake. - const packOutput = retrySync(`npm pack ${packageConfig.name}@${version}`, () => execFileSync("npm", ["pack", `${packageConfig.name}@${version}`, "--silent"], { + const npm = resolveReleaseExecutable(repoRoot, "RELEASE_NPM_EXECUTABLE", "npm"); + const packOutput = retrySync(`npm pack ${packageConfig.name}@${version}`, () => execFileSync(npm.executable, ["pack", `${packageConfig.name}@${version}`, "--silent"], { cwd: tempDirectory, encoding: "utf8", + env: npm.environment, })).trim(); const tarballName = packOutput.split(/\r?\n/).filter(Boolean).at(-1); if (!tarballName) { throw new Error(`npm pack did not return a tarball name for ${packageConfig.name}@${version}`); } - execFileSync("tar", ["-xzf", tarballName], { + const tar = resolveReleaseExecutable(repoRoot, "RELEASE_TAR_EXECUTABLE", "tar"); + execFileSync(tar.executable, ["-xzf", tarballName], { cwd: tempDirectory, stdio: "ignore", + env: tar.environment, }); const packageDirectory = path.join(tempDirectory, "package"); @@ -690,9 +918,14 @@ export async function createPackageTarball(directoryPath) { const tarballRoot = path.join(repoRoot, ".tmp", "release-tarballs"); await mkdir(tarballRoot, { recursive: true }); const tarballDirectory = await mkdtemp(path.join(tarballRoot, "pack-")); - const packOutput = execFileSync("npm", ["pack", directoryPath, "--pack-destination", tarballDirectory, "--silent"], { + const npm = resolveReleaseExecutable(repoRoot, "RELEASE_NPM_EXECUTABLE", "npm"); + const packOutput = execFileSync(npm.executable, ["pack", directoryPath, "--pack-destination", tarballDirectory, "--silent"], { cwd: repoRoot, encoding: "utf8", + env: { + ...npm.environment, + npm_config_cache: path.join(tarballDirectory, "npm-cache"), + }, }).trim(); const tarballName = packOutput.split(/\r?\n/).filter(Boolean).at(-1); if (!tarballName) { @@ -828,50 +1061,6 @@ function renderReleaseNotes(options) { ].join("\n"); } -/** - * @param {{ releasePlan: ReleasePlan; githubRepository: string; githubToken: string; githubSha?: string; }} options - */ -async function createGitHubRelease(options) { - const { - releasePlan, - githubRepository, - githubToken, - githubSha, - } = options; - - const [owner, repo] = githubRepository.split("/"); - if (!owner || !repo) { - throw new Error(`Invalid GitHub repository value: ${githubRepository}`); - } - - const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases`, { - method: "POST", - headers: { - "authorization": `Bearer ${githubToken}`, - "accept": "application/vnd.github+json", - "content-type": "application/json", - "user-agent": "typescript-baseline-lib-generator", - }, - body: JSON.stringify({ - tag_name: `${releasePlan.packageConfig.name}@${releasePlan.packageVersion}`, - target_commitish: githubSha, - name: `${releasePlan.packageConfig.name}@${releasePlan.packageVersion}`, - body: releasePlan.notesMarkdown, - }), - }); - - if (response.ok) { - return true; - } - - if (response.status === 422) { - return false; - } - - const responseText = await response.text(); - throw new Error(`Failed to create GitHub release for ${releasePlan.packageConfig.name}: ${response.status} ${response.statusText}\n${responseText}`); -} - /** * @param {Map} snapshot */ diff --git a/deploy/prepareReleaseArtifact.mjs b/deploy/prepareReleaseArtifact.mjs new file mode 100644 index 0000000..ef12b82 --- /dev/null +++ b/deploy/prepareReleaseArtifact.mjs @@ -0,0 +1,90 @@ +// @ts-check + +import { appendFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + collectReleasePlans, + createPackageTarball, +} from "./package-lib.mjs"; +import { + assertCleanWorktree, + hashPreparedReleaseArtifact, + readHeadCommit, + writePreparedReleaseArtifact, +} from "./release-artifact.mjs"; + +const deployDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(deployDirectory, ".."); +const args = parseArgs(process.argv.slice(2)); +assertCleanWorktree(repoRoot); +const sourceCommit = readHeadCommit(repoRoot); +if (process.env.GITHUB_SHA && process.env.GITHUB_SHA !== sourceCommit) { + throw new Error(`Checked-out commit ${sourceCommit} does not match GITHUB_SHA ${process.env.GITHUB_SHA}`); +} + +const releasePlans = await collectReleasePlans({ + packageId: "baseline", + versionOverride: args.version, + preview: true, +}); +if (releasePlans.length !== 1) { + throw new Error(`Expected one release plan, got ${releasePlans.length}`); +} +const [releasePlan] = releasePlans; +if (releasePlan.requiredVersionBump && !args.version && !args.preview) { + throw new Error( + `Release requires a reviewed ${releasePlan.requiredVersionBump} version; rerun with --version after inspecting the package diff`, + ); +} +const tarballPath = releasePlan.changed + ? await createPackageTarball(releasePlan.stageDirectory) + : undefined; +const plan = await writePreparedReleaseArtifact({ + outputDirectory: args.outputDirectory, + sourceCommit, + releasePlan, + tarballPath, +}); +const artifactIntegrity = await hashPreparedReleaseArtifact(args.outputDirectory, plan.changed); +if (process.env.GITHUB_OUTPUT) { + await appendFile(process.env.GITHUB_OUTPUT, `artifact-integrity=${artifactIntegrity}\n`); +} + +console.log(`Prepared: ${plan.changed ? `${plan.packageName}@${plan.packageVersion}` : "no package changes"}`); +console.log(`Artifact: ${args.outputDirectory}`); + +/** + * @param {string[]} argv + */ +function parseArgs(argv) { + /** @type {{ version?: string; outputDirectory: string; preview: boolean; }} */ + const parsed = { + outputDirectory: path.join(repoRoot, "release-artifact"), + preview: false, + }; + for (let index = 0; index < argv.length; index++) { + const current = argv[index]; + if (current === "--version") { + parsed.version = requireValue(argv[++index], current); + } + else if (current === "--preview") { + parsed.preview = true; + } + else { + throw new Error(`Unknown argument: ${current}`); + } + } + return parsed; +} + +/** + * @param {string | undefined} value + * @param {string} flag + */ +function requireValue(value, flag) { + if (!value) { + throw new Error(`Missing value for ${flag}`); + } + return value; +} diff --git a/deploy/publishReleaseArtifact.mjs b/deploy/publishReleaseArtifact.mjs new file mode 100644 index 0000000..0bd0351 --- /dev/null +++ b/deploy/publishReleaseArtifact.mjs @@ -0,0 +1,149 @@ +// @ts-check + +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { repoRoot } from "./package-registry.mjs"; +import { + assertExistingGitHubRelease, + hashPreparedReleaseArtifact, + readPreparedReleaseArtifact, +} from "./release-artifact.mjs"; +import { resolveReleaseExecutable } from "./trusted-executable.mjs"; + +const args = parseArgs(process.argv.slice(2)); +const { plan, tarballPath, notesMarkdown } = await readPreparedReleaseArtifact(args.artifactDirectory); +const artifactIntegrity = await hashPreparedReleaseArtifact(args.artifactDirectory, plan.changed); +if (!process.env.EXPECTED_ARTIFACT_INTEGRITY || artifactIntegrity !== process.env.EXPECTED_ARTIFACT_INTEGRITY) { + throw new Error("Downloaded release artifact does not match the verified build output"); +} +if (!plan.changed) { + throw new Error("Prepared release contains no package changes"); +} +if (!process.env.GITHUB_SHA || process.env.GITHUB_SHA !== plan.sourceCommit) { + throw new Error(`Release artifact commit ${plan.sourceCommit} does not match GITHUB_SHA ${process.env.GITHUB_SHA ?? ""}`); +} + +const metadata = await readPackageMetadata(plan.packageName); +const publishedVersion = metadata?.["dist-tags"]?.latest ?? null; +const existingVersion = metadata?.versions?.[plan.packageVersion]; +let published = false; +if (existingVersion) { + if (existingVersion.dist?.integrity !== plan.tarballIntegrity) { + throw new Error(`${plan.packageName}@${plan.packageVersion} already exists with different integrity`); + } +} +else { + if (publishedVersion !== plan.publishedVersion) { + throw new Error( + `npm latest changed after verification: expected ${plan.publishedVersion ?? "none"}, got ${publishedVersion ?? "none"}`, + ); + } + const publishArgs = ["publish", tarballPath, "--access", "public"]; + if (args.provenance) { + publishArgs.push("--provenance"); + } + const npm = resolveReleaseExecutable(repoRoot, "RELEASE_NPM_EXECUTABLE", "npm"); + execFileSync(npm.executable, publishArgs, { + stdio: "inherit", + env: npm.environment, + }); + published = true; +} + +await createGitHubRelease({ + packageName: plan.packageName, + packageVersion: plan.packageVersion, + sourceCommit: plan.sourceCommit, + notesMarkdown, +}); +console.log(`${published ? "Published" : "Verified existing"}: ${plan.packageName}@${plan.packageVersion}`); + +/** + * @param {string} packageName + */ +async function readPackageMetadata(packageName) { + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`); + if (response.status === 404) { + return undefined; + } + if (!response.ok) { + throw new Error(`npm registry returned ${response.status} ${response.statusText}`); + } + return /** @type {Promise} */ (response.json()); +} + +/** + * @param {{ packageName: string; packageVersion: string; sourceCommit: string; notesMarkdown: string; }} options + */ +async function createGitHubRelease(options) { + const repository = process.env.GITHUB_REPOSITORY; + const token = process.env.GITHUB_TOKEN; + if (!repository || !token) { + throw new Error("GITHUB_REPOSITORY and GITHUB_TOKEN are required to create the release"); + } + const tag = `${options.packageName}@${options.packageVersion}`; + const response = await fetch(`https://api.github.com/repos/${repository}/releases`, { + method: "POST", + headers: githubHeaders(token), + body: JSON.stringify({ + tag_name: tag, + target_commitish: options.sourceCommit, + name: tag, + body: options.notesMarkdown, + }), + }); + if (response.ok) { + return; + } + if (response.status === 422) { + const existing = await fetch( + `https://api.github.com/repos/${repository}/releases/tags/${encodeURIComponent(tag)}`, + { headers: githubHeaders(token) }, + ); + if (existing.ok) { + assertExistingGitHubRelease(await existing.json(), tag, options.sourceCommit); + return; + } + } + throw new Error(`GitHub release creation failed: ${response.status} ${response.statusText}`); +} + +/** + * @param {string} token + */ +function githubHeaders(token) { + return { + "authorization": `Bearer ${token}`, + "accept": "application/vnd.github+json", + "content-type": "application/json", + "user-agent": "typescript-baseline-lib-generator", + "x-github-api-version": "2022-11-28", + }; +} + +/** + * @param {string[]} argv + */ +function parseArgs(argv) { + const parsed = { + artifactDirectory: path.resolve("release-artifact"), + provenance: false, + }; + for (let index = 0; index < argv.length; index++) { + const current = argv[index]; + if (current === "--artifact-dir") { + const value = argv[++index]; + if (!value) { + throw new Error("Missing value for --artifact-dir"); + } + parsed.artifactDirectory = path.resolve(value); + } + else if (current === "--provenance") { + parsed.provenance = true; + } + else { + throw new Error(`Unknown argument: ${current}`); + } + } + return parsed; +} diff --git a/deploy/readmes/baseline.md b/deploy/readmes/baseline.md index 177c546..28bdf66 100644 --- a/deploy/readmes/baseline.md +++ b/deploy/readmes/baseline.md @@ -15,29 +15,38 @@ Current snapshot: - Selected declaration units: `{{SELECTED_UNIT_COUNT}}` - Transformed units: `{{TRANSFORMED_UNIT_COUNT}}` -## Usage +## Best-practice setup -Stock TypeScript doesn't treat `"baseline"` as a built-in `lib` yet, so install the package and list it under `compilerOptions.types`: +Stock TypeScript doesn't treat `"baseline"` as a built-in `lib` yet. Install the current supported TypeScript major with this package: ```sh -npm install --save-dev {{PACKAGE_NAME}} +npm install --save-dev typescript@^7 {{PACKAGE_NAME}} ``` -TypeScript is an optional peer dependency. Install a supported TypeScript 6.x or 7.x compiler separately if your project does not already provide one. +The package also supports existing TypeScript 6.x projects within `{{TYPESCRIPT_PEER_DEPENDENCY_RANGE}}`. TypeScript remains an optional peer dependency. The same snapshot facts are available to tools through `{{PACKAGE_NAME}}/snapshot.json`. +Use the package as the complete global lib: + ```json { "compilerOptions": { "noLib": true, + "strict": true, "types": ["{{PACKAGE_NAME}}"] } } ``` +```sh +npx tsc --noEmit +``` + Now only the supported Baseline widely available JavaScript surfaces type-check; APIs that haven't reached Baseline yet are reported as errors. +This package replaces TypeScript's default libs; do not set `compilerOptions.lib` or combine it with the standard `es*` libs. Add other ambient type packages to `types` only when the project needs them. Those packages can require runtime APIs outside the selected Baseline target. The package preserves erased declarations needed by TypeScript, but it does not expose unavailable APIs merely to satisfy a third-party package. + ## Allow a polyfilled feature When the runtime loads an audited polyfill, add its generated web-features entry after the base package. For example, core-js can provide `Promise.withResolvers` at runtime: diff --git a/deploy/release-artifact.mjs b/deploy/release-artifact.mjs new file mode 100644 index 0000000..82846f2 --- /dev/null +++ b/deploy/release-artifact.mjs @@ -0,0 +1,289 @@ +// @ts-check + +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + existsSync, + lstatSync, + readFileSync, + readlinkSync, +} from "node:fs"; +import { + copyFile, + mkdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { repoRoot } from "./package-registry.mjs"; +import { resolveReleaseExecutable } from "./trusted-executable.mjs"; + +const PLAN_FILE_NAME = "release-plan.json"; +const NOTES_FILE_NAME = "release-notes.md"; +const TARBALL_FILE_NAME = "package.tgz"; + +/** + * @param {string} repoRoot + */ +export function assertCleanWorktree(repoRoot) { + const worktreeStatus = getGitStatus(repoRoot, ["--untracked-files=all"]); + const ignoredGeneratedStatus = getGitStatus(repoRoot, [ + "--untracked-files=all", + "--ignored=matching", + "--", + "generated/current", + ]); + const trackedFileState = runGit(repoRoot, ["ls-files", "-v"]); + const hasHiddenIndexState = trackedFileState + .split("\n") + .some(line => line && line[0] !== "H"); + if (worktreeStatus || ignoredGeneratedStatus || hasHiddenIndexState || hasRawTrackedChanges(repoRoot)) { + throw new Error("Release artifacts require a clean worktree"); + } +} + +/** @param {string} repoRoot */ +export function readHeadCommit(repoRoot) { + return runGit(repoRoot, ["rev-parse", "HEAD"]).trim(); +} + +/** + * Compare raw bytes instead of Git's filtered worktree view. + * + * @param {string} repoRoot + */ +function hasRawTrackedChanges(repoRoot) { + const objectFormat = runGit(repoRoot, ["rev-parse", "--show-object-format"]).trim(); + if (objectFormat !== "sha1" && objectFormat !== "sha256") { + throw new Error(`Unsupported Git object format: ${objectFormat}`); + } + + const entries = runGit(repoRoot, ["ls-tree", "-r", "-z", "HEAD"]) + .split("\0") + .filter(Boolean); + for (const entry of entries) { + const match = /^(100644|100755|120000) blob ([0-9a-f]+)\t([\s\S]+)$/u.exec(entry); + if (!match) { + throw new Error(`Unsupported tracked Git entry: ${entry}`); + } + const [, mode, expectedHash, relativePath] = match; + const filePath = path.join(repoRoot, relativePath); + if (!existsSync(filePath) && mode !== "120000") { + return true; + } + const fileStats = lstatSync(filePath, { throwIfNoEntry: false }); + if (!fileStats) { + return true; + } + if ( + (mode === "120000" && !fileStats.isSymbolicLink()) + || (mode !== "120000" && !fileStats.isFile()) + ) { + return true; + } + const contents = mode === "120000" + ? Buffer.from(readlinkSync(filePath)) + : readFileSync(filePath); + const actualHash = createHash(objectFormat) + .update(`blob ${contents.length}\0`) + .update(contents) + .digest("hex"); + if (actualHash !== expectedHash) { + return true; + } + } + return false; +} + +/** + * @param {string} repoRoot + * @param {string[]} args + */ +function getGitStatus(repoRoot, args) { + return runGit(repoRoot, ["status", "--porcelain=v1", ...args]).trim(); +} + +/** + * @param {string} repoRoot + * @param {string[]} args + */ +function runGit(repoRoot, args) { + const git = resolveReleaseExecutable(repoRoot, "RELEASE_GIT_EXECUTABLE", "git"); + const result = spawnSync(git.executable, args, { + cwd: repoRoot, + encoding: "utf8", + env: { + ...git.environment, + GIT_NO_REPLACE_OBJECTS: "1", + }, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`git ${args[0]} failed with exit code ${result.status}`); + } + return result.stdout; +} + +/** + * @param {unknown} release + * @param {string} tag + * @param {string} sourceCommit + */ +export function assertExistingGitHubRelease(release, tag, sourceCommit) { + if ( + !release + || typeof release !== "object" + || !("tag_name" in release) + || release.tag_name !== tag + || !("target_commitish" in release) + || release.target_commitish !== sourceCommit + ) { + throw new Error(`Existing GitHub release ${tag} does not match source commit ${sourceCommit}`); + } +} + +/** + * @param {{ + * outputDirectory: string; + * sourceCommit: string; + * releasePlan: { + * changed: boolean; + * packageConfig: { name: string; }; + * packageVersion: string; + * publishedVersion?: string; + * requiredVersionBump?: "major" | "minor"; + * notesMarkdown: string; + * }; + * tarballPath?: string; + * }} options + */ +export async function writePreparedReleaseArtifact(options) { + const { releasePlan } = options; + if (releasePlan.changed !== Boolean(options.tarballPath)) { + throw new Error("A changed release plan must have exactly one package tarball"); + } + await rm(options.outputDirectory, { recursive: true, force: true }); + await mkdir(options.outputDirectory, { recursive: true }); + + const targetTarballPath = path.join(options.outputDirectory, TARBALL_FILE_NAME); + const tarballIntegrity = options.tarballPath + ? await copyAndHashTarball(options.tarballPath, targetTarballPath) + : null; + const plan = { + schemaVersion: 1, + sourceCommit: options.sourceCommit, + changed: releasePlan.changed, + packageName: releasePlan.packageConfig.name, + packageVersion: releasePlan.packageVersion, + publishedVersion: releasePlan.publishedVersion ?? null, + requiredVersionBump: releasePlan.requiredVersionBump ?? null, + tarballIntegrity, + }; + validateReleasePlan(plan); + await writeFile( + path.join(options.outputDirectory, PLAN_FILE_NAME), + `${JSON.stringify(plan, undefined, 2)}\n`, + ); + await writeFile(path.join(options.outputDirectory, NOTES_FILE_NAME), releasePlan.notesMarkdown); + return plan; +} + +/** + * @param {string} artifactDirectory + */ +export async function readPreparedReleaseArtifact(artifactDirectory) { + const plan = JSON.parse(await readFile(path.join(artifactDirectory, PLAN_FILE_NAME), "utf8")); + validateReleasePlan(plan); + const tarballPath = path.join(artifactDirectory, TARBALL_FILE_NAME); + if (plan.changed) { + const integrity = await hashFile(tarballPath); + if (integrity !== plan.tarballIntegrity) { + throw new Error(`Release tarball integrity mismatch: expected ${plan.tarballIntegrity}, got ${integrity}`); + } + const tar = resolveReleaseExecutable(repoRoot, "RELEASE_TAR_EXECUTABLE", "tar"); + const packageJson = JSON.parse(execFileSync(tar.executable, ["-xOf", tarballPath, "package/package.json"], { + encoding: "utf8", + env: tar.environment, + })); + if (packageJson.name !== plan.packageName || packageJson.version !== plan.packageVersion) { + throw new Error( + `Release tarball contains ${String(packageJson.name)}@${String(packageJson.version)}; ` + + `expected ${plan.packageName}@${plan.packageVersion}`, + ); + } + } + return { + plan, + tarballPath, + notesMarkdown: await readFile(path.join(artifactDirectory, NOTES_FILE_NAME), "utf8"), + }; +} + +/** + * @param {string} artifactDirectory + * @param {boolean} changed + */ +export async function hashPreparedReleaseArtifact(artifactDirectory, changed) { + const hash = createHash("sha512"); + const fileNames = [PLAN_FILE_NAME, NOTES_FILE_NAME]; + if (changed) { + fileNames.push(TARBALL_FILE_NAME); + } + for (const fileName of fileNames) { + const contents = await readFile(path.join(artifactDirectory, fileName)); + hash.update(`${fileName}\0${contents.length}\0`).update(contents); + } + return `sha512-${hash.digest("base64")}`; +} + +/** + * @param {string} sourcePath + * @param {string} destinationPath + */ +async function copyAndHashTarball(sourcePath, destinationPath) { + await copyFile(sourcePath, destinationPath); + return hashFile(destinationPath); +} + +/** + * @param {string} filePath + */ +async function hashFile(filePath) { + return `sha512-${createHash("sha512").update(await readFile(filePath)).digest("base64")}`; +} + +/** + * @param {any} plan + */ +function validateReleasePlan(plan) { + const expectedKeys = [ + "changed", + "packageName", + "packageVersion", + "publishedVersion", + "requiredVersionBump", + "schemaVersion", + "sourceCommit", + "tarballIntegrity", + ]; + if ( + !plan + || typeof plan !== "object" + || JSON.stringify(Object.keys(plan).sort()) !== JSON.stringify(expectedKeys) + || plan.schemaVersion !== 1 + || typeof plan.changed !== "boolean" + || typeof plan.packageName !== "string" + || typeof plan.packageVersion !== "string" + || !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(plan.packageVersion) + || (plan.publishedVersion !== null && typeof plan.publishedVersion !== "string") + || (plan.requiredVersionBump !== null && plan.requiredVersionBump !== "major" && plan.requiredVersionBump !== "minor") + || !/^[0-9a-f]{40}$/u.test(plan.sourceCommit) + || (plan.changed !== (typeof plan.tarballIntegrity === "string")) + || (typeof plan.tarballIntegrity === "string" && !/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(plan.tarballIntegrity)) + ) { + throw new Error("Invalid prepared release plan"); + } +} diff --git a/deploy/trusted-executable.mjs b/deploy/trusted-executable.mjs new file mode 100644 index 0000000..963700a --- /dev/null +++ b/deploy/trusted-executable.mjs @@ -0,0 +1,50 @@ +// @ts-check + +import path from "node:path"; + +/** + * @param {string} repoRoot + * @param {string} environmentVariable + * @param {string} defaultExecutable + */ +export function resolveReleaseExecutable(repoRoot, environmentVariable, defaultExecutable) { + const configuredExecutable = process.env[environmentVariable]; + if ( + configuredExecutable + && (!path.isAbsolute(configuredExecutable) || isWithinDirectory(configuredExecutable, repoRoot)) + ) { + throw new Error(`${environmentVariable} must be an absolute path outside the repository`); + } + + const safePathEntries = configuredExecutable + ? [ + path.dirname(configuredExecutable), + path.dirname(process.execPath), + ...(process.platform === "win32" ? [] : ["/usr/bin", "/bin"]), + ] + : (process.env.PATH ?? "") + .split(path.delimiter) + .filter(entry => + path.isAbsolute(entry) + && !isWithinDirectory(entry, repoRoot) + && !/[\\/]node_modules[\\/]\.bin(?:$|[\\/])/u.test(entry) + ); + const safePath = [...new Set(safePathEntries)].join(path.delimiter); + return { + executable: configuredExecutable ?? defaultExecutable, + environment: { + ...process.env, + PATH: safePath, + }, + }; +} + +/** + * @param {string} candidatePath + * @param {string} directoryPath + */ +function isWithinDirectory(candidatePath, directoryPath) { + const relativePath = path.relative(directoryPath, candidatePath); + return relativePath === "" + || (!relativePath.startsWith(`..${path.sep}`) && relativePath !== ".." && !path.isAbsolute(relativePath)); +} diff --git a/deploy/verifyReleaseArtifact.mjs b/deploy/verifyReleaseArtifact.mjs new file mode 100644 index 0000000..dfe1e53 --- /dev/null +++ b/deploy/verifyReleaseArtifact.mjs @@ -0,0 +1,30 @@ +// @ts-check + +import { appendFile } from "node:fs/promises"; +import path from "node:path"; +import { + hashPreparedReleaseArtifact, + readPreparedReleaseArtifact, +} from "./release-artifact.mjs"; + +const artifactDirectory = parseArtifactDirectory(process.argv.slice(2)); +const { plan } = await readPreparedReleaseArtifact(artifactDirectory); +const artifactIntegrity = await hashPreparedReleaseArtifact(artifactDirectory, plan.changed); +if (!process.env.EXPECTED_ARTIFACT_INTEGRITY || artifactIntegrity !== process.env.EXPECTED_ARTIFACT_INTEGRITY) { + throw new Error("Release artifact does not match the integrity recorded during preparation"); +} +if (process.env.GITHUB_SHA && plan.sourceCommit !== process.env.GITHUB_SHA) { + throw new Error(`Release artifact commit ${plan.sourceCommit} does not match GITHUB_SHA ${process.env.GITHUB_SHA}`); +} +if (process.env.GITHUB_OUTPUT) { + await appendFile(process.env.GITHUB_OUTPUT, `changed=${plan.changed}\n`); +} +console.log(`Verified release artifact for ${plan.packageName}@${plan.packageVersion}`); + +/** @param {string[]} argv */ +function parseArtifactDirectory(argv) { + if (argv.length !== 2 || argv[0] !== "--artifact-dir" || !argv[1]) { + throw new Error("Usage: node deploy/verifyReleaseArtifact.mjs --artifact-dir "); + } + return path.resolve(argv[1]); +} diff --git a/derived/current/classification.json b/derived/current/classification.json index 28297f2..1a4c98d 100644 --- a/derived/current/classification.json +++ b/derived/current/classification.json @@ -40,7 +40,7 @@ "compatManagementRegistrySummary": { "kind": "typescript-baseline-lib/compat-management-registry", "schemaVersion": 1, - "sourceHash": "sha256-fbebf10c03122d6fe78bafd62030a5842dabf0cf8f2800c3667b601ffee86133", + "sourceHash": "sha256-a2e0009acc49a898cb3154ab149fe75a1760643216e28e502d1d133a924f104a", "groupCount": 19, "managedCompatCount": 60 }, diff --git a/derived/current/compat-management-report.json b/derived/current/compat-management-report.json index 2149d7b..d94ccee 100644 --- a/derived/current/compat-management-report.json +++ b/derived/current/compat-management-report.json @@ -8,7 +8,7 @@ "kind": "typescript-baseline-lib/compat-management-registry", "schemaVersion": 1, "sourcePath": "registry/compat-management.json", - "sourceHash": "sha256-fbebf10c03122d6fe78bafd62030a5842dabf0cf8f2800c3667b601ffee86133", + "sourceHash": "sha256-a2e0009acc49a898cb3154ab149fe75a1760643216e28e502d1d133a924f104a", "groupCount": 19, "managedCompatCount": 60 }, diff --git a/derived/current/generation.json b/derived/current/generation.json index c42c030..064cfa8 100644 --- a/derived/current/generation.json +++ b/derived/current/generation.json @@ -17,14 +17,14 @@ "summary": { "sourceLibCount": 84, "classifiedCompatCount": 1150, - "selectedUnitCount": 1901, - "completeContainerCount": 172, + "selectedUnitCount": 1909, + "completeContainerCount": 176, "excludedUnitCount": 414, "preservedTypeOnlyUnitCount": 29, "transformedUnitCount": 2, "allowEntryCount": 15, "allowEntryUnitCount": 126, - "allowSupportUnitCount": 79, + "allowSupportUnitCount": 77, "yearLibCount": 6 }, "sourceLibs": [ @@ -598,10 +598,6 @@ "kind": "allow-entry", "outputPath": "generated/current/allow/uint8array-base64-hex/index.d.ts" }, - { - "kind": "allow-support", - "outputPath": "generated/current/allow/_support/array-fromasync.d.ts" - }, { "kind": "allow-support", "outputPath": "generated/current/allow/_support/intl-duration-format.d.ts" @@ -690,10 +686,7 @@ "lib.esnext.array.d.ts::ArrayConstructor.fromAsync::2", "lib.esnext.array.d.ts::ArrayConstructor.fromAsync::3" ], - "supportUnitIds": [ - "lib.es2018.asynciterable.d.ts::AsyncIterable.@@asyncIterator::10", - "lib.es2018.asynciterable.d.ts::AsyncIterable::9" - ] + "supportUnitIds": [] }, { "kind": "active", @@ -1099,7 +1092,7 @@ { "year": 2020, "outputPath": "generated/current/year/2020/index.d.ts", - "contentHash": "sha256-a211ab22eb9752333b9c614a57eeb1689b97a116d2a7d8f9eb44130ae63d576f", + "contentHash": "sha256-7f66eaec52ccad3d4870c33071c0e9e227becdadd2f2f61725ee5cd9fcee70df", "includedCompatKeys": [ "javascript.builtins.AggregateError", "javascript.builtins.AggregateError.AggregateError", @@ -1723,14 +1716,14 @@ "javascript.builtins.globalThis", "javascript.builtins.undefined" ], - "selectedUnitCount": 1542, + "selectedUnitCount": 1550, "preservedTypeOnlyUnitCount": 29, "transformedUnitCount": 1 }, { "year": 2021, "outputPath": "generated/current/year/2021/index.d.ts", - "contentHash": "sha256-d5eae27351671819ee1bbf892a62f6a181c0f2509a648c0a307e9c6ad0ff1fef", + "contentHash": "sha256-9d910337717de9eb085908983fb7b95b14daa2a019805947aaff9d6f2b6bceed", "includedCompatKeys": [ "javascript.builtins.AggregateError", "javascript.builtins.AggregateError.AggregateError", @@ -2418,14 +2411,14 @@ "javascript.builtins.globalThis", "javascript.builtins.undefined" ], - "selectedUnitCount": 1783, + "selectedUnitCount": 1791, "preservedTypeOnlyUnitCount": 29, "transformedUnitCount": 2 }, { "year": 2022, "outputPath": "generated/current/year/2022/index.d.ts", - "contentHash": "sha256-afea5cbf913cdcc885b448ca6d75908546346bc368a99089a0365aff70a5d400", + "contentHash": "sha256-ebf80adec1d38790ab32677574c22971e2fdfab97bd0bb3c2706822254aa8cfb", "includedCompatKeys": [ "javascript.builtins.AggregateError", "javascript.builtins.AggregateError.AggregateError", @@ -3123,14 +3116,14 @@ "javascript.builtins.globalThis", "javascript.builtins.undefined" ], - "selectedUnitCount": 1838, + "selectedUnitCount": 1846, "preservedTypeOnlyUnitCount": 29, "transformedUnitCount": 2 }, { "year": 2023, "outputPath": "generated/current/year/2023/index.d.ts", - "contentHash": "sha256-41b12b45750e6ea6c38804e6f885ea82e307e2a290f18550f1fcfa58aa80548f", + "contentHash": "sha256-7713c5b7bb9ec55b967f04ad05931baf43001c1064a2e818e21dfbf24851add9", "includedCompatKeys": [ "javascript.builtins.AggregateError", "javascript.builtins.AggregateError.AggregateError", @@ -3850,14 +3843,14 @@ "javascript.builtins.globalThis", "javascript.builtins.undefined" ], - "selectedUnitCount": 1901, + "selectedUnitCount": 1909, "preservedTypeOnlyUnitCount": 29, "transformedUnitCount": 2 }, { "year": 2024, "outputPath": "generated/current/year/2024/index.d.ts", - "contentHash": "sha256-28acaad2d01790d586ab9aa39d8e22b22755e00da10afdca60828954609752d5", + "contentHash": "sha256-32d89c1cf963d5b3d3dc83024a3fa741d9cba39006812e8197ab2cdf1f11b0a0", "includedCompatKeys": [ "javascript.builtins.AggregateError", "javascript.builtins.AggregateError.AggregateError", @@ -4607,14 +4600,14 @@ "javascript.builtins.globalThis", "javascript.builtins.undefined" ], - "selectedUnitCount": 1962, + "selectedUnitCount": 1968, "preservedTypeOnlyUnitCount": 29, "transformedUnitCount": 0 }, { "year": 2025, "outputPath": "generated/current/year/2025/index.d.ts", - "contentHash": "sha256-2371b7fc8f777f6ba6832eb35bf5aae17d004df7663722983a1fb54814cb3cb3", + "contentHash": "sha256-85a9499d1c70e8b1e1b88265b92731b1877c324ab79bad7c895ab8bbfc97d520", "includedCompatKeys": [ "javascript.builtins.AggregateError", "javascript.builtins.AggregateError.AggregateError", @@ -5404,7 +5397,7 @@ "javascript.builtins.globalThis", "javascript.builtins.undefined" ], - "selectedUnitCount": 2119, + "selectedUnitCount": 2125, "preservedTypeOnlyUnitCount": 29, "transformedUnitCount": 0 } diff --git a/generated/current/allow/_support/array-fromasync.d.ts b/generated/current/allow/_support/array-fromasync.d.ts deleted file mode 100644 index 682a2dd..0000000 --- a/generated/current/allow/_support/array-fromasync.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ -// -// Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. -// Source declarations are derived from the npm `typescript` package. -// Do not edit this file directly. -// - -/*! ***************************************************************************** -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the Apache License, Version 2.0 (the "License"); you may not use -this file except in compliance with the License. You may obtain a copy of the -License at http://www.apache.org/licenses/LICENSE-2.0 - -THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED -WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, -MERCHANTABILITY OR NON-INFRINGEMENT. - -See the Apache Version 2.0 License for specific language governing permissions -and limitations under the License. -***************************************************************************** */ -///////////////////////////// -// lib.es2018.asynciterable.d.ts -///////////////////////////// -interface AsyncIterable { - [Symbol.asyncIterator](): AsyncIterator; -} diff --git a/generated/current/allow/_support/intl-duration-format.d.ts b/generated/current/allow/_support/intl-duration-format.d.ts index a21cf8a..7b6201b 100644 --- a/generated/current/allow/_support/intl-duration-format.d.ts +++ b/generated/current/allow/_support/intl-duration-format.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/_support/intl-segmenter.d.ts b/generated/current/allow/_support/intl-segmenter.d.ts index 276807e..2eb7ec3 100644 --- a/generated/current/allow/_support/intl-segmenter.d.ts +++ b/generated/current/allow/_support/intl-segmenter.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/_support/promise-withresolvers.d.ts b/generated/current/allow/_support/promise-withresolvers.d.ts index 3cdea54..1a2fd6d 100644 --- a/generated/current/allow/_support/promise-withresolvers.d.ts +++ b/generated/current/allow/_support/promise-withresolvers.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/_support/set-methods.d.ts b/generated/current/allow/_support/set-methods.d.ts index 5f2e830..0e6fce1 100644 --- a/generated/current/allow/_support/set-methods.d.ts +++ b/generated/current/allow/_support/set-methods.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/array-fromasync/index.d.ts b/generated/current/allow/array-fromasync/index.d.ts index 1cc018e..868c45b 100644 --- a/generated/current/allow/array-fromasync/index.d.ts +++ b/generated/current/allow/array-fromasync/index.d.ts @@ -1,6 +1,3 @@ -/// - -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/array-group/index.d.ts b/generated/current/allow/array-group/index.d.ts index bd4782a..47666e7 100644 --- a/generated/current/allow/array-group/index.d.ts +++ b/generated/current/allow/array-group/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/atomics-pause/index.d.ts b/generated/current/allow/atomics-pause/index.d.ts index 67815ef..e062ed7 100644 --- a/generated/current/allow/atomics-pause/index.d.ts +++ b/generated/current/allow/atomics-pause/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/atomics-wait-async/index.d.ts b/generated/current/allow/atomics-wait-async/index.d.ts index 5f95cda..bd76d4d 100644 --- a/generated/current/allow/atomics-wait-async/index.d.ts +++ b/generated/current/allow/atomics-wait-async/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/float16array/index.d.ts b/generated/current/allow/float16array/index.d.ts index 71cd573..585eea2 100644 --- a/generated/current/allow/float16array/index.d.ts +++ b/generated/current/allow/float16array/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/getorinsert/index.d.ts b/generated/current/allow/getorinsert/index.d.ts index 205bd8b..21e716f 100644 --- a/generated/current/allow/getorinsert/index.d.ts +++ b/generated/current/allow/getorinsert/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/intl-duration-format/index.d.ts b/generated/current/allow/intl-duration-format/index.d.ts index 733a1f0..b5de055 100644 --- a/generated/current/allow/intl-duration-format/index.d.ts +++ b/generated/current/allow/intl-duration-format/index.d.ts @@ -1,6 +1,5 @@ /// -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/intl-segmenter/index.d.ts b/generated/current/allow/intl-segmenter/index.d.ts index 07ac9bb..a16fd34 100644 --- a/generated/current/allow/intl-segmenter/index.d.ts +++ b/generated/current/allow/intl-segmenter/index.d.ts @@ -1,6 +1,5 @@ /// -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/promise-try/index.d.ts b/generated/current/allow/promise-try/index.d.ts index ceca03a..c554244 100644 --- a/generated/current/allow/promise-try/index.d.ts +++ b/generated/current/allow/promise-try/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/promise-withresolvers/index.d.ts b/generated/current/allow/promise-withresolvers/index.d.ts index 5a0003a..f85fc7d 100644 --- a/generated/current/allow/promise-withresolvers/index.d.ts +++ b/generated/current/allow/promise-withresolvers/index.d.ts @@ -1,6 +1,5 @@ /// -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/regexp-escape/index.d.ts b/generated/current/allow/regexp-escape/index.d.ts index 7d61840..5262174 100644 --- a/generated/current/allow/regexp-escape/index.d.ts +++ b/generated/current/allow/regexp-escape/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/resizable-buffers/index.d.ts b/generated/current/allow/resizable-buffers/index.d.ts index 27eb3d8..f021ccb 100644 --- a/generated/current/allow/resizable-buffers/index.d.ts +++ b/generated/current/allow/resizable-buffers/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/set-methods/index.d.ts b/generated/current/allow/set-methods/index.d.ts index 16d6c95..da31934 100644 --- a/generated/current/allow/set-methods/index.d.ts +++ b/generated/current/allow/set-methods/index.d.ts @@ -1,6 +1,5 @@ /// -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/transferable-arraybuffer/index.d.ts b/generated/current/allow/transferable-arraybuffer/index.d.ts index a72ca74..ab9914b 100644 --- a/generated/current/allow/transferable-arraybuffer/index.d.ts +++ b/generated/current/allow/transferable-arraybuffer/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/allow/uint8array-base64-hex/index.d.ts b/generated/current/allow/uint8array-base64-hex/index.d.ts index 4bd3e3c..11dda1c 100644 --- a/generated/current/allow/uint8array-base64-hex/index.d.ts +++ b/generated/current/allow/uint8array-base64-hex/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. diff --git a/generated/current/baseline.d.ts b/generated/current/baseline.d.ts index fcf8bed..bd052b4 100644 --- a/generated/current/baseline.d.ts +++ b/generated/current/baseline.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. @@ -778,6 +777,13 @@ interface Iterable { [Symbol.iterator](): Iterator; } +/** + * Describes a user-defined {@link Iterator} that is also iterable. + */ +interface IterableIterator extends Iterator { + [Symbol.iterator](): IterableIterator; +} + /** * Describes an {@link Iterator} produced by the runtime that inherits from the intrinsic `Iterator.prototype`. */ @@ -2384,6 +2390,17 @@ interface AsyncIterator { throw?(e?: any): Promise>; } +interface AsyncIterable { + [Symbol.asyncIterator](): AsyncIterator; +} + +/** + * Describes a user-defined {@link AsyncIterator} that is also async iterable. + */ +interface AsyncIterableIterator extends AsyncIterator { + [Symbol.asyncIterator](): AsyncIterableIterator; +} + /** * Describes an {@link AsyncIterator} produced by the runtime that inherits from the intrinsic `AsyncIterator.prototype`. */ @@ -6163,6 +6180,10 @@ interface NumberConstructor { /** An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers. */ declare var Number: NumberConstructor; +interface TemplateStringsArray extends ReadonlyArray { + readonly raw: readonly string[]; +} + interface Math { /** The mathematical constant e. This is Euler's number, the base of natural logarithms. */ readonly E: number; diff --git a/generated/current/year/2020/index.d.ts b/generated/current/year/2020/index.d.ts index e34efaf..e59bab9 100644 --- a/generated/current/year/2020/index.d.ts +++ b/generated/current/year/2020/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. @@ -778,6 +777,13 @@ interface Iterable { [Symbol.iterator](): Iterator; } +/** + * Describes a user-defined {@link Iterator} that is also iterable. + */ +interface IterableIterator extends Iterator { + [Symbol.iterator](): IterableIterator; +} + /** * Describes an {@link Iterator} produced by the runtime that inherits from the intrinsic `Iterator.prototype`. */ @@ -2266,6 +2272,17 @@ interface AsyncIterator { throw?(e?: any): Promise>; } +interface AsyncIterable { + [Symbol.asyncIterator](): AsyncIterator; +} + +/** + * Describes a user-defined {@link AsyncIterator} that is also async iterable. + */ +interface AsyncIterableIterator extends AsyncIterator { + [Symbol.asyncIterator](): AsyncIterableIterator; +} + /** * Describes an {@link AsyncIterator} produced by the runtime that inherits from the intrinsic `AsyncIterator.prototype`. */ @@ -3818,6 +3835,10 @@ interface NumberConstructor { /** An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers. */ declare var Number: NumberConstructor; +interface TemplateStringsArray extends ReadonlyArray { + readonly raw: readonly string[]; +} + interface Math { /** The mathematical constant e. This is Euler's number, the base of natural logarithms. */ readonly E: number; diff --git a/generated/current/year/2021/index.d.ts b/generated/current/year/2021/index.d.ts index 4709804..d2cb466 100644 --- a/generated/current/year/2021/index.d.ts +++ b/generated/current/year/2021/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. @@ -778,6 +777,13 @@ interface Iterable { [Symbol.iterator](): Iterator; } +/** + * Describes a user-defined {@link Iterator} that is also iterable. + */ +interface IterableIterator extends Iterator { + [Symbol.iterator](): IterableIterator; +} + /** * Describes an {@link Iterator} produced by the runtime that inherits from the intrinsic `Iterator.prototype`. */ @@ -2384,6 +2390,17 @@ interface AsyncIterator { throw?(e?: any): Promise>; } +interface AsyncIterable { + [Symbol.asyncIterator](): AsyncIterator; +} + +/** + * Describes a user-defined {@link AsyncIterator} that is also async iterable. + */ +interface AsyncIterableIterator extends AsyncIterator { + [Symbol.asyncIterator](): AsyncIterableIterator; +} + /** * Describes an {@link AsyncIterator} produced by the runtime that inherits from the intrinsic `AsyncIterator.prototype`. */ @@ -5032,6 +5049,10 @@ interface NumberConstructor { /** An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers. */ declare var Number: NumberConstructor; +interface TemplateStringsArray extends ReadonlyArray { + readonly raw: readonly string[]; +} + interface Math { /** The mathematical constant e. This is Euler's number, the base of natural logarithms. */ readonly E: number; diff --git a/generated/current/year/2022/index.d.ts b/generated/current/year/2022/index.d.ts index 03486ec..e19f842 100644 --- a/generated/current/year/2022/index.d.ts +++ b/generated/current/year/2022/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. @@ -778,6 +777,13 @@ interface Iterable { [Symbol.iterator](): Iterator; } +/** + * Describes a user-defined {@link Iterator} that is also iterable. + */ +interface IterableIterator extends Iterator { + [Symbol.iterator](): IterableIterator; +} + /** * Describes an {@link Iterator} produced by the runtime that inherits from the intrinsic `Iterator.prototype`. */ @@ -2384,6 +2390,17 @@ interface AsyncIterator { throw?(e?: any): Promise>; } +interface AsyncIterable { + [Symbol.asyncIterator](): AsyncIterator; +} + +/** + * Describes a user-defined {@link AsyncIterator} that is also async iterable. + */ +interface AsyncIterableIterator extends AsyncIterator { + [Symbol.asyncIterator](): AsyncIterableIterator; +} + /** * Describes an {@link AsyncIterator} produced by the runtime that inherits from the intrinsic `AsyncIterator.prototype`. */ @@ -5725,6 +5742,10 @@ interface NumberConstructor { /** An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers. */ declare var Number: NumberConstructor; +interface TemplateStringsArray extends ReadonlyArray { + readonly raw: readonly string[]; +} + interface Math { /** The mathematical constant e. This is Euler's number, the base of natural logarithms. */ readonly E: number; diff --git a/generated/current/year/2023/index.d.ts b/generated/current/year/2023/index.d.ts index fcf8bed..bd052b4 100644 --- a/generated/current/year/2023/index.d.ts +++ b/generated/current/year/2023/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. @@ -778,6 +777,13 @@ interface Iterable { [Symbol.iterator](): Iterator; } +/** + * Describes a user-defined {@link Iterator} that is also iterable. + */ +interface IterableIterator extends Iterator { + [Symbol.iterator](): IterableIterator; +} + /** * Describes an {@link Iterator} produced by the runtime that inherits from the intrinsic `Iterator.prototype`. */ @@ -2384,6 +2390,17 @@ interface AsyncIterator { throw?(e?: any): Promise>; } +interface AsyncIterable { + [Symbol.asyncIterator](): AsyncIterator; +} + +/** + * Describes a user-defined {@link AsyncIterator} that is also async iterable. + */ +interface AsyncIterableIterator extends AsyncIterator { + [Symbol.asyncIterator](): AsyncIterableIterator; +} + /** * Describes an {@link AsyncIterator} produced by the runtime that inherits from the intrinsic `AsyncIterator.prototype`. */ @@ -6163,6 +6180,10 @@ interface NumberConstructor { /** An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers. */ declare var Number: NumberConstructor; +interface TemplateStringsArray extends ReadonlyArray { + readonly raw: readonly string[]; +} + interface Math { /** The mathematical constant e. This is Euler's number, the base of natural logarithms. */ readonly E: number; diff --git a/generated/current/year/2024/index.d.ts b/generated/current/year/2024/index.d.ts index 065dad2..b27d176 100644 --- a/generated/current/year/2024/index.d.ts +++ b/generated/current/year/2024/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. @@ -778,6 +777,13 @@ interface Iterable { [Symbol.iterator](): Iterator; } +/** + * Describes a user-defined {@link Iterator} that is also iterable. + */ +interface IterableIterator extends Iterator { + [Symbol.iterator](): IterableIterator; +} + /** * Describes an {@link Iterator} produced by the runtime that inherits from the intrinsic `Iterator.prototype`. */ @@ -2388,6 +2394,13 @@ interface AsyncIterable { [Symbol.asyncIterator](): AsyncIterator; } +/** + * Describes a user-defined {@link AsyncIterator} that is also async iterable. + */ +interface AsyncIterableIterator extends AsyncIterator { + [Symbol.asyncIterator](): AsyncIterableIterator; +} + /** * Describes an {@link AsyncIterator} produced by the runtime that inherits from the intrinsic `AsyncIterator.prototype`. */ @@ -6504,6 +6517,10 @@ interface NumberConstructor { /** An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers. */ declare var Number: NumberConstructor; +interface TemplateStringsArray extends ReadonlyArray { + readonly raw: readonly string[]; +} + interface Math { /** The mathematical constant e. This is Euler's number, the base of natural logarithms. */ readonly E: number; diff --git a/generated/current/year/2025/index.d.ts b/generated/current/year/2025/index.d.ts index 268e7a9..996b88e 100644 --- a/generated/current/year/2025/index.d.ts +++ b/generated/current/year/2025/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // // Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files. // Source declarations are derived from the npm `typescript` package. @@ -781,6 +780,13 @@ declare global { [Symbol.iterator](): Iterator; } + /** + * Describes a user-defined {@link Iterator} that is also iterable. + */ + interface IterableIterator extends Iterator { + [Symbol.iterator](): IterableIterator; + } + /** * Describes an {@link Iterator} produced by the runtime that inherits from the intrinsic `Iterator.prototype`. */ @@ -2391,6 +2397,13 @@ declare global { [Symbol.asyncIterator](): AsyncIterator; } + /** + * Describes a user-defined {@link AsyncIterator} that is also async iterable. + */ + interface AsyncIterableIterator extends AsyncIterator { + [Symbol.asyncIterator](): AsyncIterableIterator; + } + /** * Describes an {@link AsyncIterator} produced by the runtime that inherits from the intrinsic `AsyncIterator.prototype`. */ @@ -7245,6 +7258,10 @@ declare global { /** An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers. */ var Number: NumberConstructor; + interface TemplateStringsArray extends ReadonlyArray { + readonly raw: readonly string[]; + } + interface Math { /** The mathematical constant e. This is Euler's number, the base of natural logarithms. */ readonly E: number; diff --git a/lib/classifier.mjs b/lib/classifier.mjs index 7c973e9..91c4071 100644 --- a/lib/classifier.mjs +++ b/lib/classifier.mjs @@ -14,7 +14,7 @@ import { compareStringsCaseSensitive, formatPathForReport, requireRelativeManifestPath, - resolveOutputPath, + resolveManagedOutputPath, } from "./shared.mjs"; import { findLongestCompatRoot, @@ -161,8 +161,24 @@ export async function classifyManifest(options) { } = options; const baselineTarget = manifest.baselineTarget ?? "high"; const datasetPath = requireRelativeManifestPath(manifest.dataset, manifestPath, "dataset"); - const classificationOutputPath = resolveOutputPath(manifest.classificationOutput, manifestPath, "classification.json"); - const compatManagementOutputPath = resolveOutputPath(manifest.compatManagementOutput, manifestPath, "compat-management-report.json"); + const managedDerivedRoots = [ + path.join(repoRoot, "derived", "current"), + path.join(repoRoot, ".tmp"), + ]; + const classificationOutputPath = resolveManagedOutputPath( + manifest.classificationOutput, + repoRoot, + manifestPath, + "classificationOutput", + managedDerivedRoots, + ); + const compatManagementOutputPath = resolveManagedOutputPath( + manifest.compatManagementOutput, + repoRoot, + manifestPath, + "compatManagementOutput", + managedDerivedRoots, + ); const compatManagementRegistryPath = manifest.compatManagementRegistry ? requireRelativeManifestPath(manifest.compatManagementRegistry, manifestPath, "compatManagementRegistry") : undefined; @@ -174,6 +190,7 @@ export async function classifyManifest(options) { datasetPath, manifest.snapshot.name, manifest.snapshot.baselineDate, + manifest.snapshot.webFeaturesPackageVersion, ); const libCompatRows = dataset.compatRows .filter( diff --git a/lib/compat-management-registry.mjs b/lib/compat-management-registry.mjs index 7c68504..302430e 100644 --- a/lib/compat-management-registry.mjs +++ b/lib/compat-management-registry.mjs @@ -39,6 +39,25 @@ export async function loadCompatManagementRegistry(filePath) { memberNames: [...mapping.memberNames], }]), ); + /** @type {string[]} */ + const compilerSupportSurfaces = data.compilerSupport + .flatMap((/** @type {{ surfaces: string[]; }} */ group) => group.surfaces) + .sort(compareStringsCaseSensitive); + /** @type {string[]} */ + const runtimeAliasSurfaces = data.runtimeAliases + .flatMap((/** @type {{ surfaces: string[]; }} */ group) => group.surfaces) + .sort(compareStringsCaseSensitive); + assertUniqueSupportSurfaces(filePath, "compiler support", compilerSupportSurfaces); + assertUniqueSupportSurfaces(filePath, "runtime alias", runtimeAliasSurfaces); + const conflictingSupportSurfaces = compilerSupportSurfaces.filter(surface => runtimeAliasSurfaces.includes(surface)); + if (conflictingSupportSurfaces.length) { + throw new Error( + `Compat management registry ${filePath} declares surfaces as both compiler support and runtime aliases:\n` + + conflictingSupportSurfaces.sort(compareStringsCaseSensitive) + .map(surface => `- ${surface}`) + .join("\n"), + ); + } for (const rawGroup of data.groups) { const group = normalizeCompatManagementGroup(rawGroup); @@ -79,12 +98,31 @@ export async function loadCompatManagementRegistry(filePath) { schemaVersion: data.schemaVersion, sourcePath: filePath, sourceHash: hashText(sourceText), + compilerSupportSurfaces, + runtimeAliasSurfaces, groups, entries, entryByCompatKey, }; } +/** + * @param {string} filePath + * @param {string} groupKind + * @param {string[]} surfaces + */ +function assertUniqueSupportSurfaces(filePath, groupKind, surfaces) { + const duplicates = surfaces.filter((surface, index) => surfaces.indexOf(surface) !== index); + if (duplicates.length) { + throw new Error( + `Compat management registry ${filePath} declares duplicate ${groupKind} surfaces:\n` + + [...new Set(duplicates)].sort(compareStringsCaseSensitive) + .map(surface => `- ${surface}`) + .join("\n"), + ); + } +} + /** * @param {{ * data: any; diff --git a/lib/dataset-loader.mjs b/lib/dataset-loader.mjs index a27d392..c48a5ae 100644 --- a/lib/dataset-loader.mjs +++ b/lib/dataset-loader.mjs @@ -6,8 +6,9 @@ import { readFile } from "node:fs/promises"; * @param {string} filePath * @param {string} expectedSnapshot * @param {string} [expectedBaselineDate] + * @param {string} [expectedWebFeaturesVersion] */ -export async function loadBaselineDataset(filePath, expectedSnapshot, expectedBaselineDate) { +export async function loadBaselineDataset(filePath, expectedSnapshot, expectedBaselineDate, expectedWebFeaturesVersion) { const dataset = JSON.parse(await readFile(filePath, "utf8")); const snapshotDate = expectedBaselineDate ? parseIsoDate(expectedBaselineDate, "manifest snapshot baselineDate") @@ -23,6 +24,15 @@ export async function loadBaselineDataset(filePath, expectedSnapshot, expectedBa `Dataset baselineDate ${String(dataset.snapshot.baselineDate)} does not match expected ${expectedBaselineDate}`, ); } + if ( + expectedWebFeaturesVersion + && dataset.snapshot.webFeaturesPackageVersion !== expectedWebFeaturesVersion + ) { + throw new Error( + `Dataset webFeaturesPackageVersion ${String(dataset.snapshot.webFeaturesPackageVersion)} ` + + `does not match expected ${expectedWebFeaturesVersion}`, + ); + } if (!Array.isArray(dataset.featureRows)) { throw new Error(`Dataset ${filePath} is missing featureRows`); } diff --git a/lib/generator.mjs b/lib/generator.mjs index 04c7cd6..b25494e 100644 --- a/lib/generator.mjs +++ b/lib/generator.mjs @@ -3,7 +3,6 @@ import { mkdir, readFile, - rm, writeFile, } from "node:fs/promises"; import { createHash } from "node:crypto"; @@ -20,7 +19,8 @@ import { verifyLibSource } from "./toolchain-libs.mjs"; import { compareStringsCaseSensitive, formatPathForReport, - resolveOutputPath, + removeManagedPath, + resolveManagedOutputPath, } from "./shared.mjs"; import { createSurfaceInventory, @@ -162,6 +162,7 @@ export function resolveUnclaimedTypeOnlyUnitIds(options) { * @param {{ * inventory: import("./surface-inventory.mjs").SurfaceInventory; * compatSelectedUnitIds: Set; + * compilerSupportUnitIds: Set; * typeOnlyUnitIds: string[]; * completeContainerUnitIds: Set; * excludedUnitIds: Set; @@ -170,7 +171,10 @@ export function resolveUnclaimedTypeOnlyUnitIds(options) { export function resolveTypeOnlyDependencyClosure(options) { const compatClosure = new Set(resolveDependencyClosure({ inventory: options.inventory, - initiallySelectedUnitIds: options.compatSelectedUnitIds, + initiallySelectedUnitIds: new Set([ + ...options.compatSelectedUnitIds, + ...options.compilerSupportUnitIds, + ]), completeContainerUnitIds: new Set(options.completeContainerUnitIds), excludedUnitIds: options.excludedUnitIds, })); @@ -178,6 +182,7 @@ export function resolveTypeOnlyDependencyClosure(options) { inventory: options.inventory, initiallySelectedUnitIds: new Set([ ...options.compatSelectedUnitIds, + ...options.compilerSupportUnitIds, ...options.typeOnlyUnitIds, ]), completeContainerUnitIds: options.completeContainerUnitIds, @@ -268,6 +273,102 @@ export function assertExclusionInvariants(options) { } } +/** + * @param {{ + * inventory: import("./surface-inventory.mjs").SurfaceInventory; + * selectedUnitIds: Iterable; + * completeContainerUnitIds: Set; + * excludedUnitIds: Set; + * classifiedCompatRows: Array<{ + * compatKey: string; + * compatRoot: string; + * includeInTarget: boolean; + * resolutionKind: string; + * resolvedUnitIds: string[]; + * }>; + * compilerSupportUnitIds: Set; + * runtimeAliasUnitIds: Set; + * }} options + */ +export function assertRuntimeDeclarationProvenance(options) { + const selectedUnitIds = [...options.selectedUnitIds]; + const emittedUnitIds = collectEmittedUnitIds({ + inventory: options.inventory, + selectedUnitIds, + completeContainerUnitIds: options.completeContainerUnitIds, + excludedUnitIds: options.excludedUnitIds, + }); + const includedRows = options.classifiedCompatRows.filter(row => row.includeInTarget); + const claimedUnitIds = new Set(includedRows.flatMap(row => row.resolvedUnitIds)); + const claimedMemberKeys = new Set( + [...claimedUnitIds] + .map(unitId => options.inventory.unitById.get(unitId)) + .flatMap(unit => unit?.ownerSymbol && unit.memberName + ? [`${unit.ownerSymbol}\0${unit.memberName}`] + : []), + ); + const runtimeOwnerSymbols = new Set( + [...options.inventory.rootSurfaceByCompatName.values()] + .filter(root => root.rootDeclarationUnitIds.some(unitId => { + const unit = options.inventory.unitById.get(unitId); + return RUNTIME_DECLARATION_KINDS.has(unit?.declarationKind ?? ""); + })) + .flatMap(root => [...root.instanceContainerSymbols, ...root.staticContainerSymbols]), + ); + const compilerSupportUnits = [...options.compilerSupportUnitIds] + .map(unitId => options.inventory.unitById.get(unitId)); + const runtimeAliasUnits = [...options.runtimeAliasUnitIds] + .map(unitId => options.inventory.unitById.get(unitId)); + const compilerSupportMemberKeys = new Set( + [...compilerSupportUnits, ...runtimeAliasUnits].flatMap(unit => unit?.ownerSymbol && unit.memberName + ? [`${unit.ownerSymbol}\0${unit.memberName}`] + : []), + ); + const unprovenUnits = [...emittedUnitIds] + .map(unitId => options.inventory.unitById.get(unitId)) + .filter(unit => { + if ( + !unit + || options.compilerSupportUnitIds.has(unit.id) + || options.runtimeAliasUnitIds.has(unit.id) + || claimedUnitIds.has(unit.id) + ) { + return false; + } + if (unit.unitKind === "member") { + if ( + unit.memberName === "" + || (unit.ownerSymbol && !runtimeOwnerSymbols.has(unit.ownerSymbol)) + || ( + unit.ownerSymbol + && unit.memberName + && compilerSupportMemberKeys.has(`${unit.ownerSymbol}\0${unit.memberName}`) + ) + ) { + return false; + } + return !unit.ownerSymbol + || !unit.memberName + || !claimedMemberKeys.has(`${unit.ownerSymbol}\0${unit.memberName}`); + } + return RUNTIME_DECLARATION_KINDS.has(unit.declarationKind ?? "") + && unit.declarationKind !== "namespace" + && !( + options.inventory.fileByLibFileName.get(unit.libFileName)?.preserveWholeFile + && !unit.containerPath.length + ); + }) + .map(unit => unit?.id) + .filter(unitId => unitId !== undefined) + .sort(compareStringsCaseSensitive); + if (unprovenUnits.length) { + throw new Error( + "Emitted runtime declarations lack compat or compiler-support provenance:\n" + + unprovenUnits.map(unitId => `- ${unitId}`).join("\n"), + ); + } +} + /** * @param {{ * manifestPath: string; @@ -289,19 +390,48 @@ async function createGenerationPlan({ manifestPath, repoRoot }) { throw new Error(`Manifest ${manifestPath} is missing firstClassLib.firstYear`); } - const topLevelOutputPath = path.resolve(path.dirname(manifestPath), manifest.firstClassLib.outputFile); - const allowOutputDirectory = path.resolve( - path.dirname(manifestPath), - manifest.firstClassLib.allowDirectory ?? path.join(path.dirname(manifest.firstClassLib.outputFile), "allow"), + const testOutputRoot = path.join(repoRoot, ".tmp"); + const topLevelOutputPath = resolveManagedOutputPath( + manifest.firstClassLib.outputFile, + repoRoot, + manifestPath, + "firstClassLib.outputFile", + [path.join(repoRoot, "generated", "current"), testOutputRoot], + ); + const allowOutputDirectory = resolveManagedOutputPath( + manifest.firstClassLib.allowDirectory, + repoRoot, + manifestPath, + "firstClassLib.allowDirectory", + [path.join(repoRoot, "generated", "current", "allow"), testOutputRoot], ); if (!manifest.allowlistRegistry) { throw new Error(`Manifest ${manifestPath} is missing allowlistRegistry`); } const allowlistRegistryPath = path.resolve(path.dirname(manifestPath), manifest.allowlistRegistry); const allowlistRegistry = await loadAllowlistRegistry(allowlistRegistryPath); - const yearOutputDirectory = path.resolve(path.dirname(manifestPath), manifest.firstClassLib.yearDirectory); - const generationOutputPath = resolveOutputPath(manifest.generationOutput, manifestPath, "generation.json"); - const inventoryOutputPath = resolveOutputPath(manifest.inventoryOutput, manifestPath, "inventory.json"); + const yearOutputDirectory = resolveManagedOutputPath( + manifest.firstClassLib.yearDirectory, + repoRoot, + manifestPath, + "firstClassLib.yearDirectory", + [path.join(repoRoot, "generated", "current", "year"), testOutputRoot], + ); + const managedDerivedRoots = [path.join(repoRoot, "derived", "current"), testOutputRoot]; + const generationOutputPath = resolveManagedOutputPath( + manifest.generationOutput, + repoRoot, + manifestPath, + "generationOutput", + managedDerivedRoots, + ); + const inventoryOutputPath = resolveManagedOutputPath( + manifest.inventoryOutput, + repoRoot, + manifestPath, + "inventoryOutput", + managedDerivedRoots, + ); // In TS7, lib.*.d.ts ships in platform-specific packages, so verify it // matches the manifest libSource pin (content hash) before reading. @@ -330,6 +460,8 @@ async function createGenerationPlan({ manifestPath, repoRoot }) { const mainSelection = createTargetSelection({ inventory, classifiedCompatRows: classification.classifiedCompatRows, + compilerSupportSurfaces: classification.compatManagementRegistry.compilerSupportSurfaces, + runtimeAliasSurfaces: classification.compatManagementRegistry.runtimeAliasSurfaces, }); const libCompatRows = classification.dataset.compatRows .filter( @@ -354,7 +486,12 @@ async function createGenerationPlan({ manifestPath, repoRoot }) { year, outputPath: path.join(yearOutputDirectory, String(year), "index.d.ts"), ...createYearCompatAudit(classifiedCompatRows), - ...createTargetSelection({ inventory, classifiedCompatRows }), + ...createTargetSelection({ + inventory, + classifiedCompatRows, + compilerSupportSurfaces: classification.compatManagementRegistry.compilerSupportSurfaces, + runtimeAliasSurfaces: classification.compatManagementRegistry.runtimeAliasSurfaces, + }), }; }); @@ -430,26 +567,14 @@ async function createGenerationPlan({ manifestPath, repoRoot }) { * }} options */ function collectEmittedUnitIds(options) { - const emittedUnitIds = new Set(options.selectedUnitIds); - - /** @param {string} unitId */ - function includeUnit(unitId) { - if (options.excludedUnitIds.has(unitId)) { - return; - } - emittedUnitIds.add(unitId); - const unit = options.inventory.unitById.get(unitId); - if (!unit?.containerId) { - return; - } - for (const child of options.inventory.units.filter(candidate => candidate.parentContainerId === unit.containerId)) { - includeUnit(child.id); - } - } - - for (const unitId of options.completeContainerUnitIds) { - includeUnit(unitId); - } + const emittedUnitIds = new Set(); + emitSelectedUnits({ + inventory: options.inventory, + selectedUnitIds: options.selectedUnitIds, + completeContainerUnitIds: options.completeContainerUnitIds, + excludedUnitIds: options.excludedUnitIds, + emittedUnitIds, + }); return emittedUnitIds; } @@ -782,11 +907,14 @@ function createYearCompatAudit(classifiedCompatRows) { * inventory: import("./surface-inventory.mjs").SurfaceInventory; * classifiedCompatRows: Array<{ * compatKey: string; + * compatRoot: string; * includeInTarget: boolean; * resolutionKind: string; * resolvedUnitIds: string[]; * transforms: Array<{ unitId: string; kind: string; compatKey: string; }>; * }>; + * compilerSupportSurfaces: string[]; + * runtimeAliasSurfaces: string[]; * }} options */ function createTargetSelection(options) { @@ -799,34 +927,66 @@ function createTargetSelection(options) { classifiedCompatRows: options.classifiedCompatRows, excludedUnitIds, }); + const registrySupportUnitIds = resolveCompilerSupportSurfaceUnitIds( + options.inventory, + options.compilerSupportSurfaces, + ); + const runtimeAliasUnitIds = resolveRuntimeAliasUnitIds( + options.inventory, + options.runtimeAliasSurfaces, + ); + const excludedRegistrySupportUnitIds = registrySupportUnitIds.filter(unitId => excludedUnitIds.has(unitId)); + if (excludedRegistrySupportUnitIds.length) { + throw new Error( + "Compiler support surfaces conflict with excluded compat declarations:\n" + + excludedRegistrySupportUnitIds.map(unitId => `- ${unitId}`).join("\n"), + ); + } const initiallySelectedUnitIds = new Set([ ...compatSelectedUnitIds, ...typeOnlyUnitIds, + ...registrySupportUnitIds, ]); - const completeContainerUnitIds = new Set( - options.classifiedCompatRows + const compilerSupportUnitIds = new Set(registrySupportUnitIds); + const registrySupportContainerUnitIds = registrySupportUnitIds.filter(unitId => { + const unit = options.inventory.unitById.get(unitId); + return unit?.unitKind === "declaration" && Boolean(unit.containerId); + }); + const completeContainerUnitIds = new Set([ + ...options.classifiedCompatRows .filter(row => row.includeInTarget && row.resolutionKind === "root-availability") .flatMap(row => row.resolvedUnitIds) .filter(unitId => { const unit = options.inventory.unitById.get(unitId); return unit?.unitKind === "declaration" && Boolean(unit.containerId); }), - ); + ...registrySupportContainerUnitIds, + ]); + for (const unitId of collectEmittedUnitIds({ + inventory: options.inventory, + selectedUnitIds: registrySupportUnitIds, + completeContainerUnitIds: new Set(registrySupportContainerUnitIds), + excludedUnitIds, + })) { + compilerSupportUnitIds.add(unitId); + } const unitTextOverrides = buildUnitTextOverrides({ classifiedCompatRows: options.classifiedCompatRows, inventory: options.inventory, initiallySelectedUnitIds, }); + const selectedBeforeCompilerStabilization = resolveTypeOnlyDependencyClosure({ + inventory: options.inventory, + compatSelectedUnitIds: new Set(compatSelectedUnitIds), + compilerSupportUnitIds: new Set(registrySupportUnitIds), + typeOnlyUnitIds, + completeContainerUnitIds, + excludedUnitIds, + }); const selectedUnitIds = stabilizeCompilerGlobalSupport({ inventory: options.inventory, unitTextOverrides, - selectedUnitIds: resolveTypeOnlyDependencyClosure({ - inventory: options.inventory, - compatSelectedUnitIds: new Set(compatSelectedUnitIds), - typeOnlyUnitIds, - completeContainerUnitIds, - excludedUnitIds, - }), + selectedUnitIds: selectedBeforeCompilerStabilization, completeContainerUnitIds, excludedUnitIds, }); @@ -836,6 +996,15 @@ function createTargetSelection(options) { excludedUnitIds, excludedRowsByUnitId, }); + assertRuntimeDeclarationProvenance({ + inventory: options.inventory, + selectedUnitIds, + completeContainerUnitIds, + excludedUnitIds, + classifiedCompatRows: options.classifiedCompatRows, + compilerSupportUnitIds, + runtimeAliasUnitIds: new Set(runtimeAliasUnitIds), + }); return { selectedUnitIds, completeContainerUnitIds, @@ -846,6 +1015,43 @@ function createTargetSelection(options) { }; } +/** + * @param {import("./surface-inventory.mjs").SurfaceInventory} inventory + * @param {string[]} surfaces + */ +function resolveRuntimeAliasUnitIds(inventory, surfaces) { + return surfaces.flatMap(surface => { + const units = inventory.units.filter( + unit => unit.unitKind === "member" && unit.surfacePath === surface, + ); + if (!units.length) { + throw new Error(`Runtime alias surface is not modeled by the TypeScript lib inventory: ${surface}`); + } + return units.map(unit => unit.id); + }).sort(compareStringsCaseSensitive); +} + +/** + * @param {import("./surface-inventory.mjs").SurfaceInventory} inventory + * @param {string[]} surfaces + */ +function resolveCompilerSupportSurfaceUnitIds(inventory, surfaces) { + return surfaces.flatMap(surface => { + const memberUnits = inventory.units.filter( + unit => unit.unitKind === "member" && unit.surfacePath === surface, + ); + const units = memberUnits.length + ? memberUnits + : getPreferredDeclarationUnits(inventory, surface).filter( + unit => !RUNTIME_DECLARATION_KINDS.has(unit.declarationKind ?? ""), + ); + if (!units.length) { + throw new Error(`Compiler support surface is not modeled by the TypeScript lib inventory: ${surface}`); + } + return units.map(unit => unit.id); + }).sort(compareStringsCaseSensitive); +} + /** * @param {string} baselineDate * @param {number} firstYear @@ -876,7 +1082,11 @@ async function publishGenerationPlan(plan) { for (const previousOutputPath of previousOutputs) { if (!nextOutputs.has(previousOutputPath)) { - await rm(previousOutputPath, { force: true }); + await removeManagedPath( + previousOutputPath, + plan.repoRoot, + [path.join(plan.repoRoot, "generated", "current"), path.join(plan.repoRoot, ".tmp")], + ); } } @@ -891,7 +1101,12 @@ async function publishGenerationPlan(plan) { await mkdir(path.dirname(plan.topLevelOutputPath), { recursive: true }); await writeFile(plan.topLevelOutputPath, topLevelContents); - await rm(plan.allowOutputDirectory, { recursive: true, force: true }); + await removeManagedPath( + plan.allowOutputDirectory, + plan.repoRoot, + [path.join(plan.repoRoot, "generated", "current", "allow"), path.join(plan.repoRoot, ".tmp")], + { recursive: true }, + ); /** @type {Map} */ const supportArtifactsByEntryName = new Map(); for (const artifact of plan.allowSupportArtifacts) { @@ -940,7 +1155,12 @@ async function publishGenerationPlan(plan) { await writeFile(entry.outputPath, contents); } - await rm(plan.yearOutputDirectory, { recursive: true, force: true }); + await removeManagedPath( + plan.yearOutputDirectory, + plan.repoRoot, + [path.join(plan.repoRoot, "generated", "current", "year"), path.join(plan.repoRoot, ".tmp")], + { recursive: true }, + ); for (const entry of plan.yearEntries) { const contents = emitSelectedUnits({ inventory: plan.inventory, @@ -1469,15 +1689,22 @@ async function readPreviousOutputEntries(generationOutputPath, repoRoot) { continue; } previousOutputEntries.push( - path.isAbsolute(entry.outputPath) - ? entry.outputPath - : path.resolve(repoRoot, entry.outputPath), + resolveManagedOutputPath( + entry.outputPath, + repoRoot, + generationOutputPath, + "outputEntries[].outputPath", + [path.join(repoRoot, "generated", "current"), path.join(repoRoot, ".tmp")], + ), ); } return previousOutputEntries; } - catch { - return []; + catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return []; + } + throw error; } } diff --git a/lib/manifest-snapshot.mjs b/lib/manifest-snapshot.mjs index 3dfd909..c60a574 100644 --- a/lib/manifest-snapshot.mjs +++ b/lib/manifest-snapshot.mjs @@ -62,10 +62,10 @@ export async function refreshManifestSnapshot(options) { manifest.dataset = toPosixRelativePath(manifestDirectory, datasetPath); if (updateOutputPaths) { - manifest.classificationOutput = toPosixRelativePath(manifestDirectory, path.join(derivedDirectory, "classification.json")); - manifest.compatManagementOutput = toPosixRelativePath(manifestDirectory, path.join(derivedDirectory, "compat-management-report.json")); - manifest.inventoryOutput = toPosixRelativePath(manifestDirectory, path.join(derivedDirectory, "inventory.json")); - manifest.generationOutput = toPosixRelativePath(manifestDirectory, path.join(derivedDirectory, "generation.json")); + manifest.classificationOutput = toPosixRelativePath(repoRoot, path.join(derivedDirectory, "classification.json")); + manifest.compatManagementOutput = toPosixRelativePath(repoRoot, path.join(derivedDirectory, "compat-management-report.json")); + manifest.inventoryOutput = toPosixRelativePath(repoRoot, path.join(derivedDirectory, "inventory.json")); + manifest.generationOutput = toPosixRelativePath(repoRoot, path.join(derivedDirectory, "generation.json")); } return { diff --git a/lib/negative-probes.mjs b/lib/negative-probes.mjs index 1759ed8..488ee5f 100644 --- a/lib/negative-probes.mjs +++ b/lib/negative-probes.mjs @@ -104,6 +104,12 @@ export const LOW_NEGATIVE_PROBE_CANDIDATES = [ errorPattern: /Float16Array/, absencePattern: /Float16Array/, }, + { + compatKey: "javascript.builtins.Iterator.from", + sourceText: "Iterator.from([1, 2, 3]);", + errorPattern: /Iterator/, + absencePattern: /declare abstract class Iterator/, + }, { compatKey: "javascript.builtins.Error.isError", sourceText: "Error.isError(new Error(\"probe\"));", diff --git a/lib/shared.mjs b/lib/shared.mjs index c8f8f85..d4869c8 100644 --- a/lib/shared.mjs +++ b/lib/shared.mjs @@ -1,6 +1,7 @@ // @ts-check import path from "node:path"; +import { lstat, rm } from "node:fs/promises"; /** * @param {string} left @@ -21,16 +22,23 @@ export function formatPathForReport(repoRoot, filePath) { /** * @param {string | undefined} relativePath + * @param {string} repoRoot * @param {string} manifestPath - * @param {string} defaultFileName + * @param {string} propertyName + * @param {string[]} allowedRoots */ -export function resolveOutputPath(relativePath, manifestPath, defaultFileName) { - const manifestDirectory = path.dirname(manifestPath); - if (relativePath) { - return path.resolve(manifestDirectory, relativePath); +export function resolveManagedOutputPath(relativePath, repoRoot, manifestPath, propertyName, allowedRoots) { + if (!relativePath) { + throw new Error(`Manifest ${manifestPath} is missing ${propertyName}`); } - - return path.resolve(manifestDirectory, "..", "derived", "current", defaultFileName); + if (path.isAbsolute(relativePath) || relativePath.split(/[\\/]/u).includes("..")) { + throw new Error(`Manifest ${propertyName} must be a repo-relative path without '..': ${relativePath}`); + } + const outputPath = path.resolve(repoRoot, relativePath); + if (!allowedRoots.some(allowedRoot => isPathWithin(allowedRoot, outputPath))) { + throw new Error(`Manifest ${propertyName} is outside its managed output root: ${relativePath}`); + } + return outputPath; } /** @@ -44,3 +52,46 @@ export function requireRelativeManifestPath(relativePath, manifestPath, property } return path.resolve(path.dirname(manifestPath), relativePath); } + +/** + * @param {string} parentPath + * @param {string} childPath + */ +export function isPathWithin(parentPath, childPath) { + const relativePath = path.relative(path.resolve(parentPath), path.resolve(childPath)); + return relativePath === "" || (!path.isAbsolute(relativePath) && relativePath !== ".." && !relativePath.startsWith(`..${path.sep}`)); +} + +/** + * @param {string} targetPath + * @param {string} boundaryRoot + * @param {string[]} allowedRoots + * @param {{ recursive?: boolean; }} [options] + */ +export async function removeManagedPath(targetPath, boundaryRoot, allowedRoots, options = {}) { + if (!allowedRoots.some(allowedRoot => isPathWithin(allowedRoot, targetPath))) { + throw new Error(`Refusing to remove path outside managed roots: ${targetPath}`); + } + const relativePath = path.relative(path.resolve(boundaryRoot), path.resolve(targetPath)); + if (path.isAbsolute(relativePath) || relativePath === ".." || relativePath.startsWith(`..${path.sep}`)) { + throw new Error(`Refusing to remove path outside boundary root: ${targetPath}`); + } + let currentPath = path.resolve(boundaryRoot); + for (const segment of relativePath.split(path.sep).filter(Boolean)) { + currentPath = path.join(currentPath, segment); + let stats; + try { + stats = await lstat(currentPath); + } + catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + break; + } + throw error; + } + if (stats.isSymbolicLink()) { + throw new Error(`Refusing to remove through symbolic link: ${currentPath}`); + } + } + await rm(targetPath, { recursive: options.recursive ?? false, force: true }); +} diff --git a/lib/surface-inventory.mjs b/lib/surface-inventory.mjs index 3ff2582..ce810d2 100644 --- a/lib/surface-inventory.mjs +++ b/lib/surface-inventory.mjs @@ -24,7 +24,6 @@ const READONLY_COMPANION_BY_RUNTIME_ROOT = new Map([ ]); const GENERATED_LIB_HEADER = [ - "/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */", "// ", "// Generated by TypeScript Baseline Lib Generator from TypeScript lib declaration files.", "// Source declarations are derived from the npm `typescript` package.", @@ -117,13 +116,14 @@ export async function createSurfaceInventory(options) { /*setParentNodes*/ true, ts.ScriptKind.TS, ); + assertGlobalLibScope(sourceFile, sourceLibEntry.libFileName); const fileRecord = { sourceFileName: sourceLibEntry.sourceFileName, libFileName: sourceLibEntry.libFileName, sourcePath: sourceLibEntry.sourcePath, reportPath: sourceLibEntry.reportPath, sourceHash: sourceLibEntry.sourceHash, - referenceLibs: getReferenceLibs(sourceText), + referenceLibs: getReferenceLibs(sourceFile), preserveWholeFile: shouldPreserveWholeFile(sourceFile, sourceText), stageRank: getLibStageRank(sourceLibEntry.libFileName), sourceText, @@ -347,6 +347,7 @@ export async function createSurfaceInventory(options) { * unitTextOverrides?: Map; * completeContainerUnitIds?: Set; * excludedUnitIds?: Set; + * emittedUnitIds?: Set; * }} options */ export function emitSelectedUnits(options) { @@ -356,6 +357,7 @@ export function emitSelectedUnits(options) { unitTextOverrides = new Map(), completeContainerUnitIds = new Set(), excludedUnitIds = new Set(), + emittedUnitIds, } = options; const selectedSet = new Set(selectedUnitIds); /** @type {string[]} */ @@ -370,6 +372,7 @@ export function emitSelectedUnits(options) { unitTextOverrides, completeContainerUnitIds, excludedUnitIds, + emittedUnitIds, ); if (fileSection) { (fileRecord.preserveWholeFile ? moduleSections : globalSections).push(fileSection); @@ -733,6 +736,12 @@ function collectStatementUnits(options) { } if (ts.isVariableStatement(statement)) { + if (statement.declarationList.declarations.length !== 1) { + throw new Error( + `${fileRecord.libFileName} contains a variable statement with multiple declarators. ` + + "Each runtime global must have its own inventory unit.", + ); + } const declaration = statement.declarationList.declarations[0]; if (declaration?.type && ts.isTypeLiteralNode(declaration.type) && ts.isIdentifier(declaration.name)) { const symbolName = formatSymbolName(containerPath, declaration.name.text); @@ -1067,9 +1076,10 @@ function registerRootSurface(rootSurfaceByCompatName, declarationUnit) { * @param {Map} unitTextOverrides * @param {Set} completeContainerUnitIds * @param {Set} excludedUnitIds + * @param {Set | undefined} emittedUnitIds * @returns {string | undefined} */ -function emitFileSelection(inventory, libFileName, selectedUnitIds, unitTextOverrides, completeContainerUnitIds, excludedUnitIds) { +function emitFileSelection(inventory, libFileName, selectedUnitIds, unitTextOverrides, completeContainerUnitIds, excludedUnitIds, emittedUnitIds) { const fileRecord = inventory.fileByLibFileName.get(libFileName); if (!fileRecord) { return undefined; @@ -1094,6 +1104,9 @@ function emitFileSelection(inventory, libFileName, selectedUnitIds, unitTextOver .join("\n")}`, ); } + for (const unit of fileUnits) { + emittedUnitIds?.add(unit.id); + } return [ `/////////////////////////////`, `// ${libFileName}`, @@ -1107,7 +1120,7 @@ function emitFileSelection(inventory, libFileName, selectedUnitIds, unitTextOver const sections = []; for (const unit of topLevelUnits) { - const emitted = emitUnit(inventory, unit.id, selectedUnitIds, unitTextOverrides, completeContainerUnitIds, excludedUnitIds); + const emitted = emitUnit(inventory, unit.id, selectedUnitIds, unitTextOverrides, completeContainerUnitIds, excludedUnitIds, emittedUnitIds); if (emitted) { sections.push(emitted.trimEnd()); } @@ -1132,10 +1145,11 @@ function emitFileSelection(inventory, libFileName, selectedUnitIds, unitTextOver * @param {Map} unitTextOverrides * @param {Set} completeContainerUnitIds * @param {Set} excludedUnitIds + * @param {Set | undefined} emittedUnitIds * @param {boolean} [forceEmit] * @returns {string | undefined} */ -function emitUnit(inventory, unitId, selectedUnitIds, unitTextOverrides, completeContainerUnitIds, excludedUnitIds, forceEmit = false) { +function emitUnit(inventory, unitId, selectedUnitIds, unitTextOverrides, completeContainerUnitIds, excludedUnitIds, emittedUnitIds, forceEmit = false) { // Never emit an excluded unit, even through complete-container forceEmit. // The generator's invariant already rejects any intersection with the // selection set, so this acts as a mechanical last-resort guard. @@ -1159,6 +1173,7 @@ function emitUnit(inventory, unitId, selectedUnitIds, unitTextOverrides, complet unitTextOverrides, completeContainerUnitIds, excludedUnitIds, + emittedUnitIds, forceChildren, ); if (childEmission) { @@ -1177,6 +1192,7 @@ function emitUnit(inventory, unitId, selectedUnitIds, unitTextOverrides, complet if (!container) { throw new Error(`Missing container ${unit.containerId} for unit ${unit.id}`); } + emittedUnitIds?.add(unit.id); if (!selectedChildEmissions.length) { return `${container.headerText}\n${container.footerText}`.trimEnd(); } @@ -1184,9 +1200,11 @@ function emitUnit(inventory, unitId, selectedUnitIds, unitTextOverrides, complet } if (unit.unitKind === "declaration" && !unit.containerId) { + emittedUnitIds?.add(unit.id); return stripSourceFilePreamble(unitTextOverrides.get(unit.id) ?? unit.text).trimEnd(); } + emittedUnitIds?.add(unit.id); return (unitTextOverrides.get(unit.id) ?? unit.text).trimEnd(); } @@ -1475,25 +1493,31 @@ function getTypeParameterNames(node) { } /** - * @param {string} sourceText + * @param {ts.SourceFile} sourceFile */ -function getReferenceLibs(sourceText) { - const referenceLibs = []; - const pattern = /^\/\/\/\s*/gmu; - let match = pattern.exec(sourceText); - while (match) { - referenceLibs.push(match[1]); - match = pattern.exec(sourceText); - } - return referenceLibs; +function getReferenceLibs(sourceFile) { + return sourceFile.libReferenceDirectives.map(directive => directive.fileName); } /** * @param {string} sourceText */ function stripReferenceDirectives(sourceText) { - return sourceText - .replace(/^\/\/\/\s*\r?\n/gmu, "") + const sourceFile = ts.createSourceFile( + "lib.d.ts", + sourceText, + ts.ScriptTarget.Latest, + /*setParentNodes*/ false, + ts.ScriptKind.TS, + ); + let stripped = sourceText; + for (const directive of [...sourceFile.libReferenceDirectives].reverse()) { + const lineStart = stripped.lastIndexOf("\n", directive.pos - 1) + 1; + const nextLineStart = stripped.indexOf("\n", directive.end); + const lineEnd = nextLineStart === -1 ? stripped.length : nextLineStart + 1; + stripped = stripped.slice(0, lineStart) + stripped.slice(lineEnd); + } + return stripped .replace(/^\s+$/gmu, "") .trim(); } @@ -1518,16 +1542,68 @@ function stripSourceFilePreamble(sourceText) { */ function shouldPreserveWholeFile(sourceFile, sourceText) { const hasTopLevelModuleSyntax = sourceFile.statements.some(statement => - ts.isImportDeclaration(statement) - || ts.isImportEqualsDeclaration(statement) - || ts.isExportDeclaration(statement) - || ts.isExportAssignment(statement), + ts.isExportDeclaration(statement), ); return hasTopLevelModuleSyntax - || /^\s*export\s*\{\s*\}\s*;/mu.test(sourceText) || /\bdeclare\s+global\b/u.test(sourceText); } +/** + * Built-in lib files may use an empty export marker to enable `declare global`, + * but the generated lib must never expose a module API. + * + * @param {ts.SourceFile} sourceFile + * @param {string} libFileName + */ +function assertGlobalLibScope(sourceFile, libFileName) { + if (sourceFile.referencedFiles.length) { + throw new Error(`${libFileName} contains a path reference; baseline lib inputs must be self-contained`); + } + if (sourceFile.typeReferenceDirectives.length) { + throw new Error(`${libFileName} contains a types reference; baseline lib inputs must be self-contained`); + } + if (sourceFile.amdDependencies.length || sourceFile.moduleName) { + throw new Error(`${libFileName} contains AMD metadata; baseline lib inputs must be self-contained`); + } + + /** @param {ts.Node} node */ + function rejectModuleDependencies(node) { + if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) { + throw new Error(`${libFileName} contains an ambient module; baseline lib inputs may only declare globals`); + } + if ( + ts.isImportDeclaration(node) + || ts.isImportEqualsDeclaration(node) + || ts.isImportTypeNode(node) + ) { + throw new Error(`${libFileName} contains a module import; baseline lib inputs must be self-contained`); + } + ts.forEachChild(node, rejectModuleDependencies); + } + + for (const statement of sourceFile.statements) { + if ( + ts.isExportDeclaration(statement) + && !statement.moduleSpecifier + && statement.exportClause + && ts.isNamedExports(statement.exportClause) + && statement.exportClause.elements.length === 0 + ) { + continue; + } + if ( + ts.isExportDeclaration(statement) + || ts.isExportAssignment(statement) + || ts.isNamespaceExportDeclaration(statement) + || (ts.canHaveModifiers(statement) + && ts.getModifiers(statement)?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) + ) { + throw new Error(`${libFileName} exposes a module export; baseline lib inputs may only use an empty export marker`); + } + } + rejectModuleDependencies(sourceFile); +} + /** * @param {SurfaceInventory} inventory * @param {InventoryUnitRecord[]} units diff --git a/lib/typescript-upstream.mjs b/lib/typescript-upstream.mjs index 71a0598..690057f 100644 --- a/lib/typescript-upstream.mjs +++ b/lib/typescript-upstream.mjs @@ -15,6 +15,8 @@ const commandLineParserEntry = ' ["baseline", "lib.baseline.d.ts"],'; const commandLineParserAnchor = ' ["esnext", "lib.esnext.d.ts"],'; const libsJsonEntry = ' "baseline",'; const libsJsonAnchor = ' "esnext",'; +const eslintArrayTypeEntry = ' files: ["src/lib/es2019.array.d.ts", "src/lib/baseline.d.ts"],'; +const eslintArrayTypeAnchor = ' files: ["src/lib/es2019.array.d.ts"],'; /** * When expectedCommit is passed, verify the target clone's HEAD matches the pin @@ -44,10 +46,12 @@ export function prepareTypeScriptBaselinePatch(options) { const targetLibPath = path.join(typescriptDir, "src", "lib", "baseline.d.ts"); const commandLineParserPath = path.join(typescriptDir, "src", "compiler", "commandLineParser.ts"); const libsJsonPath = path.join(typescriptDir, "src", "lib", "libs.json"); + const eslintConfigPath = path.join(typescriptDir, "eslint.config.mjs"); assertFileExists(generatedLibPath, "generated baseline lib"); assertFileExists(commandLineParserPath, "TypeScript commandLineParser.ts"); assertFileExists(libsJsonPath, "TypeScript libs.json"); + assertFileExists(eslintConfigPath, "TypeScript eslint.config.mjs"); assertDirectoryExists(sourceTestsRoot, "TypeScript fixture tests root"); const copiedGeneratedLib = copyFileIfChanged(generatedLibPath, targetLibPath); @@ -63,6 +67,12 @@ export function prepareTypeScriptBaselinePatch(options) { insertion: `${libsJsonAnchor}\n${libsJsonEntry}`, description: "libs.json lib entry", }); + const patchedEslintConfig = ensurePatchedTextFile(eslintConfigPath, { + alreadyPresentMarker: eslintArrayTypeEntry, + anchor: eslintArrayTypeAnchor, + insertion: eslintArrayTypeEntry, + description: "baseline lib array-type exemption", + }); // Track the full fixture list and the files actually rewritten this run separately. // On an idempotent re-run, reporting unchanged files as "copied" would be misleading. const fixtureFiles = copyDirectoryContents(sourceTestsRoot, path.join(typescriptDir, "tests")); @@ -78,11 +88,75 @@ export function prepareTypeScriptBaselinePatch(options) { copiedGeneratedLib, patchedCommandLineParser, patchedLibsJson, + patchedEslintConfig, fixtureFiles: fixtureFilePaths, changedFixtureFiles, }; } +/** + * @param {string} typescriptDir + * @param {string[]} allowedRelativePaths + */ +export function findUnexpectedTypeScriptPatchPaths(typescriptDir, allowedRelativePaths) { + const changedPaths = [ + ...execFileSync("git", ["diff", "--name-only", "HEAD", "--"], { + cwd: typescriptDir, + encoding: "utf8", + }).split(/\r?\n/u), + ...execFileSync("git", ["ls-files", "--others", "--exclude-standard"], { + cwd: typescriptDir, + encoding: "utf8", + }).split(/\r?\n/u), + ].filter(Boolean); + const allowedPaths = new Set(allowedRelativePaths.map(relativePath => relativePath.split(path.sep).join("/"))); + return [...new Set(changedPaths)] + .filter(relativePath => ( + !allowedPaths.has(relativePath) + && !isExpectedTypeScriptBaselineUpdate(typescriptDir, relativePath) + )) + .sort(); +} + +/** + * @param {string} typescriptDir + * @param {string} relativePath + */ +function isExpectedTypeScriptBaselineUpdate(typescriptDir, relativePath) { + if ( + !relativePath.startsWith("tests/baselines/reference/") + || !relativePath.endsWith(".js") + || !fs.existsSync(path.join(typescriptDir, relativePath)) + ) { + return false; + } + const previousText = execFileSync("git", ["show", `HEAD:${relativePath}`], { + cwd: typescriptDir, + encoding: "utf8", + }); + const nextText = fs.readFileSync(path.join(typescriptDir, relativePath), "utf8"); + return normalizeTypeScriptBaselineUpdate(previousText) === normalizeTypeScriptBaselineUpdate(nextText); +} + +/** + * @param {string} text + */ +function normalizeTypeScriptBaselineUpdate(text) { + return text.split(/\r?\n/u) + .map(line => { + if (line.includes("Argument for '--lib' option must be:")) { + return line.replace(", 'baseline'", ""); + } + if (line.includes("one or more: es5,")) { + return line.replace(", baseline", ""); + } + return line; + }) + .join("\n") + .replace(/Inode:: \d+/gu, "Inode:: ") + .replace(/"inode":\d+/gu, '"inode":'); +} + /** * @param {string} typescriptDir * @param {string} expectedCommit @@ -147,6 +221,7 @@ export function renderTypeScriptPatchSummary(summary) { `- Installed lib: \`${summary.targetGeneratedLibPath}\``, `- commandLineParser patched: ${formatBoolean(summary.patchedCommandLineParser.changed)}`, `- libs.json patched: ${formatBoolean(summary.patchedLibsJson.changed)}`, + `- eslint.config.mjs patched: ${formatBoolean(summary.patchedEslintConfig.changed)}`, `- Compiler fixture files: ${summary.fixtureFiles.length} total, ${summary.changedFixtureFiles.length} written this run`, "", "## Installed Files", diff --git a/lib/web-features-dataset.mjs b/lib/web-features-dataset.mjs index dd561fa..d6efee4 100644 --- a/lib/web-features-dataset.mjs +++ b/lib/web-features-dataset.mjs @@ -30,7 +30,7 @@ export async function buildWebFeaturesDataset(options) { const webFeaturesData = JSON.parse( await readFile(resolveInstalledPackageFile(options.repoRoot, packageName, "data.json"), "utf8"), ); - if (!webFeaturesData.features || typeof webFeaturesData.features !== "object") { + if (!webFeaturesData.features || typeof webFeaturesData.features !== "object" || Array.isArray(webFeaturesData.features)) { throw new Error(`${packageName} data.json is missing the features map`); } @@ -52,12 +52,6 @@ export async function buildWebFeaturesDataset(options) { continue; } - const compatFeatures = Array.isArray(feature.compat_features) - ? feature.compat_features.filter( - /** @param {unknown} compatKey */ - compatKey => typeof compatKey === "string" && compatKey.startsWith("javascript."), - ) - : []; const snapshot = Array.isArray(feature.snapshot) ? feature.snapshot.filter( /** @param {unknown} snapshotValue */ @@ -66,6 +60,32 @@ export async function buildWebFeaturesDataset(options) { : typeof feature.snapshot === "string" ? [feature.snapshot] : []; + if ( + feature.compat_features === undefined + && ( + snapshot.some( + /** @param {string} value */ + value => value.startsWith("ecmascript-"), + ) + || Object.keys(feature.status?.by_compat_key ?? {}).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 compatFeatures = rawCompatFeatures.filter( + /** @param {string} compatKey */ + compatKey => compatKey.startsWith("javascript."), + ); const isJavaScriptFeature = compatFeatures.length > 0 || snapshot.some( /** @param {string} snapshotValue */ snapshotValue => snapshotValue.startsWith("ecmascript-"), @@ -136,6 +156,26 @@ export async function buildWebFeaturesDataset(options) { }; } +/** + * @param {{ repoRoot: string; packageName?: string; dataset: any; }} options + */ +export async function verifyWebFeaturesDataset(options) { + const expected = await buildWebFeaturesDataset({ + repoRoot: options.repoRoot, + packageName: options.packageName, + snapshotDate: options.dataset.snapshot.baselineDate, + snapshotName: options.dataset.snapshot.name, + }); + const actual = { + snapshot: options.dataset.snapshot, + featureRows: options.dataset.featureRows, + compatRows: options.dataset.compatRows, + }; + if (JSON.stringify(expected) !== JSON.stringify(actual)) { + throw new Error("Checked-in dataset does not match the pinned web-features package extraction"); + } +} + /** * Compare datasets for content equality, ignoring the extraction date * (baselineDate / extractedDate). With a pinned web-features version and the diff --git a/manifests/baseline-js.json b/manifests/baseline-js.json index 89c7f1d..162dbf5 100644 --- a/manifests/baseline-js.json +++ b/manifests/baseline-js.json @@ -22,15 +22,15 @@ "dataset": "../datasets/web-features-js-compat.json", "compatManagementRegistry": "../registry/compat-management.json", "allowlistRegistry": "../registry/allowlist.json", - "classificationOutput": "../derived/current/classification.json", - "compatManagementOutput": "../derived/current/compat-management-report.json", - "inventoryOutput": "../derived/current/inventory.json", - "generationOutput": "../derived/current/generation.json", + "classificationOutput": "derived/current/classification.json", + "compatManagementOutput": "derived/current/compat-management-report.json", + "inventoryOutput": "derived/current/inventory.json", + "generationOutput": "derived/current/generation.json", "firstClassLib": { "libName": "baseline", - "outputFile": "../generated/current/baseline.d.ts", - "allowDirectory": "../generated/current/allow", - "yearDirectory": "../generated/current/year", + "outputFile": "generated/current/baseline.d.ts", + "allowDirectory": "generated/current/allow", + "yearDirectory": "generated/current/year", "firstYear": 2020 }, "libSource": { diff --git a/package.json b/package.json index c09816b..e87f3a6 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ }, "scripts": { "generate": "node scripts/generate.mjs", + "verify:dataset": "node scripts/verify-web-features-dataset.mjs", "check:typescript-peer-latest": "node scripts/check-typescript-peer-latest.mjs", "update:typescript-toolchain": "node scripts/update-typescript-toolchain.mjs", "update:web-features": "node scripts/update-web-features.mjs", @@ -44,10 +45,11 @@ "test:typescript-go": "node scripts/test-typescript-go-integration.mjs", "pack:baseline": "node deploy/createPackage.mjs", "pack:baseline:tarball": "node deploy/createPackageTarball.mjs", - "release:dry-run": "node deploy/deployChangedPackage.mjs --dry-run --notes-out .tmp/package-release-notes.md --summary-out .tmp/package-release-summary.json", - "release:publish": "node deploy/deployChangedPackage.mjs --notes-out .tmp/package-release-notes.md --summary-out .tmp/package-release-summary.json --github-release", + "release:prepare": "node deploy/prepareReleaseArtifact.mjs", + "release:dry-run": "node deploy/prepareReleaseArtifact.mjs --preview", + "release:publish": "node deploy/publishReleaseArtifact.mjs", "test": "node --test test/*.test.mjs", - "validate": "npm run lint && npm run validate:registry && npm run generate && npm test" + "validate": "npm run lint && npm run validate:registry && npm run verify:dataset && npm run generate && npm test" }, "dependencies": { "ajv": "8.17.1", diff --git a/registry/compat-management.json b/registry/compat-management.json index f73c836..97daddf 100644 --- a/registry/compat-management.json +++ b/registry/compat-management.json @@ -53,6 +53,111 @@ ] } }, + "compilerSupport": [ + { + "id": "language-protocol-interfaces", + "reason": "These erased global interfaces are referenced by TypeScript syntax and third-party declarations, but web-features has no runtime compatibility rows for them.", + "sourceUrls": [ + "https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/lib/es5.d.ts", + "https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/lib/es2015.iterable.d.ts", + "https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/lib/es2018.asynciterable.d.ts" + ], + "surfaces": [ + "AsyncIterable", + "AsyncIterableIterator", + "AsyncIterator", + "IterableIterator", + "Iterator", + "TemplateStringsArray" + ] + }, + { + "id": "generator-function-shapes", + "reason": "TypeScript models generator function object structure through erased interfaces while the checker does not expose matching runtime constructor values.", + "sourceUrls": [ + "https://github.com/microsoft/TypeScript/issues/45146", + "https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/lib/es2015.generator.d.ts", + "https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/lib/es2018.asyncgenerator.d.ts" + ], + "surfaces": [ + "AsyncGeneratorFunction", + "GeneratorFunction" + ] + } + ], + "runtimeAliases": [ + { + "id": "function-object-shape", + "reason": "Function.prototype is part of the standard Function object shape, but web-features does not provide a dedicated compatibility row for it.", + "sourceUrls": [ + "https://tc39.es/ecma262/multipage/fundamental-objects.html#sec-function-instances-prototype", + "https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/lib/es5.d.ts" + ], + "surfaces": [ + "Function.prototype" + ] + }, + { + "id": "error-stack", + "reason": "TypeScript exposes Error.stack and the Stage 3 Error Stack Accessor proposal documents the cross-runtime surface, but web-features does not yet provide a compatibility row.", + "sourceUrls": [ + "https://tc39.es/proposal-error-stack-accessor/", + "https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/lib/es5.d.ts" + ], + "surfaces": [ + "Error.stack" + ] + }, + { + "id": "inherited-valueof-refinements", + "reason": "TypeScript refines Object.prototype.valueOf on typed-array interfaces, but these declarations do not represent separate runtime methods and web-features has no per-typed-array rows for them.", + "sourceUrls": [ + "https://tc39.es/ecma262/multipage/fundamental-objects.html#sec-object.prototype.valueof", + "https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/lib/es5.d.ts", + "https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/lib/es2020.bigint.d.ts" + ], + "surfaces": [ + "BigInt64Array.valueOf", + "BigUint64Array.valueOf", + "Float16Array.valueOf", + "Float32Array.valueOf", + "Float64Array.valueOf", + "Int16Array.valueOf", + "Int32Array.valueOf", + "Int8Array.valueOf", + "Uint16Array.valueOf", + "Uint32Array.valueOf", + "Uint8Array.valueOf", + "Uint8ClampedArray.valueOf" + ] + }, + { + "id": "well-known-symbol-tags", + "reason": "The ECMAScript specifications define these Symbol.toStringTag properties on otherwise classified runtime roots, but web-features has no dedicated compatibility rows for them.", + "sourceUrls": [ + "https://tc39.es/ecma262/multipage/fundamental-objects.html#sec-symbol.tostringtag", + "https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/lib/es2015.symbol.wellknown.d.ts", + "https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/lib/es2017.sharedmemory.d.ts", + "https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/lib/es2020.bigint.d.ts", + "https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/lib/es2021.weakref.d.ts" + ], + "surfaces": [ + "Atomics.@@toStringTag", + "BigInt.@@toStringTag", + "BigInt64Array.@@toStringTag", + "BigUint64Array.@@toStringTag", + "FinalizationRegistry.@@toStringTag", + "Float16Array.@@toStringTag", + "IteratorObject.@@toStringTag", + "Map.@@toStringTag", + "Set.@@toStringTag", + "SharedArrayBuffer.@@toStringTag", + "WeakMap.@@toStringTag", + "WeakRef.@@toStringTag", + "WeakSet.@@toStringTag" + ] + } + ], "groups": [ { "id": "generator-function-partial-model", diff --git a/registry/compat-management.schema.json b/registry/compat-management.schema.json index 1b004ff..56651fe 100644 --- a/registry/compat-management.schema.json +++ b/registry/compat-management.schema.json @@ -6,6 +6,8 @@ "required": [ "kind", "schemaVersion", + "compilerSupport", + "runtimeAliases", "groups" ], "additionalProperties": false, @@ -29,6 +31,18 @@ "$ref": "#/$defs/declarationMapping" } }, + "compilerSupport": { + "type": "array", + "items": { + "$ref": "#/$defs/supportGroup" + } + }, + "runtimeAliases": { + "type": "array", + "items": { + "$ref": "#/$defs/supportGroup" + } + }, "groups": { "type": "array", "items": { @@ -37,6 +51,43 @@ } }, "$defs": { + "supportGroup": { + "type": "object", + "required": [ + "id", + "reason", + "sourceUrls", + "surfaces" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + }, + "sourceUrls": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + } + } + }, "declarationMapping": { "type": "object", "required": [ diff --git a/scripts/test-typescript-integration.mjs b/scripts/test-typescript-integration.mjs index bbbad6c..6af3fe8 100644 --- a/scripts/test-typescript-integration.mjs +++ b/scripts/test-typescript-integration.mjs @@ -11,6 +11,7 @@ import { selectActiveNegativeProbes, } from "../lib/negative-probes.mjs"; import { + findUnexpectedTypeScriptPatchPaths, prepareTypeScriptBaselinePatch, renderTypeScriptPatchSummary, } from "../lib/typescript-upstream.mjs"; @@ -25,6 +26,17 @@ const defaultSummaryPath = path.join(repoRoot, ".tmp", "typescript-integration-s const defaultDiffPath = path.join(repoRoot, ".tmp", "typescript-baseline-changes.diff"); const defaultFocusedBaselinesDirectory = path.join(repoRoot, ".tmp", "typescript-focused-artifact"); const defaultLocalBaselinesDirectory = path.join(repoRoot, ".tmp", "typescript-raw-local-baselines"); +const TYPESCRIPT_PROPOSAL_PATHS = [ + "eslint.config.mjs", + path.join("src", "compiler", "commandLineParser.ts"), + path.join("src", "lib", "baseline.d.ts"), + path.join("src", "lib", "libs.json"), + path.join("tests", "cases", "compiler", "libBaseline.ts"), + path.join("tests", "baselines", "reference", "libBaseline.errors.txt"), + path.join("tests", "baselines", "reference", "libBaseline.js"), + path.join("tests", "baselines", "reference", "libBaseline.symbols"), + path.join("tests", "baselines", "reference", "libBaseline.types"), +]; const args = parseArgs(process.argv.slice(2)); @@ -44,7 +56,8 @@ async function main() { let smokeResults; /** @type {{ * targetedHarness?: { ok: boolean; output: string; }; - * fullSuite?: { ok: boolean; output: string; }; + * fullSuiteBeforeBaselineAccept?: { ok: boolean; output: string; }; + * fullSuiteAfterBaselineAccept?: { ok: boolean; output: string; }; * baselineAccept?: { ok: boolean; output: string; }; * baselineDiffPath?: string; * focusedBaselinesPath?: string; @@ -67,8 +80,8 @@ async function main() { // gate: blocking checks only (smoke + targeted harness). Meant to run // per PR, so it excludes the TypeScript full-suite diagnostics. - // full: on top of gate, runs the full suite / baseline-accept / raw - // baselines as diagnostics. For push to main, schedule, and dispatch. + // full: on top of gate, accepts the generated baselines and requires + // the complete TypeScript suite to pass on the accepted state. if (args.mode === "gate" || args.mode === "full") { runNpm(patchSummary.typescriptDir, ["run", "build:tests"]); extendedResults.targetedHarness = runCommandAllowFailure( @@ -89,7 +102,7 @@ async function main() { } if (args.mode === "full") { - extendedResults.fullSuite = runCommandAllowFailure( + extendedResults.fullSuiteBeforeBaselineAccept = runCommandAllowFailure( "npm", ["test"], { cwd: patchSummary.typescriptDir }, @@ -103,6 +116,36 @@ async function main() { ["hereby", "baseline-accept"], { cwd: patchSummary.typescriptDir }, ); + if (!extendedResults.baselineAccept.ok) { + blockingFailure ??= new Error( + extendedResults.baselineAccept.output.trim() || "TypeScript baseline-accept failed", + ); + } + else { + const unexpectedPaths = findUnexpectedTypeScriptPatchPaths( + patchSummary.typescriptDir, + TYPESCRIPT_PROPOSAL_PATHS, + ); + if (unexpectedPaths.length) { + blockingFailure ??= new Error( + `TypeScript baseline-accept changed files outside the proposal surface:\n` + + unexpectedPaths.map(relativePath => `- ${relativePath}`).join("\n"), + ); + } + else { + extendedResults.fullSuiteAfterBaselineAccept = runCommandAllowFailure( + "npm", + ["test"], + { cwd: patchSummary.typescriptDir }, + ); + if (!extendedResults.fullSuiteAfterBaselineAccept.ok) { + blockingFailure ??= new Error( + extendedResults.fullSuiteAfterBaselineAccept.output.trim() + || "TypeScript full suite failed after baseline-accept", + ); + } + } + } if (args.baselineDiffOut) { fs.mkdirSync(path.dirname(args.baselineDiffOut), { recursive: true }); const diffText = extendedResults.baselineAccept.ok @@ -265,7 +308,8 @@ function writeSmokeTsconfig(smokeRoot) { * smokeResults: ReturnType | undefined; * extendedResults: { * targetedHarness?: { ok: boolean; output: string; }; - * fullSuite?: { ok: boolean; output: string; }; + * fullSuiteBeforeBaselineAccept?: { ok: boolean; output: string; }; + * fullSuiteAfterBaselineAccept?: { ok: boolean; output: string; }; * baselineAccept?: { ok: boolean; output: string; }; * baselineDiffPath?: string; * focusedBaselinesPath?: string; @@ -304,8 +348,9 @@ function renderIntegrationSummary(options) { if (options.mode === "full") { lines.push( - `- Full TypeScript suite: ${formatDiagnosticResult(options.extendedResults.fullSuite)}`, - `- Baseline accept: ${formatDiagnosticResult(options.extendedResults.baselineAccept)}`, + `- Initial full TypeScript suite: ${formatInitialSuiteResult(options.extendedResults.fullSuiteBeforeBaselineAccept)}`, + `- Baseline accept: ${formatBlockingResult(options.extendedResults.baselineAccept)}`, + `- Post-accept full TypeScript suite: ${formatBlockingResult(options.extendedResults.fullSuiteAfterBaselineAccept)}`, ); if (options.extendedResults.localBaselinesPath) { lines.push(`- Raw local baselines artifact: \`${options.extendedResults.localBaselinesPath}\``); @@ -339,8 +384,9 @@ function renderIntegrationSummary(options) { "## Diagnostics", "", `- Targeted harness output: ${summarizeResult(options.extendedResults.targetedHarness)}`, - `- Full suite output: ${summarizeResult(options.extendedResults.fullSuite)}`, + `- Initial full suite output: ${summarizeResult(options.extendedResults.fullSuiteBeforeBaselineAccept)}`, `- Baseline accept output: ${summarizeResult(options.extendedResults.baselineAccept)}`, + `- Post-accept full suite output: ${summarizeResult(options.extendedResults.fullSuiteAfterBaselineAccept)}`, ); } @@ -392,11 +438,11 @@ function formatBlockingResult(result) { /** * @param {{ ok: boolean; output: string; } | undefined} result */ -function formatDiagnosticResult(result) { +function formatInitialSuiteResult(result) { if (!result) { return "not run"; } - return result.ok ? "passed" : "failed (diagnostic)"; + return result.ok ? "passed (pre-accept)" : "failed (pre-accept baselines captured)"; } /** @@ -494,7 +540,7 @@ function printUsageAndExit() { Modes: smoke only --lib baseline smoke with the built local tsc (blocking) gate smoke + targeted libBaseline harness (blocking only, for PRs) - full gate + TypeScript full suite / baseline-accept (diagnostic) + full gate + baseline-accept + a blocking post-accept TypeScript full suite Examples: node scripts/test-typescript-integration.mjs --typescript-dir ../TypeScript @@ -578,20 +624,8 @@ function copyFocusedBaselinesArtifact(options) { fs.rmSync(options.outputDirectory, { recursive: true, force: true }); // Keep a reviewer-sized snapshot of the proposal-specific patch surface. fs.mkdirSync(options.outputDirectory, { recursive: true }); - /** @type {string[]} */ - const focusedRelativePaths = [ - path.join("src", "compiler", "commandLineParser.ts"), - path.join("src", "lib", "baseline.d.ts"), - path.join("src", "lib", "libs.json"), - path.join("tests", "cases", "compiler", "libBaseline.ts"), - path.join("tests", "baselines", "reference", "libBaseline.errors.txt"), - path.join("tests", "baselines", "reference", "libBaseline.js"), - path.join("tests", "baselines", "reference", "libBaseline.symbols"), - path.join("tests", "baselines", "reference", "libBaseline.types"), - ]; - let copiedFileCount = 0; - for (const relativePath of focusedRelativePaths) { + for (const relativePath of TYPESCRIPT_PROPOSAL_PATHS) { const sourcePath = path.join(options.typescriptDir, relativePath); if (!fs.existsSync(sourcePath)) { continue; @@ -620,8 +654,7 @@ function renderUnavailableDiffArtifact(options) { return [ "# Accepted Diff Unavailable", "", - "The full TypeScript suite is diagnostic-only in this workflow.", - "The suite finished, but `hereby baseline-accept` did not succeed, so a post-accept `git diff` artifact could not be produced.", + "`hereby baseline-accept` did not succeed, so the blocking post-accept suite and accepted diff could not be produced.", "", `- Focused integration artifact: ${options.focusedBaselinesPath ?? "not written"}`, `- Raw local baselines artifact: ${options.localBaselinesPath ?? "not written"}`, diff --git a/scripts/verify-web-features-dataset.mjs b/scripts/verify-web-features-dataset.mjs new file mode 100644 index 0000000..e00bbc6 --- /dev/null +++ b/scripts/verify-web-features-dataset.mjs @@ -0,0 +1,28 @@ +// @ts-check + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadBaselineDataset } 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( + datasetPath, + manifest.snapshot.name, + manifest.snapshot.baselineDate, + manifest.snapshot.webFeaturesPackageVersion, +); + +await verifyWebFeaturesDataset({ + repoRoot, + packageName: manifest.toolchain.webFeaturesPackage, + dataset, +}); + +console.log(`Verified ${datasetPath} against ${manifest.toolchain.webFeaturesPackage}@${manifest.snapshot.webFeaturesPackageVersion}`); diff --git a/test/allowlist.test.mjs b/test/allowlist.test.mjs index 0db0af3..2ac5fa5 100644 --- a/test/allowlist.test.mjs +++ b/test/allowlist.test.mjs @@ -18,6 +18,7 @@ import { readJsonFile, repoAllowlistRegistryPath, repoDatasetPath, + repoRoot, runGenerate, runGenerateExpectFailure, runNpm, @@ -477,7 +478,10 @@ test("a registered path becomes a permanent baseline alias after promotion", () assert.deepEqual(promotedGeneration.allowEntries[0], { kind: "alias", entryName: "promise-withresolvers", - outputPath: path.join(fixture.outputRoot, "generated", "allow", "promise-withresolvers", "index.d.ts"), + outputPath: path.relative( + repoRoot, + path.join(fixture.outputRoot, "generated", "allow", "promise-withresolvers", "index.d.ts"), + ).replaceAll(path.sep, "/"), compatKeys: [compatKey], unitIds: [], supportUnitIds: [], diff --git a/test/classifier.test.mjs b/test/classifier.test.mjs index c015808..d66c00d 100644 --- a/test/classifier.test.mjs +++ b/test/classifier.test.mjs @@ -196,6 +196,8 @@ async function classifyFixture(options) { kind: "typescript-baseline-lib/compat-management-registry", schemaVersion: 1, ...(options.declarationMappings ? { declarationMappings: options.declarationMappings } : {}), + compilerSupport: [], + runtimeAliases: [], groups: options.registryGroups ?? [], }); @@ -205,8 +207,8 @@ async function classifyFixture(options) { ...(options.baselineTarget ? { baselineTarget: options.baselineTarget } : {}), dataset: "dataset.json", compatManagementRegistry: "registry.json", - classificationOutput: "out/classification.json", - compatManagementOutput: "out/compat-management-report.json", + classificationOutput: "derived/current/classification.json", + compatManagementOutput: "derived/current/compat-management-report.json", }; writeJsonFile(manifestPath, manifest); diff --git a/test/compat-management-schema.test.mjs b/test/compat-management-schema.test.mjs index 3a61376..31d8643 100644 --- a/test/compat-management-schema.test.mjs +++ b/test/compat-management-schema.test.mjs @@ -25,12 +25,27 @@ test("compat-management registry accepts the canonical registry", async () => { assert.equal(registry.kind, "typescript-baseline-lib/compat-management-registry"); assert.ok(registry.groups.length > 0); assert.ok(registry.entries.length > 0); + assert.ok(registry.compilerSupportSurfaces.includes("IterableIterator")); + assert.ok(registry.runtimeAliasSurfaces.includes("Function.prototype")); assert.deepEqual( registry.entryByCompatKey.get("javascript.builtins.RegExp.input")?.declarationMapping, { scope: "static", memberNames: ["input", "$_"] }, ); }); +test("compat-management registry keeps compiler support and runtime aliases disjoint", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + const registryPath = path.join(tempDirectory, "compat-management.overlap.json"); + const registry = readJsonFile(repoRegistryPath); + registry.runtimeAliases[0].surfaces.push(registry.compilerSupport[0].surfaces[0]); + writeJsonFile(registryPath, registry); + + await assert.rejects( + () => loadCompatManagementRegistry(registryPath), + /both compiler support and runtime aliases/u, + ); +}); + test("compat-management registry validates declaration mappings", async () => { const tempDirectory = createTempDirectory(tempDirectories); const registry = readJsonFile(repoRegistryPath); diff --git a/test/excluded-units.test.mjs b/test/excluded-units.test.mjs index 5f545d9..fd7d410 100644 --- a/test/excluded-units.test.mjs +++ b/test/excluded-units.test.mjs @@ -10,6 +10,7 @@ import { } from "./helpers.mjs"; import { assertExclusionInvariants, + assertRuntimeDeclarationProvenance, resolveExcludedUnits, resolveUnclaimedTypeOnlyUnitIds, } from "../lib/generator.mjs"; @@ -254,3 +255,166 @@ test("assertExclusionInvariants rejects selections that intersect excluded units excludedRowsByUnitId: new Map([[badMember.id, ["javascript.builtins.Widget.bad"]]]), }); }); + +test("runtime provenance rejects unclaimed members and accepts explicit compiler support", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + const inventory = await createFixtureInventory(tempDirectory, { + "lib.es5.d.ts": [ + "interface Widget {", + " supported(): void;", + " unknown(): void;", + "}", + "interface WidgetConstructor {", + " new(): Widget;", + "}", + "declare var Widget: WidgetConstructor;", + "", + ].join("\n"), + }); + const widgetDeclaration = (inventory.declarationUnitsBySymbol.get("Widget") ?? []) + .find(unit => unit.declarationKind === "interface"); + const widgetValue = (inventory.declarationUnitsBySymbol.get("Widget") ?? []) + .find(unit => unit.declarationKind === "var"); + const supportedMember = requireMemberUnit(inventory, "Widget::supported"); + const unknownMember = requireMemberUnit(inventory, "Widget::unknown"); + assert.ok(widgetDeclaration && widgetValue); + const selectedUnitIds = [widgetDeclaration.id, widgetValue.id, supportedMember.id, unknownMember.id]; + const classifiedCompatRows = [ + { + compatKey: "javascript.builtins.Widget", + compatRoot: "Widget", + includeInTarget: true, + resolutionKind: "root-availability", + resolvedUnitIds: [widgetDeclaration.id, widgetValue.id], + }, + { + compatKey: "javascript.builtins.Widget.supported", + compatRoot: "Widget", + includeInTarget: true, + resolutionKind: "member", + resolvedUnitIds: [supportedMember.id], + }, + ]; + + assert.throws( + () => assertRuntimeDeclarationProvenance({ + inventory, + selectedUnitIds, + completeContainerUnitIds: new Set(), + excludedUnitIds: new Set(), + classifiedCompatRows, + compilerSupportUnitIds: new Set(), + runtimeAliasUnitIds: new Set(), + }), + /Widget\.unknown/u, + ); + assertRuntimeDeclarationProvenance({ + inventory, + selectedUnitIds, + completeContainerUnitIds: new Set(), + excludedUnitIds: new Set(), + classifiedCompatRows, + compilerSupportUnitIds: new Set([unknownMember.id]), + runtimeAliasUnitIds: new Set(), + }); +}); + +test("runtime provenance audits every unit emitted from a whole-file lib", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + const inventory = await createFixtureInventory(tempDirectory, { + "lib.esnext.widget.d.ts": [ + "export {};", + "declare global {", + " interface Widget {", + " supported(): void;", + " unknown(): void;", + " }", + " interface WidgetConstructor {", + " new(): Widget;", + " }", + " var Widget: WidgetConstructor;", + "}", + "", + ].join("\n"), + }); + const widgetDeclaration = (inventory.declarationUnitsBySymbol.get("Widget") ?? []) + .find(unit => unit.declarationKind === "interface"); + const widgetValue = (inventory.declarationUnitsBySymbol.get("Widget") ?? []) + .find(unit => unit.declarationKind === "var"); + const supportedMember = requireMemberUnit(inventory, "Widget::supported"); + assert.ok(widgetDeclaration && widgetValue); + + assert.throws( + () => assertRuntimeDeclarationProvenance({ + inventory, + selectedUnitIds: [widgetDeclaration.id, widgetValue.id, supportedMember.id], + completeContainerUnitIds: new Set(), + excludedUnitIds: new Set(), + classifiedCompatRows: [ + { + compatKey: "javascript.builtins.Widget", + compatRoot: "Widget", + includeInTarget: true, + resolutionKind: "root-availability", + resolvedUnitIds: [widgetDeclaration.id, widgetValue.id], + }, + { + compatKey: "javascript.builtins.Widget.supported", + compatRoot: "Widget", + includeInTarget: true, + resolutionKind: "member", + resolvedUnitIds: [supportedMember.id], + }, + ], + compilerSupportUnitIds: new Set(), + runtimeAliasUnitIds: new Set(), + }), + /Widget\.unknown/u, + ); +}); + +test("surface inventory rejects module boundaries in built-in lib inputs", async () => { + for (const moduleExport of [ + "export declare const Surprise: number;", + "declare const Surprise: number; export { Surprise };", + "declare const Surprise: number; export default Surprise;", + "declare const Surprise: number; export = Surprise;", + "export as namespace Surprise;", + "export {}; declare module \"typescript\" { export const Surprise: unique symbol; }", + "import \"./allow/poison\";", + "type Poison = import(\"./allow/poison\").Poison;", + "/// ", + "/// ", + "/// ", + ]) { + const tempDirectory = createTempDirectory(tempDirectories); + await assert.rejects( + createFixtureInventory(tempDirectory, { + "lib.esnext.widget.d.ts": `${moduleExport}\n`, + }), + /(?:exposes a module export|contains an ambient module|contains a module import|contains a path reference|contains a types reference|contains AMD metadata)/u, + ); + } +}); + +test("surface inventory parses and strips TypeScript-valid lib reference syntax", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + const inventory = await createFixtureInventory(tempDirectory, { + "lib.esnext.widget.d.ts": [ + "/// ", + "interface Widget {}", + "", + ].join("\n"), + }); + const fileRecord = inventory.fileByLibFileName.get("lib.esnext.widget.d.ts"); + const widget = (inventory.declarationUnitsBySymbol.get("Widget") ?? [])[0]; + assert.deepEqual(fileRecord?.referenceLibs, ["es2015.iterable"]); + assert.ok(widget); + + const output = emitSelectedUnits({ + inventory, + selectedUnitIds: [widget.id], + }); + assert.match(output, /interface Widget/u); + assert.doesNotMatch(output, /reference\s+LIB/iu); +}); diff --git a/test/generate.test.mjs b/test/generate.test.mjs index d455aa4..ecead61 100644 --- a/test/generate.test.mjs +++ b/test/generate.test.mjs @@ -72,6 +72,10 @@ test("generate emits the current TypeScript-declarable Baseline JavaScript surfa "Uncapitalize", "ReadonlyMap", "ReadonlySet", + "IterableIterator", + "AsyncIterable", + "AsyncIterableIterator", + "TemplateStringsArray", ]) { assert.match( topLevelOutput, @@ -196,3 +200,87 @@ test("generate fails closed when compat-management metadata drifts", () => { assert.match(failureOutput, /compat management registry drift detected/); assert.match(failureOutput, /javascript\.builtins\.globalThis/); }); + +test("generate rejects emitted instance and static members whose compat row disappeared", () => { + for (const probe of [ + { + compatKey: "javascript.builtins.String.substr", + unitPattern: /lib\.es5\.d\.ts::String\.substr/u, + }, + { + compatKey: "javascript.builtins.Array.isArray", + unitPattern: /lib\.es5\.d\.ts::ArrayConstructor\.isArray/u, + }, + { + compatKey: "javascript.builtins.String.toString", + unitPattern: /lib\.es5\.d\.ts::String\.toString/u, + }, + { + compatKey: "javascript.builtins.String.valueOf", + unitPattern: /lib\.es5\.d\.ts::String\.valueOf/u, + }, + ]) { + const tempDirectory = createTempDirectory(tempDirectories); + const datasetPath = `${tempDirectory}/dataset.json`; + const dataset = readJsonFile(repoDatasetPath); + dataset.compatRows = dataset.compatRows.filter( + /** @param {{ compatKey: string; }} row */ + row => row.compatKey !== probe.compatKey, + ); + writeJsonFile(datasetPath, dataset); + + const fixture = createManifest(tempDirectory, { datasetPath }); + const failureOutput = runGenerateExpectFailure(fixture.manifestPath, probe.compatKey); + assert.match(failureOutput, /Emitted runtime declarations lack compat or compiler-support provenance/u); + assert.match(failureOutput, probe.unitPattern); + } +}); + +test("generate rejects stale compiler support registry surfaces", () => { + const tempDirectory = createTempDirectory(tempDirectories); + const registryPath = `${tempDirectory}/compat-management.json`; + const registry = readJsonFile(repoRegistryPath); + registry.compilerSupport[0].surfaces.push("MissingCompilerSupportSurface"); + writeJsonFile(registryPath, registry); + + const fixture = createManifest(tempDirectory, { registryPath }); + assert.match( + runGenerateExpectFailure(fixture.manifestPath), + /Compiler support surface is not modeled by the TypeScript lib inventory: MissingCompilerSupportSurface/u, + ); +}); + +test("generate rejects stale runtime alias registry surfaces", () => { + const tempDirectory = createTempDirectory(tempDirectories); + const registryPath = `${tempDirectory}/compat-management.json`; + const registry = readJsonFile(repoRegistryPath); + registry.runtimeAliases[0].surfaces.push("MissingRuntimeAliasSurface"); + writeJsonFile(registryPath, registry); + + const fixture = createManifest(tempDirectory, { registryPath }); + assert.match( + runGenerateExpectFailure(fixture.manifestPath), + /Runtime alias surface is not modeled by the TypeScript lib inventory: MissingRuntimeAliasSurface/u, + ); +}); + +test("generate rejects manifest and stale-report paths outside managed output roots", () => { + const tempDirectory = createTempDirectory(tempDirectories); + const fixture = createManifest(tempDirectory); + const manifest = readJsonFile(fixture.manifestPath); + manifest.firstClassLib.allowDirectory = "../outside"; + writeJsonFile(fixture.manifestPath, manifest); + assert.match( + runGenerateExpectFailure(fixture.manifestPath), + /must be a repo-relative path without '\.\.'/u, + ); + + const safeFixture = createManifest(createTempDirectory(tempDirectories)); + writeJsonFile(safeFixture.generationOutputPath, { + outputEntries: [{ outputPath: "../outside" }], + }); + assert.match( + runGenerateExpectFailure(safeFixture.manifestPath), + /outputEntries\[\]\.outputPath must be a repo-relative path without '\.\.'/u, + ); +}); diff --git a/test/helpers.mjs b/test/helpers.mjs index 03308b0..52e1c89 100644 --- a/test/helpers.mjs +++ b/test/helpers.mjs @@ -6,10 +6,10 @@ import { spawnSync, } from "node:child_process"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createPackageStages, createPackageTarball } from "../deploy/package-lib.mjs"; +import { resolveReleaseExecutable } from "../deploy/trusted-executable.mjs"; import { resolveInstalledPackageRoot } from "../lib/installed-package.mjs"; import { selectActiveNegativeProbes } from "../lib/negative-probes.mjs"; @@ -37,7 +37,9 @@ export function loadActiveNegativeProbesFromRepo() { * @param {string[]} tempDirectories */ export function createTempDirectory(tempDirectories) { - const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ts-baseline-lib-generator-")); + const tempRoot = path.join(repoRoot, ".tmp", "tests"); + fs.mkdirSync(tempRoot, { recursive: true }); + const tempDirectory = fs.mkdtempSync(path.join(tempRoot, "run-")); tempDirectories.push(tempDirectory); return tempDirectory; } @@ -95,19 +97,19 @@ export function createManifest(tempDirectory, options = {}) { manifestDirectory, options.allowlistRegistryPath ?? repoAllowlistRegistryPath, ); - manifest.classificationOutput = path.join(outputRoot, "derived", "classification.json"); - manifest.compatManagementOutput = path.join(outputRoot, "derived", "compat-management-report.json"); - manifest.inventoryOutput = path.join(outputRoot, "derived", "inventory.json"); - manifest.generationOutput = path.join(outputRoot, "derived", "generation.json"); + manifest.classificationOutput = toPosixRelativePath(repoRoot, path.join(outputRoot, "derived", "classification.json")); + manifest.compatManagementOutput = toPosixRelativePath(repoRoot, path.join(outputRoot, "derived", "compat-management-report.json")); + manifest.inventoryOutput = toPosixRelativePath(repoRoot, path.join(outputRoot, "derived", "inventory.json")); + manifest.generationOutput = toPosixRelativePath(repoRoot, path.join(outputRoot, "derived", "generation.json")); manifest.firstClassLib = { libName: "baseline", - outputFile: path.join(outputRoot, "generated", "baseline.d.ts"), - allowDirectory: path.join(outputRoot, "generated", "allow"), - yearDirectory: path.join(outputRoot, "generated", "year"), + outputFile: toPosixRelativePath(repoRoot, path.join(outputRoot, "generated", "baseline.d.ts")), + allowDirectory: toPosixRelativePath(repoRoot, path.join(outputRoot, "generated", "allow")), + yearDirectory: toPosixRelativePath(repoRoot, path.join(outputRoot, "generated", "year")), firstYear: repoManifest.firstClassLib.firstYear, }; if (options.generatedOutputPath) { - manifest.firstClassLib.outputFile = toPosixRelativePath(manifestDirectory, options.generatedOutputPath); + manifest.firstClassLib.outputFile = toPosixRelativePath(repoRoot, options.generatedOutputPath); } writeJsonFile(manifestPath, manifest); @@ -135,8 +137,9 @@ export function runGenerate(manifestPath) { /** * @param {string} manifestPath + * @param {string} [label] */ -export function runGenerateExpectFailure(manifestPath) { +export function runGenerateExpectFailure(manifestPath, label = manifestPath) { const result = spawnSync(process.execPath, [path.join(repoRoot, "scripts", "generate.mjs"), "--manifest", manifestPath], { cwd: repoRoot, encoding: "utf8", @@ -147,7 +150,7 @@ export function runGenerateExpectFailure(manifestPath) { return `${result.stdout ?? ""}${result.stderr ?? ""}`; } - assert.fail(`Expected generator to fail for ${manifestPath}`); + assert.fail(`Expected generator to fail for ${label}`); } // Compiler runs resolve the in-package bin explicitly instead of node_modules/.bin. @@ -210,11 +213,14 @@ export function runTscStradaExpectFailure(args, options = {}) { * @param {{ cwd?: string; }} [options] */ export function runNpm(args, options = {}) { - return execFileSync("npm", args, { - cwd: options.cwd ?? repoRoot, + const cwd = options.cwd ?? repoRoot; + const npm = resolveReleaseExecutable(repoRoot, "RELEASE_NPM_EXECUTABLE", "npm"); + return execFileSync(npm.executable, args, { + cwd, encoding: "utf8", env: { - ...process.env, + ...npm.environment, + npm_config_cache: path.join(cwd, ".npm-cache"), npm_config_yes: "true", }, }); diff --git a/test/managed-output-path.test.mjs b/test/managed-output-path.test.mjs new file mode 100644 index 0000000..6d990cb --- /dev/null +++ b/test/managed-output-path.test.mjs @@ -0,0 +1,73 @@ +// @ts-check + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { + isPathWithin, + removeManagedPath, + resolveManagedOutputPath, +} from "../lib/shared.mjs"; +import { + cleanupTempDirectories, + createTempDirectory, +} from "./helpers.mjs"; + +const repoRoot = path.resolve("/repo"); +const manifestPath = path.join(repoRoot, "manifests", "baseline.json"); +const generatedRoot = path.join(repoRoot, "generated", "current"); +/** @type {string[]} */ +const tempDirectories = []; + +test.afterEach(() => { + cleanupTempDirectories(tempDirectories); +}); + +test("managed output paths stay inside their canonical repo root", () => { + assert.equal( + resolveManagedOutputPath( + "generated/current/baseline.d.ts", + repoRoot, + manifestPath, + "firstClassLib.outputFile", + [generatedRoot], + ), + path.join(generatedRoot, "baseline.d.ts"), + ); + + for (const invalidPath of [ + path.join(repoRoot, "outside.d.ts"), + "../outside.d.ts", + "generated/elsewhere/outside.d.ts", + ]) { + assert.throws( + () => resolveManagedOutputPath( + invalidPath, + repoRoot, + manifestPath, + "firstClassLib.outputFile", + [generatedRoot], + ), + /repo-relative path|managed output root/u, + ); + } +}); + +test("managed removal rejects symbolic-link ancestors", async () => { + const boundaryRoot = createTempDirectory(tempDirectories); + const managedRoot = path.join(boundaryRoot, "generated", "current"); + const outsideRoot = path.join(boundaryRoot, "outside"); + fs.mkdirSync(managedRoot, { recursive: true }); + fs.mkdirSync(outsideRoot, { recursive: true }); + fs.writeFileSync(path.join(outsideRoot, "sentinel.txt"), "keep\n"); + fs.symlinkSync(outsideRoot, path.join(managedRoot, "linked"), "dir"); + + const targetPath = path.join(managedRoot, "linked", "nested"); + assert.ok(isPathWithin(managedRoot, targetPath)); + await assert.rejects( + removeManagedPath(targetPath, boundaryRoot, [managedRoot], { recursive: true }), + /symbolic link/u, + ); + assert.equal(fs.readFileSync(path.join(outsideRoot, "sentinel.txt"), "utf8"), "keep\n"); +}); diff --git a/test/package-metadata.test.mjs b/test/package-metadata.test.mjs index 4d522be..6ff6b24 100644 --- a/test/package-metadata.test.mjs +++ b/test/package-metadata.test.mjs @@ -5,8 +5,10 @@ import fs from "node:fs"; import path from "node:path"; import test from "node:test"; import { + assertExplicitVersionIncrease, assertTypeScriptPeerRange, countIncludedCompatRows, + getDeclarationContractImpact, } from "../deploy/package-lib.mjs"; import { resolveInstalledPackageRoot } from "../lib/installed-package.mjs"; import { @@ -119,6 +121,69 @@ test("TypeScript peer range contains every pinned compiler line", () => { ); }); +test("package declaration snapshots require reviewed semantic versions", () => { + const packageJson = JSON.stringify({ + types: "./index.d.ts", + typesVersions: { "*": { "allow/*": ["allow/*/index.d.ts"] } }, + }); + const published = new Map([ + ["package.json", packageJson], + ["index.d.ts", "/// \n"], + ["baseline.d.ts", "interface Stable { value: string; }\n"], + ]); + + assert.equal(getDeclarationContractImpact(published, new Map(published)), undefined); + assert.equal(getDeclarationContractImpact(published, new Map([ + ...published, + ["allow/new/index.d.ts", "interface NewFeature {}\n"], + ])), "minor"); + assert.equal(getDeclarationContractImpact(published, new Map([ + ...published, + ["baseline.d.ts", "interface Stable { value: string; }\ninterface Added {}\n"], + ])), "minor"); + const multilinePublished = new Map([ + ["baseline.d.ts", "interface Stable {\n value: string;\n}\n"], + ]); + assert.equal(getDeclarationContractImpact(multilinePublished, new Map([ + ["baseline.d.ts", "interface Stable {\n value: string;\n added(): void;\n}\n"], + ])), "minor"); + assert.equal(getDeclarationContractImpact(published, new Map([ + ...published, + ["baseline.d.ts", "export {};\ninterface Stable { value: string; }\n"], + ])), "major"); + assert.equal(getDeclarationContractImpact(published, new Map([ + ...published, + ["baseline.d.ts", "declare namespace Wrapped {\ninterface Stable { value: string; }\n}\n"], + ])), "major"); + const nestedPublished = new Map([ + ["baseline.d.ts", "declare namespace Outer {\ninterface Stable { value: string; }\n}\n"], + ]); + assert.equal(getDeclarationContractImpact(nestedPublished, new Map([ + ["baseline.d.ts", "declare namespace Outer {\nnamespace Nested {\ninterface Stable { value: string; }\n}\n}\n"], + ])), "major"); + const typeAliasPublished = new Map([ + ["baseline.d.ts", "type Stable =\n | string;\n"], + ]); + assert.equal(getDeclarationContractImpact(typeAliasPublished, new Map([ + ["baseline.d.ts", "type Stable =\n & number\n | string;\n"], + ])), "major"); + assert.equal(getDeclarationContractImpact(published, new Map([ + ...published, + ["baseline.d.ts", "interface Stable { value: number; }\n"], + ])), "major"); + assert.equal(getDeclarationContractImpact(published, new Map( + [...published].filter(([relativePath]) => relativePath !== "baseline.d.ts"), + )), "major"); + assert.equal(getDeclarationContractImpact(published, new Map([ + ...published, + ["package.json", JSON.stringify({ types: "./other.d.ts" })], + ])), "major"); + assert.throws( + () => assertExplicitVersionIncrease("0.0.1", "0.1.0-rc.0"), + /must be stable/u, + ); +}); + test("manifest compiler versions match the installed toolchains", () => { const installedTypeScript = readJsonFile(path.join(resolveInstalledPackageRoot(repoRoot, "typescript"), "package.json")); const installedStrada = readJsonFile(path.join(resolveInstalledPackageRoot(repoRoot, "typescript-strada"), "package.json")); diff --git a/test/packed-consumer-smoke.test.mjs b/test/packed-consumer-smoke.test.mjs index 95faed2..4e2407f 100644 --- a/test/packed-consumer-smoke.test.mjs +++ b/test/packed-consumer-smoke.test.mjs @@ -19,6 +19,7 @@ import { runTsc, runTscExpectFailure, runTscStrada, + runTscStradaExpectFailure, writeJsonFile, writeTextFile, } from "./helpers.mjs"; @@ -33,7 +34,9 @@ test.afterEach(() => { test("packed consumer smoke: npm-packed baseline package typechecks through compilerOptions.types", async () => { const tempDirectory = createTempDirectory(tempDirectories); const consumerDirectory = path.join(tempDirectory, "consumer"); - const { tarballPath } = await createBaselinePackageTarball({ tempDirectories }); + const tarballPath = process.env.BASELINE_PACKAGE_TARBALL + ? path.resolve(process.env.BASELINE_PACKAGE_TARBALL) + : (await createBaselinePackageTarball({ tempDirectories })).tarballPath; writeJsonFile(path.join(consumerDirectory, "package.json"), { name: "baseline-consumer-fixture", @@ -64,8 +67,14 @@ test("packed consumer smoke: npm-packed baseline package typechecks through comp "const reversed = [1, 2, 3].toReversed();", "const values = Intl.supportedValuesOf(\"currency\");", "const result = Promise.withResolvers();", + "function* iterate(): IterableIterator { yield 1; }", + "async function* iterateAsync(): AsyncIterableIterator { yield 1; }", + "function tag(strings: TemplateStringsArray): string { return strings.raw[0] ?? \"\"; }", "reversed.length + values.length;", "result.promise;", + "iterate().next();", + "iterateAsync()[Symbol.asyncIterator]();", + "tag`baseline`;", REGEXP_LEGACY_STATIC_ABSENCE_ASSERTION, "", ].join("\n")); @@ -75,6 +84,22 @@ test("packed consumer smoke: npm-packed baseline package typechecks through comp runTsc(["-p", path.join(consumerDirectory, "tsconfig.json")], { cwd: consumerDirectory }); runTscStrada(["-p", path.join(consumerDirectory, "tsconfig.json")], { cwd: consumerDirectory }); + writeTextFile(path.join(consumerDirectory, "year-pass.ts"), [ + "Promise.withResolvers();", + "Array.fromAsync([1, 2, 3]);", + "", + ].join("\n")); + writeJsonFile(path.join(consumerDirectory, "tsconfig.year.json"), { + compilerOptions: { + noLib: true, + strict: true, + types: [`${baselinePackageName}/year/2024`], + }, + files: ["year-pass.ts"], + }); + runTsc(["-p", path.join(consumerDirectory, "tsconfig.year.json")], { cwd: consumerDirectory }); + runTscStrada(["-p", path.join(consumerDirectory, "tsconfig.year.json")], { cwd: consumerDirectory }); + // Derive the currently excluded probes from the checked-in classification // rather than a hard-coded excluded-API list (auto-follows Baseline promotion). const negativeProbes = loadActiveNegativeProbesFromRepo(); @@ -88,9 +113,14 @@ test("packed consumer smoke: npm-packed baseline package typechecks through comp files: ["consumer-fail.ts"], }); - const failure = runTscExpectFailure(["-p", path.join(consumerDirectory, "tsconfig.fail.json")], { cwd: consumerDirectory }); - assert.equal(failure.ok, false); - for (const probe of negativeProbes) { - assert.match(failure.output, probe.errorPattern, `expected excluded probe ${probe.compatKey} to fail compilation`); + const failures = [ + runTscExpectFailure(["-p", path.join(consumerDirectory, "tsconfig.fail.json")], { cwd: consumerDirectory }), + runTscStradaExpectFailure(["-p", path.join(consumerDirectory, "tsconfig.fail.json")], { cwd: consumerDirectory }), + ]; + for (const failure of failures) { + assert.equal(failure.ok, false); + for (const probe of negativeProbes) { + assert.match(failure.output, probe.errorPattern, `expected excluded probe ${probe.compatKey} to fail compilation`); + } } }); diff --git a/test/release-artifact.test.mjs b/test/release-artifact.test.mjs new file mode 100644 index 0000000..b0edc4b --- /dev/null +++ b/test/release-artifact.test.mjs @@ -0,0 +1,217 @@ +// @ts-check + +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { + assertCleanWorktree, + assertExistingGitHubRelease, + hashPreparedReleaseArtifact, + readPreparedReleaseArtifact, + writePreparedReleaseArtifact, +} from "../deploy/release-artifact.mjs"; +import { resolveReleaseExecutable } from "../deploy/trusted-executable.mjs"; +import { + cleanupTempDirectories, + createTempDirectory, + writeJsonFile, +} from "./helpers.mjs"; + +/** @type {string[]} */ +const tempDirectories = []; + +test.afterEach(() => { + cleanupTempDirectories(tempDirectories); +}); + +test("existing GitHub releases must target the prepared commit", () => { + const tag = "typescript-baseline-lib@0.1.0"; + const sourceCommit = "a".repeat(40); + assert.doesNotThrow(() => assertExistingGitHubRelease({ + tag_name: tag, + target_commitish: sourceCommit, + }, tag, sourceCommit)); + assert.throws( + () => assertExistingGitHubRelease({ + tag_name: tag, + target_commitish: "b".repeat(40), + }, tag, sourceCommit), + /does not match source commit/u, + ); +}); + +test("release executables cannot resolve through repository or npm shim paths", () => { + const tempDirectory = createTempDirectory(tempDirectories); + const shimDirectory = path.join(tempDirectory, "node_modules", ".bin"); + const originalPath = process.env.PATH; + process.env.PATH = [shimDirectory, originalPath].filter(Boolean).join(path.delimiter); + try { + const resolved = resolveReleaseExecutable(tempDirectory, "UNSET_RELEASE_EXECUTABLE", "git"); + assert.ok(!resolved.environment.PATH?.split(path.delimiter).includes(shimDirectory)); + process.env.TEST_RELEASE_EXECUTABLE = path.join(shimDirectory, "git"); + assert.throws( + () => resolveReleaseExecutable(tempDirectory, "TEST_RELEASE_EXECUTABLE", "git"), + /absolute path outside the repository/u, + ); + } + finally { + if (originalPath === undefined) { + delete process.env.PATH; + } + else { + process.env.PATH = originalPath; + } + delete process.env.TEST_RELEASE_EXECUTABLE; + } +}); + +test("release artifacts reject tracked and untracked worktree changes", () => { + const tempDirectory = createTempDirectory(tempDirectories); + execFileSync("git", ["init", "--quiet"], { cwd: tempDirectory }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDirectory }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: tempDirectory }); + fs.writeFileSync(path.join(tempDirectory, "tracked.txt"), "clean\n"); + fs.writeFileSync(path.join(tempDirectory, "filtered.txt"), "reviewed\n"); + fs.writeFileSync(path.join(tempDirectory, ".gitignore"), ".tmp\n"); + execFileSync("git", ["add", "tracked.txt", "filtered.txt", ".gitignore"], { cwd: tempDirectory }); + execFileSync("git", ["commit", "--quiet", "-m", "fixture"], { cwd: tempDirectory }); + + assert.doesNotThrow(() => assertCleanWorktree(tempDirectory)); + + const untrackedPath = path.join(tempDirectory, "untracked.d.ts"); + fs.writeFileSync(untrackedPath, "export declare const Surprise: number;\n"); + assert.throws( + () => assertCleanWorktree(tempDirectory), + /clean worktree/u, + ); + fs.unlinkSync(untrackedPath); + + const ignoredGeneratedPath = path.join(tempDirectory, "generated", "current", "allow", ".tmp", "index.d.ts"); + fs.mkdirSync(path.dirname(ignoredGeneratedPath), { recursive: true }); + fs.writeFileSync(ignoredGeneratedPath, "export declare const Surprise: number;\n"); + assert.throws( + () => assertCleanWorktree(tempDirectory), + /clean worktree/u, + ); + fs.rmSync(path.join(tempDirectory, "generated"), { recursive: true }); + + execFileSync("git", ["update-index", "--assume-unchanged", "tracked.txt"], { cwd: tempDirectory }); + fs.writeFileSync(path.join(tempDirectory, "tracked.txt"), "hidden dirty\n"); + assert.throws( + () => assertCleanWorktree(tempDirectory), + /clean worktree/u, + ); + execFileSync("git", ["update-index", "--no-assume-unchanged", "tracked.txt"], { cwd: tempDirectory }); + fs.writeFileSync(path.join(tempDirectory, "tracked.txt"), "clean\n"); + + execFileSync("git", ["update-index", "--skip-worktree", "tracked.txt"], { cwd: tempDirectory }); + fs.writeFileSync(path.join(tempDirectory, "tracked.txt"), "hidden dirty\n"); + assert.throws( + () => assertCleanWorktree(tempDirectory), + /clean worktree/u, + ); + execFileSync("git", ["update-index", "--no-skip-worktree", "tracked.txt"], { cwd: tempDirectory }); + fs.writeFileSync(path.join(tempDirectory, "tracked.txt"), "clean\n"); + + const filterScriptPath = path.join(tempDirectory, ".git", "clean-filter.cjs"); + fs.writeFileSync(filterScriptPath, [ + "let input = '';", + "process.stdin.setEncoding('utf8');", + "process.stdin.on('data', chunk => input += chunk);", + "process.stdin.on('end', () => process.stdout.write(input.replaceAll('injected', 'reviewed')));", + "", + ].join("\n")); + fs.writeFileSync(path.join(tempDirectory, ".git", "info", "attributes"), "filtered.txt filter=hide\n"); + execFileSync("git", ["config", "filter.hide.clean", `\"${process.execPath}\" \"${filterScriptPath}\"`], { cwd: tempDirectory }); + fs.writeFileSync(path.join(tempDirectory, "filtered.txt"), "injected\n"); + assert.equal(execFileSync("git", ["status", "--porcelain=v1"], { cwd: tempDirectory, encoding: "utf8" }), ""); + assert.throws( + () => assertCleanWorktree(tempDirectory), + /clean worktree/u, + ); + fs.writeFileSync(path.join(tempDirectory, "filtered.txt"), "reviewed\n"); + + fs.writeFileSync(path.join(tempDirectory, "tracked.txt"), "dirty\n"); + assert.throws( + () => assertCleanWorktree(tempDirectory), + /clean worktree/u, + ); +}); + +test("prepared release artifact binds package identity, commit, and tarball integrity", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + const packageDirectory = path.join(tempDirectory, "tar-source", "package"); + const tarballPath = path.join(tempDirectory, "source.tgz"); + const artifactDirectory = path.join(tempDirectory, "artifact"); + writeJsonFile(path.join(packageDirectory, "package.json"), { + name: "typescript-baseline-lib", + version: "0.1.0", + }); + execFileSync("tar", ["-czf", tarballPath, "package"], { + cwd: path.dirname(packageDirectory), + }); + + const plan = await writePreparedReleaseArtifact({ + outputDirectory: artifactDirectory, + sourceCommit: "a".repeat(40), + releasePlan: { + changed: true, + packageConfig: { name: "typescript-baseline-lib" }, + packageVersion: "0.1.0", + publishedVersion: "0.0.1", + requiredVersionBump: "major", + notesMarkdown: "release notes\n", + }, + tarballPath, + }); + const artifact = await readPreparedReleaseArtifact(artifactDirectory); + assert.deepEqual(artifact.plan, plan); + assert.equal(artifact.notesMarkdown, "release notes\n"); + const artifactIntegrity = await hashPreparedReleaseArtifact(artifactDirectory, plan.changed); + + fs.appendFileSync(artifact.tarballPath, "tampered"); + assert.notEqual(await hashPreparedReleaseArtifact(artifactDirectory, plan.changed), artifactIntegrity); + await assert.rejects( + readPreparedReleaseArtifact(artifactDirectory), + /Release tarball integrity mismatch/u, + ); +}); + +test("release worktree verification ignores Git replacement objects", () => { + const tempDirectory = createTempDirectory(tempDirectories); + execFileSync("git", ["init", "--quiet"], { cwd: tempDirectory }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDirectory }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: tempDirectory }); + fs.writeFileSync(path.join(tempDirectory, "generated.d.ts"), "declare const Reviewed: unique symbol;\n"); + execFileSync("git", ["add", "generated.d.ts"], { cwd: tempDirectory }); + execFileSync("git", ["commit", "--quiet", "-m", "fixture"], { cwd: tempDirectory }); + const sourceCommit = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: tempDirectory, + encoding: "utf8", + }).trim(); + + fs.writeFileSync(path.join(tempDirectory, "generated.d.ts"), "declare const Injected: unique symbol;\n"); + execFileSync("git", ["add", "generated.d.ts"], { cwd: tempDirectory }); + const replacementTree = execFileSync("git", ["write-tree"], { + cwd: tempDirectory, + encoding: "utf8", + }).trim(); + const replacementCommit = execFileSync( + "git", + ["commit-tree", replacementTree, "-p", sourceCommit, "-m", "replacement"], + { cwd: tempDirectory, encoding: "utf8" }, + ).trim(); + execFileSync("git", ["replace", sourceCommit, replacementCommit], { cwd: tempDirectory }); + + assert.equal(execFileSync("git", ["status", "--porcelain=v1"], { cwd: tempDirectory, encoding: "utf8" }), ""); + assert.equal( + execFileSync("git", ["rev-parse", "HEAD"], { cwd: tempDirectory, encoding: "utf8" }).trim(), + sourceCommit, + ); + assert.throws( + () => assertCleanWorktree(tempDirectory), + /clean worktree/u, + ); +}); diff --git a/test/surface-inventory.test.mjs b/test/surface-inventory.test.mjs index 7e3a6b8..8e354dd 100644 --- a/test/surface-inventory.test.mjs +++ b/test/surface-inventory.test.mjs @@ -150,6 +150,30 @@ test("surface inventory lookup maps share dependency-populated unit records", as } }); +test("surface inventory rejects multi-declarator runtime globals", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + const libDirectory = path.join(tempDirectory, "lib"); + fs.mkdirSync(libDirectory, { recursive: true }); + fs.writeFileSync( + path.join(libDirectory, "lib.es5.d.ts"), + "declare var Known: KnownConstructor, Surprise: SurpriseConstructor;\n", + ); + const sourceLibEntries = await discoverBuiltinSourceLibEntries({ + libDirectory, + reportPathPrefix: "typescript/lib", + }); + + await assert.rejects( + createSurfaceInventory({ + snapshotName: "multi-declarator-test", + repoRoot: tempDirectory, + sourceLibEntries, + inventoryOutputPath: path.join(tempDirectory, "inventory.json"), + }), + /variable statement with multiple declarators/, + ); +}); + test("readonly companion discovery fails closed on unmatched members", async () => { const tempDirectory = createTempDirectory(tempDirectories); const libDirectory = path.join(tempDirectory, "lib"); @@ -218,6 +242,7 @@ test("type-only aliases cannot introduce unclassified runtime declarations", asy () => resolveTypeOnlyDependencyClosure({ inventory, compatSelectedUnitIds: new Set(), + compilerSupportUnitIds: new Set(), typeOnlyUnitIds: [aliasUnit.id], completeContainerUnitIds: new Set(), excludedUnitIds: new Set(), diff --git a/test/type-only-consumer.test.mjs b/test/type-only-consumer.test.mjs index e1a09f6..e88151d 100644 --- a/test/type-only-consumer.test.mjs +++ b/test/type-only-consumer.test.mjs @@ -28,6 +28,10 @@ test("generated lib supports erased utility types with strict library checking", ); writeTextFile(path.join(tempDirectory, "third-party.d.ts"), [ "declare const thirdPartyLabels: Record;", + "declare const thirdPartyIterator: IterableIterator;", + "declare const thirdPartyAsyncIterable: AsyncIterable;", + "declare const thirdPartyAsyncIterator: AsyncIterableIterator;", + "declare function thirdPartyTag(strings: TemplateStringsArray): string;", "", ].join("\n")); writeTextFile(path.join(tempDirectory, "consumer.ts"), [ @@ -63,6 +67,10 @@ test("generated lib supports erased utility types with strict library checking", "declare const promiseLike: PromiseLike;", "declare const callableValue: ((this: { prefix: string }, value: number) => string) & CallableFunction;", "readonlyMap.get(thirdPartyLabels.baseline);", + "thirdPartyIterator.next();", + "thirdPartyAsyncIterable[Symbol.asyncIterator]();", + "thirdPartyAsyncIterator.next();", + "thirdPartyTag`baseline`;", "readonlySet.has(contextual.method());", "readonlyValues.findLast(value => value > 0);", "readonlyValues.toReversed();", diff --git a/test/typescript-upstream.test.mjs b/test/typescript-upstream.test.mjs index a87a1fe..03e87f2 100644 --- a/test/typescript-upstream.test.mjs +++ b/test/typescript-upstream.test.mjs @@ -9,7 +9,10 @@ import { cleanupTempDirectories, createTempDirectory, } from "./helpers.mjs"; -import { prepareTypeScriptBaselinePatch } from "../lib/typescript-upstream.mjs"; +import { + findUnexpectedTypeScriptPatchPaths, + prepareTypeScriptBaselinePatch, +} from "../lib/typescript-upstream.mjs"; /** @type {string[]} */ const tempDirectories = []; @@ -48,6 +51,7 @@ test("prepareTypeScriptBaselinePatch installs baseline.d.ts source and patches T const targetLibPath = path.join(typescriptDir, "src", "lib", "baseline.d.ts"); const commandLineParserPath = path.join(typescriptDir, "src", "compiler", "commandLineParser.ts"); const libsJsonPath = path.join(typescriptDir, "src", "lib", "libs.json"); + const eslintConfigPath = path.join(typescriptDir, "eslint.config.mjs"); assert.equal(fs.readFileSync(targetLibPath, "utf8"), "// generated baseline\n"); assert.match(fs.readFileSync(commandLineParserPath, "utf8"), /\["baseline", "lib\.baseline\.d\.ts"\],/); @@ -60,6 +64,10 @@ test("prepareTypeScriptBaselinePatch installs baseline.d.ts source and patches T [...fs.readFileSync(libsJsonPath, "utf8").matchAll(/"baseline"/g)].length, 1, ); + assert.match( + fs.readFileSync(eslintConfigPath, "utf8"), + /files: \["src\/lib\/es2019\.array\.d\.ts", "src\/lib\/baseline\.d\.ts"\]/, + ); assert.ok(fs.existsSync(path.join(typescriptDir, "tests", "cases", "compiler", "libBaseline.ts"))); assert.ok(fs.existsSync(path.join(typescriptDir, "tests", "baselines", "reference", "libBaseline.errors.txt"))); assert.equal(firstSummary.fixtureFiles.length, 2); @@ -76,6 +84,7 @@ test("prepareTypeScriptBaselinePatch installs baseline.d.ts source and patches T assert.equal(secondSummary.copiedGeneratedLib.changed, false); assert.equal(secondSummary.patchedCommandLineParser.changed, false); assert.equal(secondSummary.patchedLibsJson.changed, false); + assert.equal(secondSummary.patchedEslintConfig.changed, false); // Second run reports the same fixture list, but zero files changed // (summary must not misreport unchanged files as "copied"). assert.equal(secondSummary.fixtureFiles.length, 2); @@ -113,6 +122,14 @@ function createFakeTypeScriptTree(tempDirectory) { "dom" ] } +`, + ); + fs.writeFileSync( + path.join(typescriptDir, "eslint.config.mjs"), + `export default [{ + files: ["src/lib/es2019.array.d.ts"], + rules: { "@typescript-eslint/array-type": "off" }, +}]; `, ); @@ -160,3 +177,46 @@ test("prepareTypeScriptBaselinePatch refuses to patch a clone that drifted from }); assert.equal(summary.copiedGeneratedLib.changed, true); }); + +test("TypeScript patch auditing rejects changes outside the proposal surface", () => { + const tempDirectory = createTempDirectory(tempDirectories); + execFileSync("git", ["init", "--quiet"], { cwd: tempDirectory }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDirectory }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: tempDirectory }); + fs.writeFileSync(path.join(tempDirectory, "allowed.txt"), "before\n"); + execFileSync("git", ["add", "allowed.txt"], { cwd: tempDirectory }); + execFileSync("git", ["commit", "--quiet", "-m", "fixture"], { cwd: tempDirectory }); + + fs.writeFileSync(path.join(tempDirectory, "allowed.txt"), "after\n"); + fs.writeFileSync(path.join(tempDirectory, "unexpected.txt"), "unexpected\n"); + assert.deepEqual( + findUnexpectedTypeScriptPatchPaths(tempDirectory, ["allowed.txt"]), + ["unexpected.txt"], + ); +}); + +test("TypeScript patch auditing accepts only mechanical lib-list baseline updates", () => { + const tempDirectory = createTempDirectory(tempDirectories); + const baselinePath = "tests/baselines/reference/config/lib-list.js"; + const fullBaselinePath = path.join(tempDirectory, baselinePath); + const semanticPath = "tests/baselines/reference/config/semantic.js"; + const fullSemanticPath = path.join(tempDirectory, semanticPath); + execFileSync("git", ["init", "--quiet"], { cwd: tempDirectory }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDirectory }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: tempDirectory }); + fs.mkdirSync(path.dirname(fullBaselinePath), { recursive: true }); + fs.writeFileSync(fullBaselinePath, "one or more: es5, esnext, dom\n//// [/file] Inode:: 10\n {\"inode\":10}\n"); + fs.writeFileSync(fullSemanticPath, "semantic result: alpha, omega\n"); + execFileSync("git", ["add", baselinePath, semanticPath], { cwd: tempDirectory }); + execFileSync("git", ["commit", "--quiet", "-m", "fixture"], { cwd: tempDirectory }); + + fs.writeFileSync(fullBaselinePath, "one or more: es5, esnext, baseline, dom\n//// [/file] Inode:: 11\n {\"inode\":11}\n"); + assert.deepEqual(findUnexpectedTypeScriptPatchPaths(tempDirectory, []), []); + + fs.writeFileSync(fullSemanticPath, "semantic result: alpha, baseline, omega\n"); + assert.deepEqual(findUnexpectedTypeScriptPatchPaths(tempDirectory, []), [semanticPath]); + fs.writeFileSync(fullSemanticPath, "semantic result: alpha, omega\n"); + + fs.appendFileSync(fullBaselinePath, "semantic change\n"); + assert.deepEqual(findUnexpectedTypeScriptPatchPaths(tempDirectory, []), [baselinePath]); +}); diff --git a/test/web-features-dataset.test.mjs b/test/web-features-dataset.test.mjs index ff8fb57..48524e8 100644 --- a/test/web-features-dataset.test.mjs +++ b/test/web-features-dataset.test.mjs @@ -14,6 +14,7 @@ import { buildWebFeaturesDataset, datasetsEqualIgnoringDate, resolveSnapshotDate, + verifyWebFeaturesDataset, } from "../lib/web-features-dataset.mjs"; // Weekly-update noise suppression: in a week where web-features itself doesn't @@ -137,6 +138,24 @@ test("web-features extractor accepts well-formed per-key statuses", async () => assert.equal(dataset.compatRows[0].baselineStatus, "high"); }); +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", + snapshotName: "web-features-test", + }); + + await verifyWebFeaturesDataset({ repoRoot: tempDirectory, dataset }); + + dataset.compatRows[0].baselineStatus = "low"; + await assert.rejects( + verifyWebFeaturesDataset({ repoRoot: tempDirectory, dataset }), + /does not match the pinned web-features package extraction/u, + ); +}); + 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(); @@ -156,6 +175,52 @@ test("web-features extractor fails closed when by_compat_key is missing instead ); }); +test("web-features extractor fails closed when compat_features changes shape", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + installWebFeaturesFixture(tempDirectory, { + "widget-helpers": { + ...validFeature(), + compat_features: { widget: "javascript.builtins.Widget.good" }, + snapshot: "ecmascript-2024", + }, + }); + + await assert.rejects( + buildWebFeaturesDataset({ + repoRoot: tempDirectory, + snapshotDate: "2026-07-07", + snapshotName: "web-features-test", + }), + /non-array compat_features/, + ); + + const renamedDirectory = createTempDirectory(tempDirectories); + const renamedFeature = validFeature(); + renamedFeature.compatFeatures = renamedFeature.compat_features; + delete renamedFeature.compat_features; + renamedFeature.snapshot = "ecmascript-2024"; + installWebFeaturesFixture(renamedDirectory, { "widget-helpers": renamedFeature }); + await assert.rejects( + buildWebFeaturesDataset({ + repoRoot: renamedDirectory, + snapshotDate: "2026-07-07", + snapshotName: "web-features-test", + }), + /missing compat_features for compatibility-backed data/, + ); + + const arrayDirectory = createTempDirectory(tempDirectories); + installWebFeaturesFixture(arrayDirectory, /** @type {any} */ ([])); + await assert.rejects( + buildWebFeaturesDataset({ + repoRoot: arrayDirectory, + snapshotDate: "2026-07-07", + snapshotName: "web-features-test", + }), + /missing the features map/, + ); +}); + test("web-features extractor fails closed on unknown entry kinds and invalid baseline values", async () => { const unknownKindDirectory = createTempDirectory(tempDirectories); installWebFeaturesFixture(unknownKindDirectory, { @@ -247,3 +312,22 @@ test("dataset loader requires the manifest and dataset Baseline dates to match", /Dataset baselineDate 2025-12-31 does not match expected 2026-07-07/, ); }); + +test("dataset loader requires the manifest and dataset package versions to match", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + const datasetPath = path.join(tempDirectory, "dataset.json"); + writeJsonFile(datasetPath, { + snapshot: { + name: "web-features-test", + baselineDate: "2026-07-07", + webFeaturesPackageVersion: "1.0.0", + }, + featureRows: [], + compatRows: [], + }); + + await assert.rejects( + loadBaselineDataset(datasetPath, "web-features-test", "2026-07-07", "2.0.0"), + /Dataset webFeaturesPackageVersion 1\.0\.0 does not match expected 2\.0\.0/u, + ); +}); diff --git a/test/workflow-pins.test.mjs b/test/workflow-pins.test.mjs index cb9c96a..820a268 100644 --- a/test/workflow-pins.test.mjs +++ b/test/workflow-pins.test.mjs @@ -61,3 +61,36 @@ test("every workflow action is pinned to a full commit SHA with a version commen `unpinned workflow actions found (pin to a 40-hex commit SHA with a version comment):\n${violations.join("\n")}`, ); }); + +test("release publishing stays isolated from build dependencies", () => { + const source = readFileSync(path.join(workflowsDirectory, "release.yml"), "utf8"); + const publishJob = source.split(/^ publish:/mu)[1]; + assert.ok(publishJob, "expected a dedicated publish job"); + assert.match(source, /^ verify:[\s\S]*?permissions:\n contents: read/mu); + assert.match(source, /RELEASE_GIT_EXECUTABLE: \/usr\/bin\/git/u); + assert.match(source, /RELEASE_NODE_EXECUTABLE: \$\{\{ steps\.release-tools\.outputs\.node \}\}/u); + assert.match(source, /RELEASE_NPM_EXECUTABLE: \$\{\{ steps\.release-tools\.outputs\.npm \}\}/u); + assert.match(source, /RELEASE_TAR_EXECUTABLE: \$\{\{ steps\.release-tools\.outputs\.tar \}\}/u); + assert.match(source, /"\$RELEASE_NODE_EXECUTABLE" deploy\/prepareReleaseArtifact\.mjs/u); + assert.match(source, /"\$RELEASE_NODE_EXECUTABLE" --test test\/packed-consumer-smoke\.test\.mjs/u); + assert.match(source, /"\$RELEASE_NODE_EXECUTABLE" deploy\/verifyReleaseArtifact\.mjs --artifact-dir release-artifact/u); + assert.match(source, /artifact-integrity: \$\{\{ steps\.prepare-artifact\.outputs\.artifact-integrity \}\}/u); + assert.match(source, /EXPECTED_ARTIFACT_INTEGRITY: \$\{\{ needs\.verify\.outputs\.artifact-integrity \}\}/u); + assert.doesNotMatch(source, /\bjq\b/u); + assert.match(publishJob, /environment: release/u); + assert.match(publishJob, /id-token: write/u); + assert.match(publishJob, /actions\/download-artifact@[0-9a-f]{40}/u); + assert.match(publishJob, /"\$RELEASE_NODE_EXECUTABLE" deploy\/publishReleaseArtifact\.mjs --artifact-dir release-artifact/u); + assert.match(publishJob, /RELEASE_NODE_EXECUTABLE: \$\{\{ steps\.publish-tools\.outputs\.node \}\}/u); + assert.match(publishJob, /RELEASE_NPM_EXECUTABLE: \$\{\{ steps\.publish-tools\.outputs\.npm \}\}/u); + assert.match(publishJob, /RELEASE_TAR_EXECUTABLE: \/usr\/bin\/tar/u); + assert.doesNotMatch(publishJob, /npm (?:ci|install)/u); + assert.doesNotMatch(source, /\.tmp\/release-artifact/u); + assert.doesNotMatch(source, /\.tmp\/typescript-(?:integration|baseline|focused|raw)/u); + assert.match(source, /path: typescript-integration-artifacts\/raw-local-baselines\n if-no-files-found: error/u); + for (const fileName of ["test-typescript.yml", "test-typescript-go.yml"]) { + const integrationSource = readFileSync(path.join(workflowsDirectory, fileName), "utf8"); + assert.doesNotMatch(integrationSource, /\.tmp\/typescript-(?:integration|baseline|focused|raw|go-integration)/u); + assert.match(integrationSource, /if-no-files-found: error/u); + } +}); diff --git a/test/year-entrypoints.test.mjs b/test/year-entrypoints.test.mjs index dec28df..8a9b0ec 100644 --- a/test/year-entrypoints.test.mjs +++ b/test/year-entrypoints.test.mjs @@ -303,6 +303,11 @@ test("year range and release guards fail closed", () => { assertYearContractsPreserved(JSON.stringify(contract), changedHashReport, { preview: true }), "major", ); + assert.doesNotThrow(() => assertYearContractsPreserved(JSON.stringify(contract), changedHashReport, { + reviewedVersion: true, + publishedVersion: "0.0.4", + stagedVersion: "0.1.0", + })); assert.doesNotThrow(() => assertYearContractsPreserved(JSON.stringify(contract), changedHashReport, { reviewedVersion: true, publishedVersion: "1.2.3", @@ -326,7 +331,10 @@ test("year range and release guards fail closed", () => { assert.doesNotThrow(() => assertExplicitVersionIncrease(undefined, "0.0.1")); assert.doesNotThrow(() => assertExplicitVersionIncrease("1.2.3", "1.2.4")); - assert.doesNotThrow(() => assertExplicitVersionIncrease("1.2.3-rc.2", "1.2.3-rc.10")); + assert.throws( + () => assertExplicitVersionIncrease("1.2.3-rc.2", "1.2.3-rc.10"), + /must be stable/, + ); assert.throws( () => assertExplicitVersionIncrease("1.2.3", "1.2.3"), /must be greater than 1\.2\.3/, @@ -337,7 +345,7 @@ test("year range and release guards fail closed", () => { ); assert.throws( () => assertExplicitVersionIncrease("1.2.3", "1.2.3-rc.1"), - /must be greater than 1\.2\.3/, + /must be stable/, ); for (const invalidVersion of [undefined, "not-semver", "01.2.3", "1.2.3-.", "1.2.3-01"]) { assert.throws(