diff --git a/.github/workflows/site-generated-docs-check.yml b/.github/workflows/site-generated-docs-check.yml new file mode 100644 index 000000000..94659e42c --- /dev/null +++ b/.github/workflows/site-generated-docs-check.yml @@ -0,0 +1,84 @@ +name: site-generated-docs-check + +# PR gate for apps/site's generated-docs pipeline: verify's own `tsc` pass +# (types:check) and the root generated-artifacts drift check +# (generated-artifacts:check), both of which currently run ONLY post-merge +# (generated-artifacts:check in semantic-release.yml, gated on a version +# actually publishing) or not at all in CI (types:check/verify are never +# invoked by any workflow). That gap let two separate defects reach main +# undetected: a stale tsconfig path mapping that broke `pnpm --dir apps/site +# run verify` on a fresh checkout, and hand-edited generated output +# (apps/site/scripts/sync-spec-docs.mjs) that drifted from the TypeScript +# source it's compiled from. Neither is caught by spec-check.yml's "root/site +# spec drift" job, which only diffs generated markdown against the root +# specs. +# +# Deliberately NOT the full `verify` script: `verify` also runs the test +# suite and a production `next build`, which is real coverage but not what +# closes this specific gap and would roughly double this job's wall time for +# no additional defect coverage here. `types:check` alone reproduces the +# tsconfig defect (TS2307/TS2305-class failures); generated-artifacts:check +# alone reproduces the compiled-output drift. Both run in well under the +# `pnpm install` time this job already pays. + +on: + push: + branches: [main] + paths: + - ".github/workflows/site-generated-docs-check.yml" + - "apps/site/**" + - "docs/design-system/ink-carbon/project/**" + - "scripts/check-generated-artifacts.ts" + - "package.json" + - "pnpm-lock.yaml" + pull_request: + paths: + - ".github/workflows/site-generated-docs-check.yml" + - "apps/site/**" + - "docs/design-system/ink-carbon/project/**" + - "scripts/check-generated-artifacts.ts" + - "package.json" + - "pnpm-lock.yaml" + workflow_dispatch: {} + +concurrency: + group: site-generated-docs-check-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: site types:check + generated-artifacts:check + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - name: Install Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: "pnpm" + + - name: Install dependencies + env: + PATCHRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + run: pnpm install --frozen-lockfile + + # Runs with the same PDPP_SITE_SOURCE_DIR/PDPP_SITE_TSCONFIG isolation + # as `pnpm --dir apps/site run verify`, so this job exercises the exact + # tsconfig this gate exists to protect (tsconfig.verify.json), not the + # default tsconfig.json a plain `types:check` would use. + - name: Site types:check (verify mode) + env: + PDPP_SITE_SOURCE_DIR: .source-verify + PDPP_SITE_TSCONFIG: tsconfig.verify.json + run: pnpm --dir apps/site run types:check + + - name: Generated artifacts are not drifted + run: pnpm generated-artifacts:check diff --git a/apps/site/biome.jsonc b/apps/site/biome.jsonc index 67d9bb4b2..9a295dd85 100644 --- a/apps/site/biome.jsonc +++ b/apps/site/biome.jsonc @@ -89,6 +89,32 @@ } } }, + { + // Upstream Biome resolver false-positive waiver — NOT a doctrine change. + // + // scripts/style-differ.mjs does `import { chromium } from "playwright"`. + // playwright's index.d.ts is `export * from 'playwright-core'`, and + // playwright-core is an indirect dependency here (pulled in + // transitively, not listed in this package's own package.json), so + // under pnpm's isolated node_modules it has no symlink inside + // apps/site/node_modules for Biome's resolver to follow. The import + // resolves cleanly under TypeScript (moduleResolution: "bundler") and + // at runtime (verified: `node -e "require('playwright').chromium"` + // and the ESM equivalent both return the launcher). Same upstream gap + // as the src/** waiver above (biomejs/biome#9143, #6464); this file + // sits outside src/** so that override does not reach it. + // + // Exit condition: same as the src/** override above — drop once + // Biome's resolver follows re-exports through transitive dependencies. + "includes": ["scripts/style-differ.mjs"], + "linter": { + "rules": { + "correctness": { + "noUnresolvedImports": "off" + } + } + } + }, { // Architectural shim waiver — NOT a barrel-file pattern. // diff --git a/apps/site/next.config.mjs b/apps/site/next.config.mjs index 61d499abd..55c629e91 100644 --- a/apps/site/next.config.mjs +++ b/apps/site/next.config.mjs @@ -9,7 +9,7 @@ import { collectAllowedDevOrigins } from "./scripts/dev-origins.ts"; import { resolveSiteRuntime } from "./scripts/site-runtime.ts"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); -const { buildWorkers, distDir, isProduction, sourceDir } = resolveSiteRuntime(); +const { buildWorkers, distDir, isProduction, sourceDir, tsconfigPath } = resolveSiteRuntime(); const withMDX = createMDX({ outDir: sourceDir }); const allowedDevOrigins = isProduction ? [] : collectAllowedDevOrigins(); @@ -27,6 +27,17 @@ const nextConfig = { }, output: "standalone", serverExternalPackages: ["fumadocs-mdx", "satteri"], + // `next build`'s own internal type-check pass reads tsconfig.json's + // "paths" directly — it does not go through the webpack/turbopack + // aliases below, which the bundler itself uses instead. Point it at the + // same per-mode tsconfig apps/site/package.json's "types:check" script + // selects via PDPP_SITE_TSCONFIG (see scripts/site-runtime.ts), so a + // `verify` build's typecheck resolves "@generated-docs/*" against + // ".source-verify" like `verify`'s own separate `tsc -p` pass does, not + // against the base config's ".source". + typescript: { + tsconfigPath, + }, turbopack: { resolveAlias: { "@generated-docs/*": `./${sourceDir}/*`, diff --git a/apps/site/package.json b/apps/site/package.json index 2f81691ba..72f380bb1 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -8,6 +8,7 @@ "//dev-node-options": "NODE_OPTIONS adds Node.js diagnostic reports on fatal V8 errors and uncaught exceptions. Reports land in tmp/node-reports/ at the repo root (gitignored), excluding env/network sections so reports do not persist secrets.", "//dev-port": "Public site defaults to :3001 (design.md §5). PDPP_SITE_PORT overrides; PDPP_WEB_PORT kept as a fallback for existing contributor env files.", "//sync-specs": "Generates content/docs/spec-*.md from the normative root spec-*.md (single source). The generated files are gitignored; predev/prebuild regenerate them. See scripts/sync-spec-docs.mjs.", + "//types-check-tsconfig": "PDPP_SITE_TSCONFIG selects which tsconfig types:check's tsc pass uses; default tsconfig.json resolves @generated-docs/* against .source only. A mode whose PDPP_SITE_SOURCE_DIR points elsewhere (currently just verify, via .source-verify) must also set PDPP_SITE_TSCONFIG to a config whose paths match that directory, or tsc can silently resolve a stale .source left by an unrelated dev/build run instead of the output this run just generated. See tsconfig.verify.json.", "prebuild": "node scripts/sync-spec-docs.mjs", "build": "next build --webpack", "check": "ultracite check && pnpm --dir ../.. generated-artifacts:check", @@ -19,8 +20,8 @@ "start": "next start", "sync:specs": "node scripts/sync-spec-docs.mjs", "test": "node --import tsx ../../scripts/test-scratch/run-command.ts -- node ../../scripts/run-node-tests.mjs --test --import tsx --import ../../packages/operator-ui/css-stub-register.ts \"scripts/*.test.ts\" \"src/**/*.test.ts\"", - "types:check": "node scripts/sync-spec-docs.mjs && fumadocs-mdx source.config.ts \"${PDPP_SITE_SOURCE_DIR:-.source}\" && next typegen && tsc --noEmit", - "verify": "sh -c 'export PDPP_SITE_NEXT_DIST_DIR=.next-verify PDPP_SITE_SOURCE_DIR=.source-verify; pnpm types:check && pnpm test && pnpm check && pnpm build'" + "types:check": "node scripts/sync-spec-docs.mjs && fumadocs-mdx source.config.ts \"${PDPP_SITE_SOURCE_DIR:-.source}\" && next typegen && tsc -p \"${PDPP_SITE_TSCONFIG:-tsconfig.json}\" --noEmit", + "verify": "sh -c 'export PDPP_SITE_NEXT_DIST_DIR=.next-verify PDPP_SITE_SOURCE_DIR=.source-verify PDPP_SITE_TSCONFIG=tsconfig.verify.json; pnpm types:check && pnpm test && pnpm check && pnpm build'" }, "dependencies": { "@base-ui/react": "^1.8.0", diff --git a/apps/site/scripts/site-runtime.test.ts b/apps/site/scripts/site-runtime.test.ts index b9567b1c4..19336506e 100644 --- a/apps/site/scripts/site-runtime.test.ts +++ b/apps/site/scripts/site-runtime.test.ts @@ -11,6 +11,7 @@ const DEV_DIST_DIR = /PDPP_SITE_NEXT_DIST_DIR=\.next-dev/; const DEV_SOURCE_DIR = /PDPP_SITE_SOURCE_DIR=\.source-dev/; const VERIFY_DIST_DIR = /PDPP_SITE_NEXT_DIST_DIR=\.next-verify/; const VERIFY_SOURCE_DIR = /PDPP_SITE_SOURCE_DIR=\.source-verify/; +const VERIFY_TSCONFIG = /PDPP_SITE_TSCONFIG=tsconfig\.verify\.json/; test("development omits the production worker limit", () => { assert.deepEqual(resolveSiteRuntime({ NODE_ENV: "development", PDPP_SITE_NEXT_DIST_DIR: ".next-dev" }), { @@ -18,6 +19,7 @@ test("development omits the production worker limit", () => { distDir: ".next-dev", isProduction: false, sourceDir: ".source", + tsconfigPath: "tsconfig.json", }); }); @@ -26,6 +28,20 @@ test("production defaults to one worker and accepts an explicit override", () => assert.equal(resolveSiteRuntime({ NODE_ENV: "production", PDPP_WEB_BUILD_WORKERS: "8" }).buildWorkers, 8); }); +// The default sourceDir/tsconfigPath pairing must stay consistent: a mode +// that does not set PDPP_SITE_SOURCE_DIR resolves "@generated-docs/*" via +// tsconfig.json's own ".source" paths entry, so a plain resolveSiteRuntime() +// call must also default to that same tsconfig, not verify's. +test("default source directory pairs with the base tsconfig, not a per-mode override", () => { + const runtime = resolveSiteRuntime({}); + assert.equal(runtime.sourceDir, ".source"); + assert.equal(runtime.tsconfigPath, "tsconfig.json"); +}); + +test("PDPP_SITE_TSCONFIG overrides the default tsconfig path", () => { + assert.equal(resolveSiteRuntime({ PDPP_SITE_TSCONFIG: "tsconfig.verify.json" }).tsconfigPath, "tsconfig.verify.json"); +}); + test("supported development and verification commands select distinct output directories", async () => { const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as { scripts: Record; @@ -40,3 +56,19 @@ test("supported development and verification commands select distinct output dir assert.match(verify, VERIFY_SOURCE_DIR); assert.notEqual(dev, verify); }); + +// verify's own tsc pass (types:check) and next build's internal type-check +// must resolve "@generated-docs/*" against .source-verify, not the base +// tsconfig's ".source" — see tsconfig.verify.json and next.config.mjs's +// typescript.tsconfigPath wiring. A stale .source left by an unrelated +// dev/build run must never be able to mask a type error in verify's own +// freshly generated output. +test("verify selects its own tsconfig, distinct from the base config", async () => { + const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as { + scripts: Record; + }; + + const { verify } = packageJson.scripts; + assert.ok(verify); + assert.match(verify, VERIFY_TSCONFIG); +}); diff --git a/apps/site/scripts/site-runtime.ts b/apps/site/scripts/site-runtime.ts index e7cfdb183..175f35c61 100644 --- a/apps/site/scripts/site-runtime.ts +++ b/apps/site/scripts/site-runtime.ts @@ -5,6 +5,7 @@ interface SiteRuntimeEnvironment { NODE_ENV?: string; PDPP_SITE_NEXT_DIST_DIR?: string; PDPP_SITE_SOURCE_DIR?: string; + PDPP_SITE_TSCONFIG?: string; PDPP_WEB_BUILD_WORKERS?: string; } @@ -27,5 +28,11 @@ export function resolveSiteRuntime(environment: SiteRuntimeEnvironment = process distDir: environment.PDPP_SITE_NEXT_DIST_DIR ?? ".next", isProduction, sourceDir: environment.PDPP_SITE_SOURCE_DIR ?? ".source", + // Same PDPP_SITE_TSCONFIG that package.json's "types:check" script + // passes to `tsc -p`, so `next build`'s own internal type-check pass + // (which reads tsconfig.json directly, not through the webpack/ + // turbopack "@generated-docs" aliases) resolves against the same + // directory as the rest of the current run. See next.config.mjs. + tsconfigPath: environment.PDPP_SITE_TSCONFIG ?? "tsconfig.json", }; } diff --git a/apps/site/scripts/sync-spec-docs.mts b/apps/site/scripts/sync-spec-docs.mts index 0467a0c0d..95dc6cd03 100644 --- a/apps/site/scripts/sync-spec-docs.mts +++ b/apps/site/scripts/sync-spec-docs.mts @@ -31,7 +31,7 @@ // Runs from `predev` and `prebuild`. Vercel builds from apps/site with the // monorepo root available, so the relative path to the repo root resolves. -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -155,6 +155,7 @@ function checkStatusDateDrift(header, root, spec) { } let generated = 0; +let staleRemoved = 0; for (const spec of SPECS) { const rootPath = path.join(repoRoot, `${spec}.md`); const headerPath = path.join(headerDir, `${spec}.header.md`); @@ -174,6 +175,16 @@ for (const spec of SPECS) { // header (frontmatter + Callout) + blank line + normative body. const out = `${header}\n\n${root.body.replace(/\s*$/, "")}\n`; writeFileSync(outPath, out); + // An earlier revision of this script wrote `.md` here. A leftover from one + // of those runs is not overwritten by the `.mdx` write above, and fumadocs + // globs both, so the two resolve to the same slug and fail the build with + // "Duplicated slugs". Clearing it makes a stale checkout self-heal instead + // of failing a build that looks unrelated to anything the author changed. + const stalePath = path.join(contentDir, `${spec}.md`); + if (existsSync(stalePath)) { + rmSync(stalePath); + staleRemoved += 1; + } generated += 1; } @@ -455,6 +466,9 @@ export const PRINCIPLES_LIST = ${JSON.stringify(principles, null, 2)} as const; ); console.log(`[sync-spec-docs] generated ${generated} spec page(s) from root spec-*.md`); +if (staleRemoved > 0) { + console.log(`[sync-spec-docs] removed ${staleRemoved} stale .md page(s) left by an earlier revision of this script`); +} console.log( `[sync-spec-docs] wrote src/generated/spec-front-matter.ts (${versionMatch[1]}, ${status}, ${date}, ${editors.length} editor(s); ` + `governance: ${governanceFrontMatter.status}; principles: v${principlesFrontMatter.version})` diff --git a/apps/site/tsconfig.json b/apps/site/tsconfig.json index 4940cad8d..f6263dcbe 100644 --- a/apps/site/tsconfig.json +++ b/apps/site/tsconfig.json @@ -25,6 +25,10 @@ "name": "next" } ], + // Resolves generated docs from ".source", the default PDPP_SITE_SOURCE_DIR. + // tsc cannot read env vars in "paths", so a mode that writes elsewhere + // needs its own tsconfig; see tsconfig.verify.json. Do not list several + // directories here: tsc picks the first that exists, which can be stale. "paths": { "@/*": ["./src/*"], "@generated-docs/*": ["./.source/*"], diff --git a/apps/site/tsconfig.verify.json b/apps/site/tsconfig.verify.json new file mode 100644 index 000000000..e40f00baa --- /dev/null +++ b/apps/site/tsconfig.verify.json @@ -0,0 +1,14 @@ +{ + // Used by `verify` (PDPP_SITE_SOURCE_DIR=.source-verify, selected through + // PDPP_SITE_TSCONFIG). Resolves generated docs only from the output that + // verify just wrote, so a stale ".source" from a dev or build run cannot + // hide a type error. + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"], + "@generated-docs/*": ["./.source-verify/*"], + "collections/*": ["./.source-verify/*"] + } + } +}