diff --git a/apps/api/scripts/lint-meta/rules/ci/engine-pin-parity.ts b/apps/api/scripts/lint-meta/rules/ci/engine-pin-parity.ts index 5bd79493..4240aa29 100644 --- a/apps/api/scripts/lint-meta/rules/ci/engine-pin-parity.ts +++ b/apps/api/scripts/lint-meta/rules/ci/engine-pin-parity.ts @@ -31,7 +31,12 @@ function findParentPackageJsonDir(root: string): string | null { } } -export function checkEnginePinParity(root: string): IViolation[] { +const BUN_VERSION_PIN_REGEX = /bun-version:\s*["']?([\w.+-]+)["']?/gu; + +export function checkEnginePinParity( + root: string, + workflowFiles: readonly string[] = [] +): IViolation[] { const violations: IViolation[] = []; const pkg = readApiPackageJson(root); const bunVersion = pkg?.engines?.bun; @@ -78,17 +83,30 @@ export function checkEnginePinParity(root: string): IViolation[] { } } - const ciWorkflow = join(root, ".github", "workflows", "ci.yml"); + /* + * Every GitHub Actions workflow that pins a Bun version via + * `setup-bun` must match engines.bun. The monorepo keeps its workflows + * at the repo root, not under apps/api, and a single bump can leave one + * of a dozen `bun-version:` literals behind — so check every occurrence + * in every workflow, not just the first match. + */ + for (const workflow of workflowFiles) { + if (!existsSync(workflow)) { + continue; + } - if (existsSync(ciWorkflow)) { - const content = readFileSync(ciWorkflow, "utf8"); + const content = readFileSync(workflow, "utf8"); - if (!content.includes(`bun-version: ${bunVersion}`)) { - violations.push({ - file: ciWorkflow, - rule: RULE_ID, - message: `CI workflow must pin bun-version: ${bunVersion} to match package.json engines.bun.`, - }); + for (const match of content.matchAll(BUN_VERSION_PIN_REGEX)) { + const pinned = match[1]; + + if (pinned !== undefined && pinned !== bunVersion) { + violations.push({ + file: workflow, + rule: RULE_ID, + message: `CI workflow pins bun-version: ${pinned} but must match package.json engines.bun (${bunVersion}).`, + }); + } } } @@ -101,7 +119,7 @@ export const enginePinParityRule: IMetaRule = { category: "ci", description: "Bun version pin must stay aligned across package.json, Docker, and CI.", - run({ root }) { - return checkEnginePinParity(root); + run({ root, workflowFiles }) { + return checkEnginePinParity(root, workflowFiles); }, }; diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts index 3a34d7cf..62fb035e 100644 --- a/apps/api/tests/lint-meta/lint-meta.test.ts +++ b/apps/api/tests/lint-meta/lint-meta.test.ts @@ -1610,10 +1610,12 @@ describe("checkEnginePinParity", () => { workflowBun: "1.2.0", }); - const violations = checkEnginePinParity(root); + const violations = checkEnginePinParity(root, [ + join(root, ".github", "workflows", "ci.yml"), + ]); expect( - violations.some((row) => row.message.includes("bun-version: 1.3.14")) + violations.some((row) => row.message.includes("bun-version: 1.2.0")) ).toBe(true); } finally { rmSync(root, { recursive: true, force: true }); @@ -1630,13 +1632,51 @@ describe("checkEnginePinParity", () => { workflowBun: "1.3.14", }); - const violations = checkEnginePinParity(root); + const violations = checkEnginePinParity(root, [ + join(root, ".github", "workflows", "ci.yml"), + ]); expect(violations).toEqual([]); } finally { rmSync(root, { recursive: true, force: true }); } }); + + test("flags a drifted bun-version in any of several root workflows", () => { + const root = mkdtempSync(join(tmpdir(), "lint-meta-engine-multi-")); + + try { + writeFileSync( + join(root, PKG_JSON), + JSON.stringify({ engines: { bun: "1.3.14" } }) + ); + writeFileSync( + join(root, "Dockerfile"), + "FROM oven/bun:1.3.14-alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000\n" + ); + mkdirSync(join(root, ".github", "workflows"), { recursive: true }); + + const good = join(root, ".github", "workflows", "apps-ui-validate.yml"); + const drifted = join(root, ".github", "workflows", "apps-api-ci.yml"); + + writeFileSync( + good, + "jobs:\n a:\n steps:\n - uses: oven-sh/setup-bun@abc\n with:\n bun-version: 1.3.14\n" + ); + writeFileSync( + drifted, + "jobs:\n b:\n steps:\n - uses: oven-sh/setup-bun@abc\n with:\n bun-version: 1.2.0\n" + ); + + const violations = checkEnginePinParity(root, [good, drifted]); + + expect(violations).toHaveLength(1); + expect(violations[0]?.file).toBe(drifted); + expect(violations[0]?.message).toContain("bun-version: 1.2.0"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); describe("checkEnvSchemaDrift", () => { diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 255e491b..c8f381d7 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -5,6 +5,7 @@ import sitemap from "@astrojs/sitemap"; import starlight from "@astrojs/starlight"; import tailwindcss from "@tailwindcss/vite"; import mermaid from "astro-mermaid"; +import remarkGfm from "remark-gfm"; import starlightLlmsTxt from "starlight-llms-txt"; // Wrap every so wide content stays keyboard-scrollable in Safari/Firefox. @@ -107,6 +108,11 @@ export default defineConfig({ }, markdown: { + // GFM is normally Astro's default, but Starlight's MDX pipeline was not + // emitting
/ for pipe tables and strikethrough — every table + // in the docs rendered as literal `| … |` text. Wiring remark-gfm + // explicitly restores GFM table/strikethrough/autolink parsing. + remarkPlugins: [remarkGfm], rehypePlugins: [rehypeAccessibleTables], }, diff --git a/apps/docs/bun.lock b/apps/docs/bun.lock index 5ff38605..95445e5e 100644 --- a/apps/docs/bun.lock +++ b/apps/docs/bun.lock @@ -16,6 +16,7 @@ "react": "19.2.7", "react-dom": "19.2.7", "reading-time": "1.5.0", + "remark-gfm": "4.0.1", "sharp": "0.34.5", "tailwindcss": "4.3.0", "ws": "8.21.0", diff --git a/apps/docs/package.json b/apps/docs/package.json index 71acdc00..eb9fcab9 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -20,10 +20,11 @@ "check:scripts-docs": "bun run scripts/generate-scripts-docs.mjs --check", "check:docs-data": "bun run check:lint-meta-docs && bun run check:scripts-docs", "check:fragments": "bun run scripts/check-fragments.mjs", + "check:rendered-markdown": "bun run scripts/check-rendered-markdown.mjs", "check:components": "bun run scripts/check-unused-components.mjs", "build:site": "bun run generate:og-image && astro build", "build": "bun run generate:og-image && astro build", - "build:ci": "bun run check:docs-data && bun run check:components && bun run generate:og-image && astro build && bun run check:fragments", + "build:ci": "bun run check:docs-data && bun run check:components && bun run generate:og-image && astro build && bun run check:fragments && bun run check:rendered-markdown", "preview": "bun run build:site && wrangler dev", "astro": "astro", "deploy": "bun run build:ci && wrangler deploy", @@ -42,6 +43,7 @@ "react": "19.2.7", "react-dom": "19.2.7", "reading-time": "1.5.0", + "remark-gfm": "4.0.1", "sharp": "0.34.5", "tailwindcss": "4.3.0", "ws": "8.21.0" diff --git a/apps/docs/scripts/check-rendered-markdown.mjs b/apps/docs/scripts/check-rendered-markdown.mjs new file mode 100644 index 00000000..2b20f3b5 --- /dev/null +++ b/apps/docs/scripts/check-rendered-markdown.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/* + * Guard against GFM markdown that ships to readers as literal source text. + * + * A misconfigured remark pipeline (e.g. GFM not wired into Starlight's MDX + * processor) silently renders pipe tables as `| cell | cell |` and the + * `|---|---|` delimiter row as plain text instead of
. The build + * still succeeds, so the breakage only surfaces in the browser. This check + * scans the built HTML for that residue and fails the build if it finds any. + * + * Detected residue (outside
/, where such syntax is shown as a
+ * deliberate example):
+ *   - GFM table delimiter rows: `|---|`, `| --- |`, `|:--|`, etc.
+ *
+ * Usage: node scripts/check-rendered-markdown.mjs   (after `astro build`)
+ */
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { dirname, join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const DIST = resolve(dirname(fileURLToPath(import.meta.url)), "..", "dist");
+
+function walkHtml(dir) {
+  const out = [];
+  for (const entry of readdirSync(dir)) {
+    const full = join(dir, entry);
+    if (statSync(full).isDirectory()) {
+      out.push(...walkHtml(full));
+    } else if (entry.endsWith(".html")) {
+      out.push(full);
+    }
+  }
+  return out;
+}
+
+/* Reduce a page to its visible prose so we only test real rendered text.
+ * Order matters: drop code blocks (where literal table syntax is a legitimate
+ * example) first, then strip every remaining tag — this also removes attribute
+ * payloads like Expressive Code's `data-code="…"` copy-button cache, which
+ * mirrors the code sample and would otherwise read as a false positive. */
+function toProse(html) {
+  return html
+    .replace(//gi, " ")
+    .replace(//gi, " ")
+    .replace(/<[^>]+>/g, " ");
+}
+
+/* A GFM table delimiter row: a pipe adjacent to a run of 2+ dashes. Real
+ * prose effectively never contains this; a rendered table never does either
+ * (the delimiter becomes 
structure). */ +const DELIMITER = /\|\s*:?-{2,}|-{2,}:?\s*\|/; + +const offenders = []; +for (const file of walkHtml(DIST)) { + const text = toProse(readFileSync(file, "utf8")); + if (DELIMITER.test(text)) { + offenders.push(relative(DIST, file)); + } +} + +if (offenders.length > 0) { + console.error( + "✗ Unrendered GFM table syntax found in built HTML (GFM pipeline broken?):", + ); + for (const f of offenders) console.error(` • ${f}`); + console.error( + "\nPipe tables are rendering as literal text. Ensure remark-gfm is wired\n" + + "into markdown.remarkPlugins in astro.config.mjs.", + ); + process.exit(1); +} + +console.log("✓ No unrendered markdown table syntax in built HTML"); diff --git a/apps/ui/playwright.config.ts b/apps/ui/playwright.config.ts index b3d39539..a3fd2460 100644 --- a/apps/ui/playwright.config.ts +++ b/apps/ui/playwright.config.ts @@ -1,6 +1,8 @@ import { defineConfig, devices } from "@playwright/test"; -const PORT = 7331; +// Default dev/e2e port is 7331; override with PLAYWRIGHT_PORT when the UI +// dev server is remapped (Docker port mapping, port conflicts on shared hosts). +const PORT = Number(process.env.PLAYWRIGHT_PORT) || 7331; const BASE_URL = `http://localhost:${PORT}`; export default defineConfig({