Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 0 additions & 111 deletions ui/lychee.toml

This file was deleted.

88 changes: 67 additions & 21 deletions ui/scripts/__tests__/check-links-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -25,13 +26,17 @@ import { fileURLToPath } from 'node:url';
* 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
Expand All @@ -51,8 +56,36 @@ import { fileURLToPath } from 'node:url';
* 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 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 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. 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));
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(' ')
.replace(/\$\{\{\s*([^}]*?)\s*\}\}/g, (_, expr: string) => `\${{${expr}}}`)
.split(/\s+/)
.filter(Boolean);
}

/**
* This file's own leading block comment — the second surface the count pin reads.
Expand Down Expand Up @@ -209,25 +242,38 @@ 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', () => {
// The removed `^/docs/(.*)$ file://./docs/$1` never fired (remap operates on
// 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))
Expand Down
40 changes: 27 additions & 13 deletions ui/scripts/__tests__/helpers/turbo-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { inputs?: string[] }>;
}

/**
* 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 {
Expand Down Expand Up @@ -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<string, { inputs?: string[] }>;
};
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 ?? [];
Expand All @@ -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));
}
Expand Down
5 changes: 3 additions & 2 deletions ui/scripts/__tests__/turbo-lint-inputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
rel,
repoFilesOnDisk,
repoRoot,
liveTurboConfig,
rootAnchoredInputs,
taskInputs,
} from './helpers/turbo-inputs';
Expand Down Expand Up @@ -88,7 +89,7 @@ const INPUTS_NOT_DERIVABLE: ReadonlyMap<string, string> = 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)', () => {
Expand Down Expand Up @@ -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.`,
Expand Down
3 changes: 2 additions & 1 deletion ui/scripts/__tests__/turbo-type-check-inputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
packagesWithScript,
rel,
repoFilesOnDisk,
liveTurboConfig,
rootAnchoredInputs,
} from './helpers/turbo-inputs';
import { outOfPackageFiles } from './helpers/tsc-program';
Expand Down Expand Up @@ -131,7 +132,7 @@ const INPUTS_NOT_DERIVABLE: ReadonlyMap<string, string> = 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)', () => {
Expand Down
Loading