From ccfa03a63c695f8ca9d147af289c4eeb5b8587e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:42:03 +0000 Subject: [PATCH 1/3] test(ui/scripts): the type-check and lint inputs guards and the lychee remap pin read the config that runs No turbo invocation in this repository reads ui/turbo.objectui.json and no workflow reads ui/lychee.toml: every `turbo run` resolves the root turbo.json, and check-links.yml passes the root lychee.toml. The guards whose property the root files carry now read them: `type-check` and package `lint` inputs through uiScopedTurboConfig, and the no-remap pin against the root lychee.toml. The guards whose property the root config does not carry keep reading the parked copies, unchanged, so both files stay. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QSeKYmTYfaPbLQD4BJfomW --- .../__tests__/check-links-workflow.test.ts | 12 +++++- ui/scripts/__tests__/helpers/turbo-inputs.ts | 40 +++++++++++++------ .../__tests__/turbo-lint-inputs.test.ts | 5 ++- .../__tests__/turbo-type-check-inputs.test.ts | 3 +- 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/ui/scripts/__tests__/check-links-workflow.test.ts b/ui/scripts/__tests__/check-links-workflow.test.ts index 52be428bace..665b99b36ea 100644 --- a/ui/scripts/__tests__/check-links-workflow.test.ts +++ b/ui/scripts/__tests__/check-links-workflow.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { workspaceRoot } from '../workspace-root.mjs'; /** * objectui#3449 — `check-links.yml` was pointed at the wrong tree. @@ -53,6 +54,15 @@ import { fileURLToPath } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const lycheeConfigPath = path.join(repoRoot, 'lychee.toml'); +/** + * The lychee config a run actually reads: the root `check-links.yml` passes + * `--config lychee.toml` from the repository root, and no workflow reads + * `ui/lychee.toml` (objectstack#26080). A pin whose property the root file + * carries reads this one; the `root_dir` sentinel pin below has no root + * counterpart (the root lane RESOLVES site routes with `--root-dir`), so it + * stays on the ui copy until that is decided. + */ +const liveLycheeConfigPath = path.join(workspaceRoot(), 'lychee.toml'); /** * This file's own leading block comment — the second surface the count pin reads. @@ -227,7 +237,7 @@ describe('lychee.toml — site-absolute routes are skipped, not resolved', () => // a parsed URL; `/docs/x` fails to parse first) and pointed at the internal // `docs/` tree besides. Route-to-file resolution has exactly one owner: // scripts/check-doc-links.mjs. - const config = fs.readFileSync(lycheeConfigPath, 'utf8'); + const config = fs.readFileSync(liveLycheeConfigPath, 'utf8'); const active = config .split('\n') .filter((line) => !/^\s*#/.test(line)) diff --git a/ui/scripts/__tests__/helpers/turbo-inputs.ts b/ui/scripts/__tests__/helpers/turbo-inputs.ts index f19e07c7c54..654badbad68 100644 --- a/ui/scripts/__tests__/helpers/turbo-inputs.ts +++ b/ui/scripts/__tests__/helpers/turbo-inputs.ts @@ -21,20 +21,37 @@ import fs from 'node:fs'; import path from 'node:path'; import { uiRoot } from '../../ui-root.mjs'; -import { uiTurboConfigPath, workspaceGlobs } from '../../workspace-root.mjs'; +import { uiScopedTurboConfig, uiTurboConfigPath, workspaceGlobs } from '../../workspace-root.mjs'; /** Repo root: the objectui tree, standalone or as the monorepo's `ui/`. */ export const repoRoot = uiRoot(); +/** The `tasks` shape these guards read out of a turbo config. */ +export interface TurboTasks { + readonly tasks?: Record; +} + +/** + * The config turbo RUNS: the nearest `turbo.json` upward, narrowed to this tree + * (`$TURBO_ROOT$/ui/x` read as `$TURBO_ROOT$/x`, the monorepo's own entries + * dropped) by `uiScopedTurboConfig`. Under the monorepo that is the root + * `turbo.json`; no turbo invocation reads anything else (objectstack#26080). + * A guard whose property the root config carries reads this one. + */ +export function liveTurboConfig(): TurboTasks { + return uiScopedTurboConfig(); +} + /** * objectui's OWN task declaration: `turbo.json` at the repo root, or the copy - * the cutover parks as `turbo.objectui.json` (a nested turbo.json must `extends` - * the root, so under the monorepo the live one is the root's, with these - * inputs re-rooted to `$TURBO_ROOT$/ui/...`). These guards pin the shapes - * objectui declares, relative to its own tree, so they read this one - * (objectstack#25723). + * the cutover parks as `turbo.objectui.json`. ⚠️ No turbo run reads the parked + * copy (objectstack#26080 measured every invocation); it is still the default + * here only for the guards whose property the root config does not carry yet + * (`build` and `test` inputs), so none of them is loosened by a retarget. */ -const turboConfigPath = uiTurboConfigPath(); +export function parkedTurboConfig(): TurboTasks { + return JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as TurboTasks; +} /** POSIX-separated, repo-relative. The one path spelling these guards compare in. */ export function rel(absolute: string): string { @@ -106,10 +123,7 @@ export function packagesWithScript(script: string): WorkspacePackage[] { * turbo's default, which is the defect all four guards exist to close, so the * absence throws rather than reading as an empty list. */ -export function taskInputs(task: string): string[] { - const turbo = JSON.parse(fs.readFileSync(turboConfigPath, 'utf8')) as { - tasks?: Record; - }; +export function taskInputs(task: string, turbo: TurboTasks = parkedTurboConfig()): string[] { const definition = turbo.tasks?.[task]; if (!definition) throw new Error(`turbo.json must define a \`${task}\` task`); const inputs = definition.inputs ?? []; @@ -124,8 +138,8 @@ export function taskInputs(task: string): string[] { * outside the package directory — turbo has no `..` escape, which is why * `$TURBO_ROOT$` exists at all. */ -export function rootAnchoredInputs(task: string): string[] { - return taskInputs(task) +export function rootAnchoredInputs(task: string, turbo: TurboTasks = parkedTurboConfig()): string[] { + return taskInputs(task, turbo) .filter((entry) => entry.startsWith('$TURBO_ROOT$/')) .map((entry) => entry.slice('$TURBO_ROOT$/'.length)); } diff --git a/ui/scripts/__tests__/turbo-lint-inputs.test.ts b/ui/scripts/__tests__/turbo-lint-inputs.test.ts index f1fae700468..24300b36eda 100644 --- a/ui/scripts/__tests__/turbo-lint-inputs.test.ts +++ b/ui/scripts/__tests__/turbo-lint-inputs.test.ts @@ -10,6 +10,7 @@ import { rel, repoFilesOnDisk, repoRoot, + liveTurboConfig, rootAnchoredInputs, taskInputs, } from './helpers/turbo-inputs'; @@ -88,7 +89,7 @@ const INPUTS_NOT_DERIVABLE: ReadonlyMap = new Map(); const PACKAGES = packagesWithScript(TASK); const DERIVED = PACKAGES.map((pkg) => ({ ...pkg, outside: outOfPackageFiles(pkg) })); -const ROOT_INPUTS = rootAnchoredInputs(TASK); +const ROOT_INPUTS = rootAnchoredInputs(TASK, liveTurboConfig()); const MATCHERS = ROOT_INPUTS.map((glob) => ({ glob, re: globToRegExp(glob) })); describe('turbo `lint` inputs cover every out-of-package file (objectui#4184)', () => { @@ -167,7 +168,7 @@ describe('turbo `lint` inputs cover every out-of-package file (objectui#4184)', */ it('the explicit inputs list still carries $TURBO_DEFAULT$', () => { expect( - taskInputs(TASK), + taskInputs(TASK, liveTurboConfig()), `turbo.json \`${TASK}\` declares an explicit inputs list, so it no longer falls back to ` + `turbo's default. Without "$TURBO_DEFAULT$" in the list the package's OWN files stop ` + `being hashed.`, diff --git a/ui/scripts/__tests__/turbo-type-check-inputs.test.ts b/ui/scripts/__tests__/turbo-type-check-inputs.test.ts index 42907e797d1..17e511cf7d1 100644 --- a/ui/scripts/__tests__/turbo-type-check-inputs.test.ts +++ b/ui/scripts/__tests__/turbo-type-check-inputs.test.ts @@ -4,6 +4,7 @@ import { packagesWithScript, rel, repoFilesOnDisk, + liveTurboConfig, rootAnchoredInputs, } from './helpers/turbo-inputs'; import { outOfPackageFiles } from './helpers/tsc-program'; @@ -131,7 +132,7 @@ const INPUTS_NOT_DERIVABLE: ReadonlyMap = new Map([ const PACKAGES = packagesWithScript(TASK); const DERIVED = PACKAGES.map((pkg) => ({ ...pkg, outside: outOfPackageFiles(pkg.dir, pkg.script) })); -const ROOT_INPUTS = rootAnchoredInputs(TASK); +const ROOT_INPUTS = rootAnchoredInputs(TASK, liveTurboConfig()); const MATCHERS = ROOT_INPUTS.map((glob) => ({ glob, re: globToRegExp(glob) })); describe('turbo `type-check` inputs cover every out-of-package file (objectui#3514)', () => { From 9a1075c3f03de68c246d9769bc07854874735b9b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:25:08 +0000 Subject: [PATCH 2/3] test(ui/scripts): the site-route pin reads check-links.yml's argv; delete the unread ui/lychee.toml The pin held objectui's root_dir sentinel in ui/lychee.toml, a file no workflow reads. The lane that runs, the root check-links.yml, resolves site-absolute routes with --root-dir at the content root plus --fallback-extensions, so the pin now reads that argv: the root lychee.toml config, an absolute --root-dir over the tree /docs routes live under, and the mdx/md fallbacks. With its last reader retargeted, ui/lychee.toml goes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QSeKYmTYfaPbLQD4BJfomW --- ui/lychee.toml | 111 ------------------ .../__tests__/check-links-workflow.test.ts | 82 ++++++++----- 2 files changed, 55 insertions(+), 138 deletions(-) delete mode 100644 ui/lychee.toml diff --git a/ui/lychee.toml b/ui/lychee.toml deleted file mode 100644 index b33a5c1fe43..00000000000 --- a/ui/lychee.toml +++ /dev/null @@ -1,111 +0,0 @@ -# Lychee configuration file for ObjectUI documentation link checking -# (used by `.github/workflows/check-links.yml` — schedule + workflow_dispatch). -# -# Lychee checks two classes of link here: EXTERNAL URLs, and RELATIVE in-repo -# file links (`docs/**` links into `../../packages/**` source files rely on -# that). Site-ABSOLUTE routes — `/docs/...`, `/api/...`, `/protocol/...` — are -# not its job; those belong to `scripts/check-doc-links.mjs`, run by -# `repo-gates.yml` (the Internal Docs Link Check step). See the `root_dir` -# section below. - -# Maximum number of concurrent requests -max_concurrency = 10 - -# Timeout for each request (in seconds) -timeout = 20 - -# Number of retries per request -max_retries = 3 - -# Accept any valid status code (2xx, 3xx) -accept = [200, 204, 206, 301, 302, 303, 307, 308] - -# Exclude patterns - files and URLs to skip -exclude = [ - # Local development URLs - "http://localhost*", - "https://localhost*", - - # Example and placeholder URLs - "https://example.com", - "http://example.com", - - # Social media (anti-scraping) - "https://twitter.com*", - "https://x.com*", - - # GitHub specific patterns that may cause false positives - "https://github.com/.*/compare/*", - "https://github.com/.*/commit/*", - - # npmjs.com PACKAGE PAGES — the website answers a CI runner with 403, and it - # does so for every package page, live or dead (objectui#8128). Measured on - # scheduled runs 31295178818 (2026-08-09) and 34011606950 (2026-09-06): all - # seven `www.npmjs.com/package/@object-ui/*` links in the swept trees came - # back `403 Forbidden`, the same seven in both runs a month apart. That is - # anti-scraping, not link rot — `registry.npmjs.org` answered `200` for all - # seven on 2026-09-07, `dist-tags.latest = 17.6.0`, so every one of those - # package pages exists and renders in a browser. npm returns 404, not 403, - # for a package that is not there. - # - # ⚠️ Scoped to the whole `/package/` path on purpose, NOT to `@object-ui/*`. - # The 403 is the host's answer to the client, so it does not depend on which - # package is named: a third-party npm link would be rejected identically, and - # a per-URL list would grow a line every time a doc page names a package — - # the unbounded-maintenance shape this file exists to avoid. - # - # ⛔ This hides NO detection the sweep ever had. A blanket 403 carries zero - # information about the target, so there is no true verdict being suppressed - # here — only noise that made 7 of this workflow's 10 rejections unreadable. - # What genuinely is NOT covered by anything is filed as objectui#8382 - # rather than left implied: whether the packages these pages describe are - # still PUBLISHED. That question has a working instrument — - # `registry.npmjs.org`, used for the measurement above — and it is not this - # workflow's job. - "^https://www\\.npmjs\\.com/package/", - - # Site-absolute routes, resolved into the sentinel namespace by `root_dir` - # below and skipped wholesale here. Rationale: see `root_dir`. - "^file:///__site-routes-not-on-disk__/", -] - -# Why site-absolute routes (`/docs/...`) are handled this way -# -# When a local file contains an absolute-path link, Lychee without a `root_dir` -# reports a HARD error: -# [ERROR] Cannot resolve root-relative link '/docs/plugins': -# To resolve root-relative links in local files, provide a root dir -# and that failure happens while the URI is being constructed, BEFORE the -# exclude filter runs — adding `^/docs` or `^/` to `exclude` is measurably a -# no-op: the errors are still reported and the exit code is still 2. -# `content/docs/**` contains 297 such links, so once #3449 widened the scan to -# `content/docs`, leaving them unhandled would make this workflow red from its -# very first run — trading a false-green report on the wrong tree for a -# false-red one on the right tree, which is no better. -# -# So: `root_dir` resolves site-absolute routes into a sentinel namespace that -# does not exist on disk, and the exclude entry above skips that namespace -# entirely. `root_dir` must be an absolute path, and the repository's absolute -# path differs between a local checkout and a runner, so it can only be a -# repo-independent constant — the sentinel is forced by that constraint, not -# chosen for cuteness. Expect one line of output per run: -# [WARN] Root dir '/__site-routes-not-on-disk__' does not exist -# That is intended; the directory is not supposed to exist, and its name is the -# explanation. -# -# Why not have Lychee resolve these routes properly instead: fumadocs routes -# carry no extension (`/docs/guide/data-source` -> `content/docs/guide/ -# data-source.md`), so judging them needs a real route-to-file mapping. That -# mapping already exists — `scripts/check-doc-links.mjs` — and a second copy -# here would only be a second copy free to drift from the first. -# -# (This section replaced `remap = ["^/docs/(.*)$ file://./docs/$1"]`, which had -# never once fired: `remap` operates on an already-parsed URL, and `/docs/x` -# fails to parse before it ever reaches remapping. Its replacement target was -# wrong twice over anyway — no extension, and pointing at the internal `docs/` -# rather than the site's `content/docs/`.) -root_dir = "/__site-routes-not-on-disk__" - -# Cache configuration -# Don't use cache to ensure fresh results -cache = false diff --git a/ui/scripts/__tests__/check-links-workflow.test.ts b/ui/scripts/__tests__/check-links-workflow.test.ts index 665b99b36ea..74d2ca5fadb 100644 --- a/ui/scripts/__tests__/check-links-workflow.test.ts +++ b/ui/scripts/__tests__/check-links-workflow.test.ts @@ -26,13 +26,17 @@ import { workspaceRoot } from '../workspace-root.mjs'; * tree again and this test goes red on the same day, instead of the * workflow going quietly blind for another year. * - * 2. **The `root_dir` / `exclude` pair in `lychee.toml`.** Widening the scope - * without it does not produce a working sweep, it produces 316 hard errors: - * `content/docs` carries ~297 site-absolute links (`/docs/...`), and Lychee - * fails those while CONSTRUCTING the URI — before `exclude` is consulted, - * so an `^/docs` exclude pattern cannot suppress them. The two settings only - * work together; removing either alone re-breaks the sweep, so neither may - * leave without the other. + * 2. **Site-absolute routes are given a root.** Widening the scope without + * one does not produce a working sweep, it produces hundreds of hard errors: + * `content/docs` carries site-absolute links (`/docs/...`), and Lychee fails + * those while CONSTRUCTING the URI — before `exclude` is consulted, so an + * `^/docs` exclude pattern cannot suppress them. objectui's `lychee.toml` + * answered with a `root_dir` sentinel plus its exclude. The lane that runs + * now, the root `check-links.yml`, answers with `--root-dir` at the content + * root plus `--fallback-extensions`, so the routes RESOLVE; this pin reads + * that argv (objectstack#26080). The two flags only work together: a route + * omits its extension, so a root alone turns every route into "file not + * found". * * 3. **No document count, here or in the workflow's header.** objectui#7825. * Both prose sentences above once carried a hand-copied size — one per tree @@ -52,17 +56,28 @@ import { workspaceRoot } from '../workspace-root.mjs'; * and this header's own count pin stand. */ -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const lycheeConfigPath = path.join(repoRoot, 'lychee.toml'); /** - * The lychee config a run actually reads: the root `check-links.yml` passes - * `--config lychee.toml` from the repository root, and no workflow reads - * `ui/lychee.toml` (objectstack#26080). A pin whose property the root file - * carries reads this one; the `root_dir` sentinel pin below has no root - * counterpart (the root lane RESOLVES site routes with `--root-dir`), so it - * stays on the ui copy until that is decided. + * The lychee run that exists: the root `check-links.yml`, which passes + * `--config lychee.toml` from the repository root. objectui's own + * `lychee.toml` was read by no workflow and is gone (objectstack#26080). */ -const liveLycheeConfigPath = path.join(workspaceRoot(), 'lychee.toml'); +const liveRoot = workspaceRoot(); +const liveLycheeConfigPath = path.join(liveRoot, 'lychee.toml'); +const checkLinksWorkflowPath = path.join(liveRoot, '.github/workflows/check-links.yml'); + +/** The lychee argv `check-links.yml` declares once in `env.LYCHEE_ARGS`, one token per entry. */ +function lycheeArgs(): string[] { + const lines = fs.readFileSync(checkLinksWorkflowPath, 'utf8').split('\n'); + const start = lines.findIndex((line) => /^\s*LYCHEE_ARGS:\s*>-\s*$/.test(line)); + expect(start, 'check-links.yml no longer declares LYCHEE_ARGS as a folded block').toBeGreaterThan(-1); + const indent = /^\s*/.exec(lines[start])![0].length; + const block: string[] = []; + for (const line of lines.slice(start + 1)) { + if (line.trim() !== '' && /^\s*/.exec(line)![0].length <= indent) break; + block.push(line.trim()); + } + return block.join(' ').split(/\s+/).filter(Boolean); +} /** * This file's own leading block comment — the second surface the count pin reads. @@ -219,17 +234,30 @@ describe('check-links — the header count pin', () => { }); }); -describe('lychee.toml — site-absolute routes are skipped, not resolved', () => { - it('resolves site-absolute routes into a sentinel root and excludes it', () => { - // Both halves or neither: `root_dir` alone turns 297 unresolvable links into - // 297 "file not found" errors, and the exclude alone cannot fire at all, - // because the failure happens before filtering. - const config = fs.readFileSync(lycheeConfigPath, 'utf8'); - const rootDir = /^\s*root_dir\s*=\s*"([^"]+)"/m.exec(config); - expect(rootDir, 'lychee.toml must set root_dir, or site-absolute links are hard errors').not.toBeNull(); - expect(rootDir![1].startsWith('/'), 'root_dir must be an absolute path').toBe(true); - expect(fs.existsSync(rootDir![1]), 'root_dir is a sentinel namespace and must not exist on disk').toBe(false); - expect(config, `root_dir ${rootDir![1]} is set but never excluded`).toContain(`"^file://${rootDir![1]}/`); +describe('check-links — site-absolute routes are resolved against the content root', () => { + it('hands lychee a root for site-absolute routes, and the extensions a route omits', () => { + // Both halves or neither: without `--root-dir` every `/docs/...` link is a + // hard error before any filter runs, and a root without + // `--fallback-extensions` turns every extension-less route into a + // "file not found". + const args = lycheeArgs(); + const value = (flag: string): string | undefined => { + const at = args.indexOf(flag); + return at === -1 ? undefined : args[at + 1]; + }; + expect(value('--config'), 'the lane must read the root lychee.toml').toBe('lychee.toml'); + + const rootDir = value('--root-dir'); + expect(rootDir, 'check-links.yml must pass --root-dir, or site-absolute links are hard errors').toBeDefined(); + const workspace = '${{ github.workspace }}/'; + expect(rootDir!.startsWith(workspace), '--root-dir must be absolute, i.e. anchored on the workspace').toBe(true); + const rel = rootDir!.slice(workspace.length); + expect(fs.existsSync(path.join(liveRoot, rel, 'docs')), `--root-dir ${rel} must be the tree \`/docs/...\` routes live under`).toBe(true); + + const fallback = (value('--fallback-extensions') ?? '').split(','); + expect(fallback, '--root-dir alone leaves every extension-less route unresolved').toEqual( + expect.arrayContaining(['mdx', 'md']), + ); }); it('carries no remap pretending to resolve fumadocs routes', () => { From 88bd2bb7e17ba21e20faedb61e0bffc46043646d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:26:11 +0000 Subject: [PATCH 3/3] test(ui/scripts): read a ${{ }} expression in the lychee argv as one token Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QSeKYmTYfaPbLQD4BJfomW --- ui/scripts/__tests__/check-links-workflow.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/ui/scripts/__tests__/check-links-workflow.test.ts b/ui/scripts/__tests__/check-links-workflow.test.ts index 74d2ca5fadb..755a456ea33 100644 --- a/ui/scripts/__tests__/check-links-workflow.test.ts +++ b/ui/scripts/__tests__/check-links-workflow.test.ts @@ -65,7 +65,11 @@ const liveRoot = workspaceRoot(); const liveLycheeConfigPath = path.join(liveRoot, 'lychee.toml'); const checkLinksWorkflowPath = path.join(liveRoot, '.github/workflows/check-links.yml'); -/** The lychee argv `check-links.yml` declares once in `env.LYCHEE_ARGS`, one token per entry. */ +/** + * The lychee argv `check-links.yml` declares once in `env.LYCHEE_ARGS`, one + * token per entry. A `${{ … }}` expression is collapsed to one token first + * (`${{github.workspace}}/content`), so its inner spaces do not split it. + */ function lycheeArgs(): string[] { const lines = fs.readFileSync(checkLinksWorkflowPath, 'utf8').split('\n'); const start = lines.findIndex((line) => /^\s*LYCHEE_ARGS:\s*>-\s*$/.test(line)); @@ -76,7 +80,11 @@ function lycheeArgs(): string[] { if (line.trim() !== '' && /^\s*/.exec(line)![0].length <= indent) break; block.push(line.trim()); } - return block.join(' ').split(/\s+/).filter(Boolean); + return block + .join(' ') + .replace(/\$\{\{\s*([^}]*?)\s*\}\}/g, (_, expr: string) => `\${{${expr}}}`) + .split(/\s+/) + .filter(Boolean); } /** @@ -249,7 +257,7 @@ describe('check-links — site-absolute routes are resolved against the content const rootDir = value('--root-dir'); expect(rootDir, 'check-links.yml must pass --root-dir, or site-absolute links are hard errors').toBeDefined(); - const workspace = '${{ github.workspace }}/'; + const workspace = '${{github.workspace}}/'; expect(rootDir!.startsWith(workspace), '--root-dir must be absolute, i.e. anchored on the workspace').toBe(true); const rel = rootDir!.slice(workspace.length); expect(fs.existsSync(path.join(liveRoot, rel, 'docs')), `--root-dir ${rel} must be the tree \`/docs/...\` routes live under`).toBe(true);