diff --git a/.changeset/25723-workspace-root-readers.md b/.changeset/25723-workspace-root-readers.md new file mode 100644 index 0000000000..a3994dd749 --- /dev/null +++ b/.changeset/25723-workspace-root-readers.md @@ -0,0 +1,4 @@ +--- +--- + +Tooling-only change (objectstack#25723): the scripts, gate tests and e2e specs that read `pnpm-workspace.yaml`, `pnpm-lock.yaml`, the root manifest's `pnpm` block or `turbo.json` resolve the file through `scripts/workspace-root.mjs`, which answers the same path in this repository and one level up when the tree is the objectstack monorepo's `ui/`. The two package test files touched (`@object-ui/cli`, `@object-ui/i18n`) read this repository's own workspace manifest; no published behaviour changes. diff --git a/e2e/dashboard-metric-tile-height.spec.ts b/e2e/dashboard-metric-tile-height.spec.ts index 54a9d75650..40d5e8fa2c 100644 --- a/e2e/dashboard-metric-tile-height.spec.ts +++ b/e2e/dashboard-metric-tile-height.spec.ts @@ -5,7 +5,8 @@ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; +import { uiRoot } from '../scripts/ui-root.mjs'; /** * GEOMETRY pin for objectui#12704: a one-row dashboard metric tile is as tall @@ -60,20 +61,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; * workspace `dist/`, and their `exports` point there. */ -const REPO_ROOT = (() => { - // Rooted on this file, never on `process.cwd()` (AGENTS.md: tests read the - // filesystem relative to their own file). Bare `import.meta.url`, walked up. - let dir = path.dirname(fileURLToPath(import.meta.url)); - for (let i = 0; i < 10; i++) { - try { - readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf8'); - return dir; - } catch { - dir = path.dirname(dir); - } - } - throw new Error('could not locate the repo root from ' + import.meta.url); -})(); +// The objectui tree, resolved from `scripts/ui-root.mjs`'s own location -- the +// one fact true standalone and under the objectstack monorepo's `ui/`. Walking up +// to `pnpm-workspace.yaml` found the OBJECTSTACK root there (objectstack#25723), +// and the sources this spec reads live under this tree's `packages/`. +const REPO_ROOT = uiRoot(); const source = (rel: string) => path.join(REPO_ROOT, rel); diff --git a/e2e/record-header-title-width.spec.ts b/e2e/record-header-title-width.spec.ts index 6a342eebd9..9640fd8595 100644 --- a/e2e/record-header-title-width.spec.ts +++ b/e2e/record-header-title-width.spec.ts @@ -3,6 +3,7 @@ import { compile } from 'tailwindcss'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; +import { uiRoot } from '../scripts/ui-root.mjs'; /** * GEOMETRY pin for the record-header title/action width arbitration on the two @@ -46,21 +47,11 @@ import path from 'node:path'; * produces, not a worst case invented to make a floor look necessary. */ -const REPO_ROOT = (() => { - // Rooted on this file, never on `process.cwd()` — AGENTS.md's - // "tests read the filesystem relative to their own file" rule. Bare - // `import.meta.url`, walked up; never `new URL(, import.meta.url)`. - let dir = path.dirname(fileURLToPath(import.meta.url)); - for (let i = 0; i < 10; i++) { - try { - readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf8'); - return dir; - } catch { - dir = path.dirname(dir); - } - } - throw new Error('could not locate the repo root from ' + import.meta.url); -})(); +// The objectui tree, resolved from `scripts/ui-root.mjs`'s own location -- the +// one fact true standalone and under the objectstack monorepo's `ui/`. Walking up +// to `pnpm-workspace.yaml` found the OBJECTSTACK root there (objectstack#25723), +// and the sources this spec reads live under this tree's `packages/`. +const REPO_ROOT = uiRoot(); const read = (rel: string) => readFileSync(path.join(REPO_ROOT, rel), 'utf8'); diff --git a/e2e/sidebar-offcanvas-geometry.spec.ts b/e2e/sidebar-offcanvas-geometry.spec.ts index 71107a8b4c..644e53f993 100644 --- a/e2e/sidebar-offcanvas-geometry.spec.ts +++ b/e2e/sidebar-offcanvas-geometry.spec.ts @@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { uiRoot } from '../scripts/ui-root.mjs'; /** * GEOMETRY pin for objectui#11464: the shadcn sidebar's `offcanvas` form drew @@ -71,20 +72,11 @@ import { fileURLToPath } from 'node:url'; * below `md` the desktop form is `hidden` anyway. */ -const REPO_ROOT = (() => { - // Rooted on this file, never on `process.cwd()` (AGENTS.md: tests read the - // filesystem relative to their own file). Bare `import.meta.url`, walked up. - let dir = path.dirname(fileURLToPath(import.meta.url)); - for (let i = 0; i < 10; i++) { - try { - readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf8'); - return dir; - } catch { - dir = path.dirname(dir); - } - } - throw new Error('could not locate the repo root from ' + import.meta.url); -})(); +// The objectui tree, resolved from `scripts/ui-root.mjs`'s own location -- the +// one fact true standalone and under the objectstack monorepo's `ui/`. Walking up +// to `pnpm-workspace.yaml` found the OBJECTSTACK root there (objectstack#25723), +// and the sources this spec reads live under this tree's `packages/`. +const REPO_ROOT = uiRoot(); const source = (rel: string) => path.join(REPO_ROOT, rel); diff --git a/e2e/studio-item-header-title-width.spec.ts b/e2e/studio-item-header-title-width.spec.ts index 6a40ebe5b9..ef58514c16 100644 --- a/e2e/studio-item-header-title-width.spec.ts +++ b/e2e/studio-item-header-title-width.spec.ts @@ -5,7 +5,8 @@ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; +import { uiRoot } from '../scripts/ui-root.mjs'; /** * GEOMETRY pin for objectui#12791: in Studio's item header a long api-name @@ -59,20 +60,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; * font draws the title about 17% wider than a local one. */ -const REPO_ROOT = (() => { - // Rooted on this file, never on `process.cwd()` (AGENTS.md: tests read the - // filesystem relative to their own file). Bare `import.meta.url`, walked up. - let dir = path.dirname(fileURLToPath(import.meta.url)); - for (let i = 0; i < 10; i++) { - try { - readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf8'); - return dir; - } catch { - dir = path.dirname(dir); - } - } - throw new Error('could not locate the repo root from ' + import.meta.url); -})(); +// The objectui tree, resolved from `scripts/ui-root.mjs`'s own location -- the +// one fact true standalone and under the objectstack monorepo's `ui/`. Walking up +// to `pnpm-workspace.yaml` found the OBJECTSTACK root there (objectstack#25723), +// and the sources this spec reads live under this tree's `packages/`. +const REPO_ROOT = uiRoot(); const source = (rel: string) => path.join(REPO_ROOT, rel); diff --git a/e2e/toast-under-modal.spec.ts b/e2e/toast-under-modal.spec.ts index 52a9a4801b..3ce0b846e1 100644 --- a/e2e/toast-under-modal.spec.ts +++ b/e2e/toast-under-modal.spec.ts @@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { uiRoot } from '../scripts/ui-root.mjs'; /** * objectui#11723: a toast's Undo and close buttons were dead while the record @@ -71,20 +72,11 @@ import { fileURLToPath } from 'node:url'; * under `e2e/` imports one. */ -const REPO_ROOT = (() => { - // Rooted on this file, never on `process.cwd()` (AGENTS.md: tests read the - // filesystem relative to their own file). Bare `import.meta.url`, walked up. - let dir = path.dirname(fileURLToPath(import.meta.url)); - for (let i = 0; i < 10; i++) { - try { - readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf8'); - return dir; - } catch { - dir = path.dirname(dir); - } - } - throw new Error('could not locate the repo root from ' + import.meta.url); -})(); +// The objectui tree, resolved from `scripts/ui-root.mjs`'s own location -- the +// one fact true standalone and under the objectstack monorepo's `ui/`. Walking up +// to `pnpm-workspace.yaml` found the OBJECTSTACK root there (objectstack#25723), +// and the sources this spec reads live under this tree's `packages/`. +const REPO_ROOT = uiRoot(); const source = (rel: string) => path.join(REPO_ROOT, rel); diff --git a/packages/cli/src/__tests__/workspace-vite.test.ts b/packages/cli/src/__tests__/workspace-vite.test.ts index fd704fc121..1571da94b8 100644 --- a/packages/cli/src/__tests__/workspace-vite.test.ts +++ b/packages/cli/src/__tests__/workspace-vite.test.ts @@ -43,6 +43,7 @@ import { workspacePackageDirs, workspaceSourceAliases } from '../utils/workspace-vite.js'; +import { workspaceGlobs, workspaceRoot } from '../../../../scripts/workspace-root.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); /** packages/cli/src/__tests__ -> repo root */ @@ -58,19 +59,16 @@ const BARREL_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx']; /** * The workspace's package directories, expanded WITHOUT the helper under test. * - * Deliberately hand-rolled and deliberately narrow: it understands the two - * pattern shapes this repo's manifest actually uses (a `dir/*` fan-out and a - * literal directory) and throws on anything else, so a manifest that grows a + * The globs come from `scripts/workspace-root.mjs`'s text parser -- a different + * reader from the YAML-and-glob one under test, and one that understands the + * two pattern shapes this repo's manifest actually uses (a `dir/*` fan-out and + * a literal directory) and throws on anything else, so a manifest that grows a * shape this expectation cannot read fails loudly here instead of quietly - * agreeing with whatever the helper returned. + * agreeing with whatever the helper returned. They are relative to the objectui + * tree in both layouts (objectstack#25723). */ function independentWorkspaceDirs(): string[] { - const manifest = readFileSync(join(REPO_ROOT, 'pnpm-workspace.yaml'), 'utf-8'); - const patterns: string[] = []; - for (const line of manifest.split('\n')) { - const match = /^\s*-\s*['"]?([^'"#\s]+)['"]?\s*$/.exec(line); - if (match) patterns.push(match[1]); - } + const patterns = workspaceGlobs(REPO_ROOT); expect(patterns.length).toBeGreaterThan(0); const dirs: string[] = []; @@ -109,7 +107,12 @@ function independentAliasablePackages(): Map { return found; } -const aliases = workspaceSourceAliases(REPO_ROOT); +// The helper under test asks about a pnpm workspace ROOT. Standalone that is +// this repository's root; under the objectstack monorepo it is the parent, whose +// `pnpm-workspace.yaml` declares this tree's packages as `ui/packages/*` and so +// on, and the `@object-ui/*` table it derives is the same set of `src` directories +// the expectation above expands relative to this tree (objectstack#25723). +const aliases = workspaceSourceAliases(workspaceRoot(REPO_ROOT)); describe('workspace alias table (objectui#3890)', () => { it('covers every @object-ui package the workspace manifest declares', () => { diff --git a/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts b/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts index 1575aecd3e..e7f8a3176d 100644 --- a/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts +++ b/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts @@ -66,6 +66,7 @@ */ import { describe, it, expect } from 'vitest'; +import { workspaceGlobs as readWorkspaceGlobs } from '../../../../scripts/workspace-root.mjs'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -86,12 +87,18 @@ const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'opti /* The population */ /* -------------------------------------------------------------------------- */ -/** The workspace globs, read from `pnpm-workspace.yaml` rather than restated. */ +/** + * The `packages:` globs that describe this tree, relative to `repoRoot`, from + * the one parser in `scripts/workspace-root.mjs` (objectstack#25723): read rather + * than hardcoded so a new workspace root is covered the day it is added, and + * throwing on a shape it does not know rather than skipping it, because a guard + * that silently stops looking at part of the workspace goes on reporting + * success over a shrinking surface. Where the file is -- the objectui root, or + * the monorepo root one level up with its `ui/` prefix stripped -- is the + * helper's question, not this file's. + */ function workspaceGlobs(): string[] { - const yaml = readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf8'); - const block = /^packages:\s*\n((?:[ \t]+-[^\n]*\n?)+)/m.exec(yaml); - if (!block) return []; - return [...block[1].matchAll(/-\s*['"]?([^'"\s#]+)['"]?/g)].map((m) => m[1]); + return readWorkspaceGlobs(repoRoot); } function workspacePackageDirs(): string[] { diff --git a/scripts/__tests__/check-dist-completeness.test.ts b/scripts/__tests__/check-dist-completeness.test.ts index c85537575c..c317fec358 100644 --- a/scripts/__tests__/check-dist-completeness.test.ts +++ b/scripts/__tests__/check-dist-completeness.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { uiTurboConfigPath } from '../workspace-root.mjs'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -306,7 +307,7 @@ describe('this repository', () => { ); it('turbo treats the gate as an input of the builds that run it', () => { - const turbo = JSON.parse(fs.readFileSync(path.join(repoRoot, 'turbo.json'), 'utf8')); + const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')); // Otherwise editing the gate does not invalidate a single cached build, and // the new rule is enforced only where nothing was cached. expect(turbo.tasks.build.inputs).toContain('$TURBO_ROOT$/scripts/check-dist-completeness.mjs'); diff --git a/scripts/__tests__/check-installed-spec-pin-claims.test.ts b/scripts/__tests__/check-installed-spec-pin-claims.test.ts index e460d5e48f..1cd6ff5d08 100644 --- a/scripts/__tests__/check-installed-spec-pin-claims.test.ts +++ b/scripts/__tests__/check-installed-spec-pin-claims.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { lockfilePath } from '../workspace-root.mjs'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; @@ -181,7 +182,7 @@ describe('attribution — a version on the line is not automatically the anchor' }); describe('the pin, read from two faces', () => { - const lock = pinsFromLockfile(fs.readFileSync(path.join(repoRoot, 'pnpm-lock.yaml'), 'utf8')); + const lock = pinsFromLockfile(fs.readFileSync(lockfilePath(), 'utf8')); const workspaces = execFileSync('git', ['-C', repoRoot, 'ls-files', '--', '*/package.json'], { encoding: 'utf8' }) .split('\n') .filter((f) => /^(packages|apps|examples)\/[^/]+\/package\.json$/.test(f)) diff --git a/scripts/__tests__/check-lint-rule-coverage.test.ts b/scripts/__tests__/check-lint-rule-coverage.test.ts index 21fe3a5e34..def899b79f 100644 --- a/scripts/__tests__/check-lint-rule-coverage.test.ts +++ b/scripts/__tests__/check-lint-rule-coverage.test.ts @@ -586,13 +586,14 @@ describe('this repository', () => { expect(row.vacuousMatches.length, `ledger row '${row.glob}' declares nothing any more`).toBeGreaterThan(0); expect(row.ruleBearingMatches, `ledger row '${row.glob}' over-claims`).toEqual([]); } - // Predicate 2, same two properties. Its population is two files today -- - // the vitest config objectui#8337 was filed about, and the declaration twin - // `scripts/ui-root.d.mts` that package tests (compiled with `allowJs: false`) - // need to import `scripts/ui-root.mjs` typed (objectstack#25685, objectui#13472) - // -- and a green here means both are DECLARED rather than invisible. + // Predicate 2, same two properties. Its population is three files today -- + // the vitest config objectui#8337 was filed about, and the declaration twins + // `scripts/ui-root.d.mts` and `scripts/workspace-root.d.mts` that package + // tests (compiled with `allowJs: false`) need to import those helpers typed + // (objectstack#25685, objectui#13472, objectstack#25723) -- and a green here + // means all three are DECLARED rather than invisible. expect(extensionProbeCollapse(result.reach)).toBeNull(); - expect(result.unreached).toEqual(['scripts/ui-root.d.mts', 'vitest.config.mts']); + expect(result.unreached).toEqual(['scripts/ui-root.d.mts', 'scripts/workspace-root.d.mts', 'vitest.config.mts']); // objectui#8369: this run is only a reading of the tree git tracks if the // filter was there and could answer both ways. expect(result.git.available).toBe(true); diff --git a/scripts/__tests__/check-lockfile-dedupe.test.ts b/scripts/__tests__/check-lockfile-dedupe.test.ts index fa0e7df362..c81bfcb92f 100644 --- a/scripts/__tests__/check-lockfile-dedupe.test.ts +++ b/scripts/__tests__/check-lockfile-dedupe.test.ts @@ -114,8 +114,9 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { DEDUPE_SENTINEL, pinnedBuildMissing } from '../check-lockfile-dedupe.mjs'; -import { CACHE_DIR, STAMP_FILE } from '../objectstack-pin.mjs'; +import { DEDUPE_SENTINEL, pinDeclared, pinnedBuildMissing } from '../check-lockfile-dedupe.mjs'; +import { CACHE_DIR, OVERRIDE_PREFIX, STAMP_FILE } from '../objectstack-pin.mjs'; +import { isNestedWorkspace } from '../workspace-root.mjs'; import { FLOORS, TEST_FILE } from '../check-test-path-roots.mjs'; import { NOT_A_GATE, OPTIONAL_CONTEXTS, REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; import { maskComments } from '../js-comment-mask.mjs'; @@ -344,9 +345,21 @@ describe('the pinned @objectstack/* build is the checker\'s precondition (object } }); + it('applies only where the manifest pnpm reads overrides from declares the pin (objectstack#25723)', () => { + // Standalone the root manifest declares `@objectstack/*: file:.objectstack-pin/...` + // and the stamp is a precondition; under the monorepo nothing does, the + // `@objectstack/*` packages are workspace members, and there is no build + // whose absence could send `pnpm dedupe --check` to the registry. + expect(pinDeclared({ pnpm: { overrides: { '@objectstack/spec': `${OVERRIDE_PREFIX}spec`, react: '19.3.0' } } })).toBe(true); + expect(pinDeclared({ pnpm: { overrides: { react: '19.3.0' } } })).toBe(false); + expect(pinDeclared({ pnpm: {} })).toBe(false); + expect(pinDeclared({})).toBe(false); + expect(pinDeclared()).toBe(!isNestedWorkspace()); + }); + it('checks before it spawns pnpm, and says so in a could-not-run verdict', () => { const source = fs.readFileSync(path.join(repoRoot, SCRIPT), 'utf8'); - const guard = source.indexOf('if (pinnedBuildMissing())'); + const guard = source.indexOf('if (pinDeclared() && pinnedBuildMissing())'); expect(guard, 'the precondition is gone from main()').toBeGreaterThan(0); expect(guard).toBeLessThan(source.indexOf("spawnSync('pnpm', ['dedupe', '--check']")); expect(source.slice(guard, source.indexOf("spawnSync('pnpm', ['dedupe', '--check']"))).toContain("outcome: 'cannot-run'"); diff --git a/scripts/__tests__/check-lockfile-integrity.test.ts b/scripts/__tests__/check-lockfile-integrity.test.ts index 7f4cec6acc..d42b404cdb 100644 --- a/scripts/__tests__/check-lockfile-integrity.test.ts +++ b/scripts/__tests__/check-lockfile-integrity.test.ts @@ -22,6 +22,7 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; +import { lockfilePath } from '../workspace-root.mjs'; import { readWorkflows, repoRoot } from './workflow-checks'; import { NOT_A_GATE, OPTIONAL_CONTEXTS, REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; @@ -33,7 +34,7 @@ import { } from '../check-lockfile-integrity.mjs'; const GATE = path.join(repoRoot, 'scripts/check-lockfile-integrity.mjs'); -const LOCKFILE = path.join(repoRoot, 'pnpm-lock.yaml'); +const LOCKFILE = lockfilePath(); const realLock = fs.readFileSync(LOCKFILE, 'utf8'); /** The check-run name the workflow's job produces. */ diff --git a/scripts/__tests__/check-ui-root-lookups.test.ts b/scripts/__tests__/check-ui-root-lookups.test.ts index cca7ed1207..e877bb36e0 100644 --- a/scripts/__tests__/check-ui-root-lookups.test.ts +++ b/scripts/__tests__/check-ui-root-lookups.test.ts @@ -31,6 +31,14 @@ describe('check-ui-root-lookups (objectstack ruling D1)', () => { expect(silent).toEqual([]); }); + it('reads a marker read inside a dirname walk as the lookup it is, and a data read in a plain loop as data (objectstack#25723)', () => { + const head = "import { readFileSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\n"; + const walk = lookupsIn('e2e/x.spec.ts', `${head}let dir = __dirname;\nfor (let i = 0; i < 10; i++) {\n try { readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8'); break; } catch { dir = dirname(dir); }\n}`); + expect(walk.map((h) => h.shape)).toEqual(['marker-walk']); + const data = lookupsIn('x.ts', `${head}for (const root of roots) globs.push(readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8'));`); + expect(data).toEqual([]); + }); + it('finds no lookup outside the helper in this tree, and every allowlist entry is live', () => { const result = scan(repoRoot); expect(result.scanned).toBeGreaterThan(100); diff --git a/scripts/__tests__/ci-cd-pipeline-doc.test.ts b/scripts/__tests__/ci-cd-pipeline-doc.test.ts index fa68514810..c321942c03 100644 --- a/scripts/__tests__/ci-cd-pipeline-doc.test.ts +++ b/scripts/__tests__/ci-cd-pipeline-doc.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { lockfilePath as workspaceLockfilePath } from '../workspace-root.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -1795,7 +1796,7 @@ describe('ci-cd-pipeline.md — contexts that can never be required (#4170)', () */ const backendEnvPath = path.join(repoRoot, 'e2e/live/ci/backend.env'); const startBackendPath = path.join(repoRoot, 'e2e/live/ci/start-backend.sh'); -const lockfilePath = path.join(repoRoot, 'pnpm-lock.yaml'); +const lockfilePath = workspaceLockfilePath(); const backendEnv = fs.readFileSync(backendEnvPath, 'utf8'); const startBackend = fs.readFileSync(startBackendPath, 'utf8'); diff --git a/scripts/__tests__/console-vite-alias-closure-4925.test.ts b/scripts/__tests__/console-vite-alias-closure-4925.test.ts index 3477df27b6..d404e7e06a 100644 --- a/scripts/__tests__/console-vite-alias-closure-4925.test.ts +++ b/scripts/__tests__/console-vite-alias-closure-4925.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { workspaceGlobs } from '../workspace-root.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -111,7 +112,6 @@ import ts from 'typescript'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const CONSOLE_CONFIG = path.join(repoRoot, 'apps/console/vite.config.ts'); const CONSOLE_SRC = path.join(repoRoot, 'apps/console/src'); -const WORKSPACE_MANIFEST = path.join(repoRoot, 'pnpm-workspace.yaml'); /** The scope this table is about. Nothing else in it is reconciled here. */ const SCOPE = '@object-ui/'; @@ -136,32 +136,13 @@ function packageOf(specifier: string): string | undefined { /** * `pnpm-workspace.yaml`'s `packages:` globs, expanded to `name -> dir`. * - * Parsed as text rather than with a YAML dependency: this is a flat list of - * scalars under one key, and the gates in this directory read their inputs as - * text by convention (see `vitest-config-alias-targets-3944.test.ts`). + * The globs come from the one text parser in `scripts/workspace-root.mjs` + * (objectstack#25723), relative to `repoRoot` in both layouts: this is a flat + * list of scalars under one key, and the gates in this directory read their + * inputs as text by convention (see `vitest-config-alias-targets-3944.test.ts`). */ function workspacePackages(): Map { - const source = fs.readFileSync(WORKSPACE_MANIFEST, 'utf8'); - const globs: string[] = []; - let inPackages = false; - - for (const line of source.split('\n')) { - if (/^packages:\s*$/.test(line)) { - inPackages = true; - continue; - } - if (!inPackages) continue; - const item = /^\s+-\s*['"]?([^'"#]+?)['"]?\s*$/.exec(line); - if (item) { - globs.push(item[1]); - continue; - } - if (/^\S/.test(line)) inPackages = false; - } - - if (globs.length === 0) { - throw new Error('pnpm-workspace.yaml: no `packages:` entries parsed — the manifest shape changed'); - } + const globs = workspaceGlobs(repoRoot); const dirs: string[] = []; for (const glob of globs) { diff --git a/scripts/__tests__/docs-build-trigger.test.ts b/scripts/__tests__/docs-build-trigger.test.ts index 8eec7b1d26..c3a782fd1d 100644 --- a/scripts/__tests__/docs-build-trigger.test.ts +++ b/scripts/__tests__/docs-build-trigger.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { uiTurboConfigPath } from '../workspace-root.mjs'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -259,7 +260,7 @@ const outsideClosureDirs = allWorkspaceDirs.filter( /** Root-level build inputs `turbo.json` declares for the `build` task. */ function turboRootBuildInputs(): string[] { - const turbo = JSON.parse(fs.readFileSync(path.join(repoRoot, 'turbo.json'), 'utf8')) as { + const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as { tasks?: Record; }; const inputs = turbo.tasks?.build?.inputs ?? []; diff --git a/scripts/__tests__/e2e-type-check.test.ts b/scripts/__tests__/e2e-type-check.test.ts index c22be82637..5783c0bece 100644 --- a/scripts/__tests__/e2e-type-check.test.ts +++ b/scripts/__tests__/e2e-type-check.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { uiTurboConfigPath } from '../workspace-root.mjs'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -378,7 +379,7 @@ describe('the gate is actually wired up (objectui#4471)', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { scripts?: Record; }; - const turbo = JSON.parse(fs.readFileSync(path.join(repoRoot, 'turbo.json'), 'utf8')) as { + const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as { tasks: Record; }; diff --git a/scripts/__tests__/helpers/turbo-inputs.ts b/scripts/__tests__/helpers/turbo-inputs.ts index 768ba34bca..f19e07c7c5 100644 --- a/scripts/__tests__/helpers/turbo-inputs.ts +++ b/scripts/__tests__/helpers/turbo-inputs.ts @@ -19,13 +19,22 @@ */ import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -/** Repo root: this file sits at `scripts/__tests__/helpers/`. */ -export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +import { uiRoot } from '../../ui-root.mjs'; +import { uiTurboConfigPath, workspaceGlobs } from '../../workspace-root.mjs'; -const turboConfigPath = path.join(repoRoot, 'turbo.json'); -const workspaceConfigPath = path.join(repoRoot, 'pnpm-workspace.yaml'); +/** Repo root: the objectui tree, standalone or as the monorepo's `ui/`. */ +export const repoRoot = uiRoot(); + +/** + * 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). + */ +const turboConfigPath = uiTurboConfigPath(); /** POSIX-separated, repo-relative. The one path spelling these guards compare in. */ export function rel(absolute: string): string { @@ -33,8 +42,9 @@ export function rel(absolute: string): string { } /** - * Package directories, derived from `pnpm-workspace.yaml` rather than a - * hardcoded group list. + * Package directories, derived from the governing `pnpm-workspace.yaml` rather + * than a hardcoded group list, through the one parser in + * `scripts/workspace-root.mjs` (relative to `repoRoot` in both layouts). * * Only the two entry shapes the file actually uses are understood (`dir/*` and * a bare `dir`); anything else throws. A workspace layout a guard silently @@ -42,26 +52,7 @@ export function rel(absolute: string): string { * is the failure mode both guards exist to prevent. */ export function workspacePackageDirs(): string[] { - const yaml = fs.readFileSync(workspaceConfigPath, 'utf8'); - const patterns: string[] = []; - let inPackages = false; - for (const line of yaml.split('\n')) { - if (/^packages:\s*$/.test(line)) { - inPackages = true; - continue; - } - if (inPackages) { - const item = line.match(/^\s+-\s*['"]?([^'"#]+?)['"]?\s*$/); - if (item) { - patterns.push(item[1]); - continue; - } - if (line.trim() !== '') break; - } - } - if (patterns.length === 0) { - throw new Error('pnpm-workspace.yaml must declare at least one package pattern'); - } + const patterns = workspaceGlobs(repoRoot); const dirs: string[] = []; for (const pattern of patterns) { diff --git a/scripts/__tests__/package-files-exist.test.ts b/scripts/__tests__/package-files-exist.test.ts index d6791a3172..308a64caef 100644 --- a/scripts/__tests__/package-files-exist.test.ts +++ b/scripts/__tests__/package-files-exist.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { workspaceGlobs as readWorkspaceGlobs } from '../workspace-root.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -94,34 +95,17 @@ interface WorkspacePackage { } /** - * The `packages:` globs from `pnpm-workspace.yaml`, read rather than hardcoded - * so a new workspace root is covered the day it is added. - * - * Understands only the two shapes the file actually uses (`dir/*` and a bare - * `dir`); anything else throws instead of being skipped. A guard that silently - * stops looking at part of the workspace is worse than no guard, because it - * goes on reporting success over a shrinking surface. + * The `packages:` globs that describe this tree, relative to `repoRoot`, from + * the one parser in `scripts/workspace-root.mjs` (objectstack#25723): read rather + * than hardcoded so a new workspace root is covered the day it is added, and + * throwing on a shape it does not know rather than skipping it, because a guard + * that silently stops looking at part of the workspace goes on reporting + * success over a shrinking surface. Where the file is -- the objectui root, or + * the monorepo root one level up with its `ui/` prefix stripped -- is the + * helper's question, not this file's. */ function workspaceGlobs(): string[] { - const yaml = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf8'); - const lines = yaml.split('\n'); - const start = lines.findIndex((l) => /^packages:\s*$/.test(l)); - expect(start, '`pnpm-workspace.yaml` must still declare a top-level `packages:` key').toBeGreaterThan(-1); - - const globs: string[] = []; - for (const line of lines.slice(start + 1)) { - if (/^\s*(#.*)?$/.test(line)) continue; - // A non-indented line ends the `packages:` block. - if (!/^\s/.test(line)) break; - const match = line.match(/^\s*-\s*['"]?([^'"#\s]+)['"]?\s*(#.*)?$/); - if (!match) { - throw new Error( - `Unparsed entry in pnpm-workspace.yaml \`packages:\`: ${JSON.stringify(line)} — teach this guard the new syntax.`, - ); - } - globs.push(match[1]); - } - return globs; + return readWorkspaceGlobs(repoRoot); } function readWorkspacePackages(): WorkspacePackage[] { diff --git a/scripts/__tests__/quick-reference-commands-4149.test.ts b/scripts/__tests__/quick-reference-commands-4149.test.ts index c01fddfb8c..690797df72 100644 --- a/scripts/__tests__/quick-reference-commands-4149.test.ts +++ b/scripts/__tests__/quick-reference-commands-4149.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { workspaceGlobs as readWorkspaceGlobs } from '../workspace-root.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -77,31 +78,17 @@ function manifest(rel: string): Manifest { const rootScripts = new Set(Object.keys(manifest('package.json').scripts ?? {})); /** - * The `packages:` globs from `pnpm-workspace.yaml`, read rather than hardcoded, in the - * shape `package-files-exist.test.ts` and `react-peer-range-norm-3741.test.ts` already - * use here: understand the two syntaxes the file actually uses and throw on anything - * else, because a guard that silently stops looking at part of the workspace goes on - * reporting success over a shrinking surface. + * The `packages:` globs that describe this tree, relative to `repoRoot`, from + * the one parser in `scripts/workspace-root.mjs` (objectstack#25723): read rather + * than hardcoded so a new workspace root is covered the day it is added, and + * throwing on a shape it does not know rather than skipping it, because a guard + * that silently stops looking at part of the workspace goes on reporting + * success over a shrinking surface. Where the file is -- the objectui root, or + * the monorepo root one level up with its `ui/` prefix stripped -- is the + * helper's question, not this file's. */ function workspaceGlobs(): string[] { - const yaml = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf8'); - const lines = yaml.split('\n'); - const start = lines.findIndex((l) => /^packages:\s*$/.test(l)); - expect(start, '`pnpm-workspace.yaml` must still declare a top-level `packages:` key').toBeGreaterThan(-1); - - const globs: string[] = []; - for (const line of lines.slice(start + 1)) { - if (/^\s*(#.*)?$/.test(line)) continue; - if (!/^\s/.test(line)) break; // a non-indented line ends the block - const match = line.match(/^\s*-\s*['"]?([^'"#\s]+)['"]?\s*(#.*)?$/); - if (!match) { - throw new Error( - `Unparsed entry in pnpm-workspace.yaml \`packages:\`: ${JSON.stringify(line)} — teach this guard the new syntax.`, - ); - } - globs.push(match[1]); - } - return globs; + return readWorkspaceGlobs(repoRoot); } interface WorkspacePackage { diff --git a/scripts/__tests__/react-peer-range-norm-3741.test.ts b/scripts/__tests__/react-peer-range-norm-3741.test.ts index b20c5bc1e1..6ceef9cb27 100644 --- a/scripts/__tests__/react-peer-range-norm-3741.test.ts +++ b/scripts/__tests__/react-peer-range-norm-3741.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { workspaceGlobs as readWorkspaceGlobs } from '../workspace-root.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -78,33 +79,17 @@ interface WorkspacePackage { } /** - * The `packages:` globs from `pnpm-workspace.yaml`, read rather than hardcoded so a new - * workspace root is covered the day it is added — and throwing rather than skipping on a - * shape it does not understand, because a guard that quietly stops looking at part of the - * workspace keeps reporting success over a shrinking surface. Same reasoning, and the - * same two supported shapes (`dir/*` and a bare `dir`), as - * `workspace-peer-dependency-edges.test.ts`. + * The `packages:` globs that describe this tree, relative to `repoRoot`, from + * the one parser in `scripts/workspace-root.mjs` (objectstack#25723): read rather + * than hardcoded so a new workspace root is covered the day it is added, and + * throwing on a shape it does not know rather than skipping it, because a guard + * that silently stops looking at part of the workspace goes on reporting + * success over a shrinking surface. Where the file is -- the objectui root, or + * the monorepo root one level up with its `ui/` prefix stripped -- is the + * helper's question, not this file's. */ function workspaceGlobs(): string[] { - const yaml = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf8'); - const lines = yaml.split('\n'); - const start = lines.findIndex((l) => /^packages:\s*$/.test(l)); - expect(start, '`pnpm-workspace.yaml` must still declare a top-level `packages:` key').toBeGreaterThan(-1); - - const globs: string[] = []; - for (const line of lines.slice(start + 1)) { - if (/^\s*(#.*)?$/.test(line)) continue; - // A non-indented line ends the `packages:` block. - if (!/^\s/.test(line)) break; - const match = line.match(/^\s*-\s*['"]?([^'"#\s]+)['"]?\s*(#.*)?$/); - if (!match) { - throw new Error( - `Unparsed entry in pnpm-workspace.yaml \`packages:\`: ${JSON.stringify(line)} — teach this guard the new syntax.`, - ); - } - globs.push(match[1]); - } - return globs; + return readWorkspaceGlobs(repoRoot); } function readWorkspacePackages(): WorkspacePackage[] { diff --git a/scripts/__tests__/select-type-check-scope.test.ts b/scripts/__tests__/select-type-check-scope.test.ts index ca9f1107a1..1aea87bfca 100644 --- a/scripts/__tests__/select-type-check-scope.test.ts +++ b/scripts/__tests__/select-type-check-scope.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { uiTurboConfigPath } from '../workspace-root.mjs'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -29,7 +30,7 @@ import { * added to turbo.json is a root change the day it lands. */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const turbo = JSON.parse(fs.readFileSync(path.join(repoRoot, 'turbo.json'), 'utf8')) as { +const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as { globalDependencies?: string[]; tasks: Record; }; diff --git a/scripts/__tests__/side-effects-declaration-consistency.test.ts b/scripts/__tests__/side-effects-declaration-consistency.test.ts index 3468ab5e2e..619bb2b833 100644 --- a/scripts/__tests__/side-effects-declaration-consistency.test.ts +++ b/scripts/__tests__/side-effects-declaration-consistency.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { workspaceGlobs as readWorkspaceGlobs } from '../workspace-root.mjs'; // The shared registration reader (objectui#4894). Types are INFERRED from the // .mjs source by `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error`. @@ -153,32 +154,17 @@ interface DeclaringPackage { } /** - * The `packages:` globs from `pnpm-workspace.yaml`, read rather than hardcoded - * so a new workspace root is covered the day it is added. Understands only the - * two shapes the file uses (`dir/*` and a bare `dir`); anything else throws - * rather than being skipped, because a guard that silently stops looking at - * part of the workspace goes on reporting success over a shrinking surface. - * (Same derivation as `package-files-exist.test.ts`.) + * The `packages:` globs that describe this tree, relative to `repoRoot`, from + * the one parser in `scripts/workspace-root.mjs` (objectstack#25723): read rather + * than hardcoded so a new workspace root is covered the day it is added, and + * throwing on a shape it does not know rather than skipping it, because a guard + * that silently stops looking at part of the workspace goes on reporting + * success over a shrinking surface. Where the file is -- the objectui root, or + * the monorepo root one level up with its `ui/` prefix stripped -- is the + * helper's question, not this file's. */ function workspaceGlobs(): string[] { - const yaml = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf8'); - const lines = yaml.split('\n'); - const start = lines.findIndex((l) => /^packages:\s*$/.test(l)); - expect(start, '`pnpm-workspace.yaml` must still declare a top-level `packages:` key').toBeGreaterThan(-1); - - const globs: string[] = []; - for (const line of lines.slice(start + 1)) { - if (/^\s*(#.*)?$/.test(line)) continue; - if (!/^\s/.test(line)) break; - const match = line.match(/^\s*-\s*['"]?([^'"#\s]+)['"]?\s*(#.*)?$/); - if (!match) { - throw new Error( - `Unparsed entry in pnpm-workspace.yaml \`packages:\`: ${JSON.stringify(line)} — teach this guard the new syntax.`, - ); - } - globs.push(match[1]); - } - return globs; + return readWorkspaceGlobs(repoRoot); } function workspacePackageDirs(): string[] { diff --git a/scripts/__tests__/turbo-build-outputs-cover-published-entries.test.ts b/scripts/__tests__/turbo-build-outputs-cover-published-entries.test.ts index 7d120fc6d1..ebfcd20650 100644 --- a/scripts/__tests__/turbo-build-outputs-cover-published-entries.test.ts +++ b/scripts/__tests__/turbo-build-outputs-cover-published-entries.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { uiTurboConfigPath } from '../workspace-root.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; @@ -141,7 +142,7 @@ function declaredOutputs(configPath: string): string[] | undefined { return config.tasks?.[TASK]?.outputs; } -const ROOT_OUTPUTS = declaredOutputs(path.join(repoRoot, 'turbo.json')); +const ROOT_OUTPUTS = declaredOutputs(uiTurboConfigPath()); if (!ROOT_OUTPUTS || ROOT_OUTPUTS.length === 0) { throw new Error(`turbo.json must declare \`${TASK}\` outputs`); } diff --git a/scripts/__tests__/turbo-lint-inputs.test.ts b/scripts/__tests__/turbo-lint-inputs.test.ts index f93b598245..f1fae70046 100644 --- a/scripts/__tests__/turbo-lint-inputs.test.ts +++ b/scripts/__tests__/turbo-lint-inputs.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { uiTurboConfigPath } from '../workspace-root.mjs'; import fs from 'node:fs'; import path from 'node:path'; import ts from 'typescript'; @@ -278,7 +279,7 @@ describe('turbo `lint` inputs cover every out-of-package file (objectui#4184)', */ describe('`//#lint:root` hashes the files it lints, never the whole repository (objectui#12276)', () => { const ROOT_TASK = '//#lint:root'; - const turbo = JSON.parse(fs.readFileSync(path.join(repoRoot, 'turbo.json'), 'utf8')) as { + const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as { tasks: Record; }; const inputs = turbo.tasks[ROOT_TASK]?.inputs ?? []; diff --git a/scripts/__tests__/turbo-task-guard-coverage.test.ts b/scripts/__tests__/turbo-task-guard-coverage.test.ts index d8e14d2537..830a27683b 100644 --- a/scripts/__tests__/turbo-task-guard-coverage.test.ts +++ b/scripts/__tests__/turbo-task-guard-coverage.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { uiTurboConfigPath } from '../workspace-root.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { repoRoot } from './helpers/turbo-inputs'; @@ -76,7 +77,7 @@ interface TurboTask { readonly inputs?: string[]; } -const turbo = JSON.parse(fs.readFileSync(path.join(repoRoot, 'turbo.json'), 'utf8')) as { +const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as { tasks: Record; }; diff --git a/scripts/__tests__/vite-objectstack-spec-dist.test.ts b/scripts/__tests__/vite-objectstack-spec-dist.test.ts index e8d937a04a..7a73510b6b 100644 --- a/scripts/__tests__/vite-objectstack-spec-dist.test.ts +++ b/scripts/__tests__/vite-objectstack-spec-dist.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { uiTurboConfigPath } from '../workspace-root.mjs'; import { build, resolveConfig } from 'vite'; import type { InlineConfig } from 'vite'; import fs from 'node:fs'; @@ -1010,7 +1011,7 @@ describe('objectui#4854: the four flagged surfaces in the console config', () => }); it('declares the var in turbo.json, which strict env mode would otherwise strip', () => { - const turbo = JSON.parse(fs.readFileSync(path.join(repoRoot, 'turbo.json'), 'utf8')) as { + const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as { tasks: { build: { env: string[] } }; }; // Turbo v2 runs tasks in strict env mode: an undeclared var never reaches diff --git a/scripts/__tests__/vitest-config-alias-targets-3944.test.ts b/scripts/__tests__/vitest-config-alias-targets-3944.test.ts index 3d331c5716..550f48ba6f 100644 --- a/scripts/__tests__/vitest-config-alias-targets-3944.test.ts +++ b/scripts/__tests__/vitest-config-alias-targets-3944.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { workspaceGlobs as readWorkspaceGlobs } from '../workspace-root.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -506,31 +507,17 @@ interface PackageAliasParseGap { const packageAliasParseGaps: PackageAliasParseGap[] = []; /** - * The `packages:` globs from `pnpm-workspace.yaml`, read rather than hardcoded - * so a new workspace root is covered the day it is added. Understands only the - * two shapes the file uses (`dir/*` and a bare `dir`); anything else throws - * rather than being skipped, because a guard that silently stops looking at - * part of the workspace goes on reporting success over a shrinking surface. + * The `packages:` globs that describe this tree, relative to `repoRoot`, from + * the one parser in `scripts/workspace-root.mjs` (objectstack#25723): read rather + * than hardcoded so a new workspace root is covered the day it is added, and + * throwing on a shape it does not know rather than skipping it, because a guard + * that silently stops looking at part of the workspace goes on reporting + * success over a shrinking surface. Where the file is -- the objectui root, or + * the monorepo root one level up with its `ui/` prefix stripped -- is the + * helper's question, not this file's. */ function workspaceGlobs(): string[] { - const yaml = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf8'); - const lines = yaml.split('\n'); - const start = lines.findIndex((l) => /^packages:\s*$/.test(l)); - if (start === -1) throw new Error('pnpm-workspace.yaml no longer declares a top-level `packages:` key'); - - const globs: string[] = []; - for (const line of lines.slice(start + 1)) { - if (/^\s*(#.*)?$/.test(line)) continue; - if (!/^\s/.test(line)) break; - const match = line.match(/^\s*-\s*['"]?([^'"#\s]+)['"]?\s*(#.*)?$/); - if (!match) { - throw new Error( - `Unparsed entry in pnpm-workspace.yaml \`packages:\`: ${JSON.stringify(line)} — teach this guard the new syntax.` - ); - } - globs.push(match[1]); - } - return globs; + return readWorkspaceGlobs(repoRoot); } function workspacePackageDirs(): string[] { diff --git a/scripts/__tests__/workspace-peer-dependency-edges.test.ts b/scripts/__tests__/workspace-peer-dependency-edges.test.ts index 599a80816f..a6c9dc7cf6 100644 --- a/scripts/__tests__/workspace-peer-dependency-edges.test.ts +++ b/scripts/__tests__/workspace-peer-dependency-edges.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { workspaceGlobs as readWorkspaceGlobs } from '../workspace-root.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -47,30 +48,17 @@ interface WorkspacePackage { } /** - * The `packages:` globs from `pnpm-workspace.yaml`, read rather than hardcoded so - * a new workspace root is covered the day it is added. - * - * The parser deliberately understands only the two shapes the file actually uses - * (`dir/*` and a bare `dir`). Anything else throws instead of being skipped: a - * guard that silently stops looking at part of the workspace is worse than no - * guard, because it keeps reporting success over a shrinking surface. + * The `packages:` globs that describe this tree, relative to `repoRoot`, from + * the one parser in `scripts/workspace-root.mjs` (objectstack#25723): read rather + * than hardcoded so a new workspace root is covered the day it is added, and + * throwing on a shape it does not know rather than skipping it, because a guard + * that silently stops looking at part of the workspace goes on reporting + * success over a shrinking surface. Where the file is -- the objectui root, or + * the monorepo root one level up with its `ui/` prefix stripped -- is the + * helper's question, not this file's. */ function workspaceGlobs(): string[] { - const yaml = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf8'); - const lines = yaml.split('\n'); - const start = lines.findIndex((l) => /^packages:\s*$/.test(l)); - expect(start, '`pnpm-workspace.yaml` must still declare a top-level `packages:` key').toBeGreaterThan(-1); - - const globs: string[] = []; - for (const line of lines.slice(start + 1)) { - if (/^\s*(#.*)?$/.test(line)) continue; - // A non-indented line ends the `packages:` block. - if (!/^\s/.test(line)) break; - const match = line.match(/^\s*-\s*['"]?([^'"#\s]+)['"]?\s*(#.*)?$/); - if (!match) throw new Error(`Unparsed entry in pnpm-workspace.yaml \`packages:\`: ${JSON.stringify(line)} — teach this guard the new syntax.`); - globs.push(match[1]); - } - return globs; + return readWorkspaceGlobs(repoRoot); } function readWorkspacePackages(): WorkspacePackage[] { diff --git a/scripts/__tests__/workspace-root.test.ts b/scripts/__tests__/workspace-root.test.ts new file mode 100644 index 0000000000..3cb1a6cbcc --- /dev/null +++ b/scripts/__tests__/workspace-root.test.ts @@ -0,0 +1,249 @@ +/** + * `scripts/workspace-root.mjs` -- where the pnpm workspace's four root files are, + * in the standalone layout and under the objectstack monorepo's `ui/` + * (objectstack#25723). The rule is held on fixtures of BOTH layouts, so the + * nested branch is exercised whichever layout this checkout is; the live tree + * is then asked only what is true in both. The declaration twin + * `workspace-root.d.mts` is pinned to the module's real export surface, since a + * hand-written declaration is the one thing here free to drift. + */ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { stripComments } from '../js-comment-mask.mjs'; +import { uiRoot } from '../ui-root.mjs'; +import { + isNestedWorkspace, + lockfilePath, + narrowTurboConfig, + nestedPrefix, + parseWorkspaceGlobs, + rootManifest, + rootManifestPath, + turboConfigPath, + uiScopedTurboConfig, + uiTurboConfigPath, + workspaceGlobs, + workspaceManifestPath, + workspaceRoot, +} from '../workspace-root.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +type Files = Record; + +/** A scratch tree; `run` gets its absolute root. */ +function withTree(files: Files, run: (root: string) => T): T { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-root-')); + try { + for (const [rel, contents] of Object.entries(files)) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, contents); + } + return run(root); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +const STANDALONE: Files = { + 'ws/pnpm-workspace.yaml': "packages:\n - 'packages/*'\n - 'examples/*'\n - 'apps/*'\n - 'docs'\n", + 'ws/pnpm-lock.yaml': 'lockfileVersion: 9.0\n', + 'ws/package.json': JSON.stringify({ name: 'fixture-ws', pnpm: { overrides: { react: '19.0.0' } } }), + 'ws/turbo.json': JSON.stringify({ + globalDependencies: ['tsconfig.json'], + tasks: { build: { inputs: ['$TURBO_DEFAULT$', '$TURBO_ROOT$/scripts/x.mjs'] } }, + }), +}; + +const NESTED: Files = { + 'mono/pnpm-workspace.yaml': [ + 'packages:', + ' - packages/*', + ' - packages/apps/*', + ' - apps/*', + ' # the imported tree', + ' - ui', + ' - ui/packages/*', + ' - ui/apps/*', + " - 'ui/examples/*'", + '', + 'overrides:', + ' react: 19.3.0', + '', + ].join('\n'), + 'mono/pnpm-lock.yaml': 'lockfileVersion: 9.0\n', + 'mono/package.json': JSON.stringify({ name: 'fixture-mono', pnpm: { ignoredBuiltDependencies: ['yarn'] } }), + 'mono/turbo.json': JSON.stringify({ + globalDependencies: ['tsconfig.json', 'ui/tsconfig.tier-base.json'], + tasks: { + build: { dependsOn: ['^build'], inputs: ['$TURBO_DEFAULT$', '$TURBO_ROOT$/scripts/tsup.mjs', '$TURBO_ROOT$/ui/scripts/check-dist.mjs'] }, + 'type-check': { dependsOn: ['^build'], inputs: ['$TURBO_DEFAULT$', '$TURBO_ROOT$/ui/tsconfig.json'] }, + lint: { outputs: [] }, + }, + }), + 'mono/ui/package.json': JSON.stringify({ name: 'fixture-ui' }), + 'mono/ui/turbo.objectui.json': JSON.stringify({ + tasks: { build: { inputs: ['$TURBO_DEFAULT$', '$TURBO_ROOT$/scripts/check-dist.mjs'] } }, + }), +}; + +describe('workspaceRoot() -- rule (a): the tree, else its parent, else a throw', () => { + it('is the tree itself when it holds a pnpm-workspace.yaml', () => { + withTree(STANDALONE, (tmp) => { + const ws = path.join(tmp, 'ws'); + expect(workspaceRoot(ws)).toBe(ws); + expect(isNestedWorkspace(ws)).toBe(false); + expect(nestedPrefix(ws)).toBe(''); + expect(workspaceManifestPath(ws)).toBe(path.join(ws, 'pnpm-workspace.yaml')); + expect(lockfilePath(ws)).toBe(path.join(ws, 'pnpm-lock.yaml')); + expect(rootManifestPath(ws)).toBe(path.join(ws, 'package.json')); + expect(rootManifest(ws).pnpm?.overrides).toEqual({ react: '19.0.0' }); + }); + }); + + it('is the parent when only the parent holds one -- the `ui/` layout', () => { + withTree(NESTED, (tmp) => { + const mono = path.join(tmp, 'mono'); + const ui = path.join(mono, 'ui'); + expect(workspaceRoot(ui)).toBe(mono); + expect(isNestedWorkspace(ui)).toBe(true); + expect(nestedPrefix(ui)).toBe('ui/'); + expect(workspaceManifestPath(ui)).toBe(path.join(mono, 'pnpm-workspace.yaml')); + expect(lockfilePath(ui)).toBe(path.join(mono, 'pnpm-lock.yaml')); + expect(rootManifest(ui).name).toBe('fixture-mono'); + }); + }); + + it('throws when neither holds one, rather than picking a grandparent or the cwd', () => { + withTree({ 'none/sub/package.json': '{}' }, (tmp) => { + expect(() => workspaceRoot(path.join(tmp, 'none/sub'))).toThrow(/no pnpm-workspace\.yaml at/); + expect(() => workspaceGlobs(path.join(tmp, 'none/sub'))).toThrow(/no pnpm-workspace\.yaml at/); + }); + }); +}); + +describe('workspaceGlobs() -- rule (b): verbatim at the root, the tree’s own subset at the parent', () => { + it('returns the file’s globs verbatim, in order, when the tree is the root', () => { + withTree(STANDALONE, (tmp) => { + expect(workspaceGlobs(path.join(tmp, 'ws'))).toEqual(['packages/*', 'examples/*', 'apps/*', 'docs']); + }); + }); + + it('strips the tree’s prefix and drops the bare tree entry when the parent is the root', () => { + withTree(NESTED, (tmp) => { + expect(workspaceGlobs(path.join(tmp, 'mono/ui'))).toEqual(['packages/*', 'apps/*', 'examples/*']); + }); + }); + + it('throws when the parent’s file does not describe the tree at all', () => { + withTree( + { 'mono/pnpm-workspace.yaml': "packages:\n - 'packages/*'\n", 'mono/other/package.json': '{}' }, + (tmp) => { + expect(() => workspaceGlobs(path.join(tmp, 'mono/other'))).toThrow(/declares no packages glob under other\//); + }, + ); + }); +}); + +describe('parseWorkspaceGlobs() -- the one parser, with the union of the three refusals', () => { + it('reads quoted, unquoted and commented entries, and nested `dir/*` shapes', () => { + expect(parseWorkspaceGlobs("packages:\n - 'packages/*'\n - docs\n")).toEqual(['packages/*', 'docs']); + expect(parseWorkspaceGlobs('packages:\n - "apps/*" # trailing comment\n # a comment line\n\n - packages/apps/*\n')).toEqual([ + 'apps/*', + 'packages/apps/*', + ]); + // The block ends at the next column-0 key. + expect(parseWorkspaceGlobs('packages:\n - a\nonlyBuiltDependencies:\n - esbuild\n')).toEqual(['a']); + }); + + it('refuses a glob shape it does not know rather than skipping it', () => { + expect(() => parseWorkspaceGlobs("packages:\n - 'packages/**'\n")).toThrow(/does not know/); + expect(() => parseWorkspaceGlobs("packages:\n - 'packages/*/src'\n")).toThrow(/does not know/); + expect(() => parseWorkspaceGlobs("packages:\n - '!packages/x'\n")).toThrow(/does not know/); + expect(() => parseWorkspaceGlobs("packages:\n - 'pkg?/*'\n")).toThrow(/does not know/); + }); + + it('refuses an entry it cannot read, a missing key and an empty list', () => { + expect(() => parseWorkspaceGlobs('packages:\n - a b\n')).toThrow(/cannot read the packages entry/); + expect(() => parseWorkspaceGlobs('packages:\n packages/*\n')).toThrow(/cannot read the packages entry/); + expect(() => parseWorkspaceGlobs('catalog: {}\n')).toThrow(/declares no packages/); + expect(() => parseWorkspaceGlobs('packages:\n')).toThrow(/declares no packages/); + }); +}); + +describe('turbo.json -- the nearest one for what turbo hashes, the tree’s own for its task shapes', () => { + it('standalone: every answer is `/turbo.json` and the config comes back unnarrowed', () => { + withTree(STANDALONE, (tmp) => { + const ws = path.join(tmp, 'ws'); + expect(turboConfigPath(ws)).toBe(path.join(ws, 'turbo.json')); + expect(uiTurboConfigPath(ws)).toBe(path.join(ws, 'turbo.json')); + expect(uiScopedTurboConfig(ws)).toEqual(JSON.parse(fs.readFileSync(path.join(ws, 'turbo.json'), 'utf8'))); + }); + }); + + it('nested: the parent’s turbo.json is nearest, the parked copy carries the tree’s own shapes', () => { + withTree(NESTED, (tmp) => { + const mono = path.join(tmp, 'mono'); + const ui = path.join(mono, 'ui'); + expect(turboConfigPath(ui)).toBe(path.join(mono, 'turbo.json')); + expect(uiTurboConfigPath(ui)).toBe(path.join(ui, 'turbo.objectui.json')); + fs.rmSync(path.join(ui, 'turbo.objectui.json')); + expect(() => uiTurboConfigPath(ui)).toThrow(/neither turbo\.json nor turbo\.objectui\.json/); + }); + }); + + it('nested: re-roots `$TURBO_ROOT$/ui/` inputs and drops the monorepo’s own', () => { + withTree(NESTED, (tmp) => { + const scoped = uiScopedTurboConfig(path.join(tmp, 'mono/ui')); + expect(scoped.globalDependencies).toEqual(['tsconfig.tier-base.json']); + expect(scoped.tasks.build).toEqual({ dependsOn: ['^build'], inputs: ['$TURBO_DEFAULT$', '$TURBO_ROOT$/scripts/check-dist.mjs'] }); + expect(scoped.tasks['type-check']).toEqual({ dependsOn: ['^build'], inputs: ['$TURBO_DEFAULT$', '$TURBO_ROOT$/tsconfig.json'] }); + expect(scoped.tasks.lint).toEqual({ outputs: [] }); + }); + }); + + it('narrowTurboConfig with an empty prefix is the identity', () => { + const config = { globalDependencies: ['a'], tasks: { build: { inputs: ['$TURBO_ROOT$/x'] } } }; + expect(narrowTurboConfig(config, '')).toBe(config); + }); +}); + +describe('this checkout', () => { + it('has a workspace root that is the tree or its parent, and globs that expand under the tree', () => { + const ws = workspaceRoot(); + expect([uiRoot(), path.dirname(uiRoot())]).toContain(ws); + expect(isNestedWorkspace()).toBe(ws !== uiRoot()); + expect(fs.existsSync(lockfilePath())).toBe(true); + expect(fs.existsSync(workspaceManifestPath())).toBe(true); + expect(typeof rootManifest().name).toBe('string'); + const globs = workspaceGlobs(); + expect(globs.length).toBeGreaterThan(2); + // Every `dir/*` glob names a directory of this tree (`packages`, `apps`, ...). + for (const glob of globs.filter((g) => g.endsWith('/*'))) { + expect(fs.existsSync(path.join(uiRoot(), glob.slice(0, -2))), glob).toBe(true); + } + expect(fs.existsSync(turboConfigPath())).toBe(true); + expect(fs.existsSync(uiTurboConfigPath())).toBe(true); + expect(uiScopedTurboConfig().tasks.build.inputs).toContain('$TURBO_DEFAULT$'); + }); + + it('imports node builtins and ui-root.mjs only, so the pre-install gates may import it', () => { + const code = stripComments(fs.readFileSync(path.join(here, '..', 'workspace-root.mjs'), 'utf8')); + const specifiers = [...code.matchAll(/^import\b[^'"]*['"]([^'"]+)['"]/gm)].map((m) => m[1]); + expect(specifiers.length).toBeGreaterThan(0); + expect(specifiers.filter((s) => !s.startsWith('node:') && s !== './ui-root.mjs')).toEqual([]); + expect(code).not.toContain('process.cwd'); + }); + + it('declares in workspace-root.d.mts exactly the names workspace-root.mjs exports', async () => { + const declared = fs.readFileSync(path.join(here, '..', 'workspace-root.d.mts'), 'utf8'); + const declaredNames = [...declared.matchAll(/^export declare (?:const|function) (\w+)/gm)].map((m) => m[1]).sort(); + const exported = Object.keys(await import('../workspace-root.mjs')).sort(); + expect(declaredNames).toEqual(exported); + }); +}); diff --git a/scripts/__tests__/zero-test-member-exclusion-9106.test.ts b/scripts/__tests__/zero-test-member-exclusion-9106.test.ts index c1f4fb2373..2a107a1a27 100644 --- a/scripts/__tests__/zero-test-member-exclusion-9106.test.ts +++ b/scripts/__tests__/zero-test-member-exclusion-9106.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { workspaceGlobs } from '../workspace-root.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -72,7 +73,6 @@ import { fileURLToPath } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const ROOT_VITEST_CONFIG = path.join(repoRoot, 'vitest.config.mts'); -const WORKSPACE_YAML = path.join(repoRoot, 'pnpm-workspace.yaml'); const TEST_FILE = /\.test\.[cm]?[jt]sx?$/; @@ -113,7 +113,9 @@ type Member = { }; /** - * The workspace globs, read from `pnpm-workspace.yaml` rather than hard-coded. + * The workspace globs, read from the governing `pnpm-workspace.yaml` rather than + * hard-coded, through the one parser in `scripts/workspace-root.mjs` + * (objectstack#25723) -- relative to `repoRoot` in both layouts. * * The two existing pins in this directory both spell `['packages','apps','examples']` * as a literal. That is correct today and silently incomplete the day a fourth @@ -121,23 +123,7 @@ type Member = { * workspace is the failure mode this whole file is about. */ function workspaceGroups(): string[] { - const text = fs.readFileSync(WORKSPACE_YAML, 'utf8'); - const groups: string[] = []; - let inPackages = false; - - for (const rawLine of text.split('\n')) { - if (/^packages:\s*$/.test(rawLine)) { - inPackages = true; - continue; - } - if (inPackages && /^\S/.test(rawLine)) break; - if (!inPackages) continue; - - const match = rawLine.match(/^\s*-\s*'?"?([^'"\s]+)"?'?\s*$/); - if (match) groups.push(match[1]); - } - - return groups; + return workspaceGlobs(repoRoot); } /** Every directory in the workspace that actually carries a `package.json`. */ diff --git a/scripts/check-docs-route-eager-closure.mjs b/scripts/check-docs-route-eager-closure.mjs index 6287d0c9eb..a2163ae147 100644 --- a/scripts/check-docs-route-eager-closure.mjs +++ b/scripts/check-docs-route-eager-closure.mjs @@ -121,6 +121,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { maskComments } from './js-comment-mask.mjs'; import { isEntrypoint } from './invoked-as.mjs'; +import { workspaceGlobs } from './workspace-root.mjs'; export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -193,23 +194,17 @@ const TRY_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']; // --------------------------------------------------------------------------- /** - * Every workspace package, name -> absolute directory, from the globs - * `pnpm-workspace.yaml` actually declares. Reading the file rather than - * hard-coding `packages/`, `apps/`, `examples/` keeps one declaration of what - * the workspace is; an unsupported glob shape throws instead of being skipped, - * because a silently narrower workspace makes every package in it read as - * external — which is to say, as unreachable. + * Every workspace package, name -> absolute directory, from the globs the + * governing `pnpm-workspace.yaml` actually declares for this tree + * (`scripts/workspace-root.mjs`: the objectui root's own file, or the monorepo + * root's `ui/...` globs with the prefix stripped -- objectstack#25723). Reading + * the file rather than hard-coding `packages/`, `apps/`, `examples/` keeps one + * declaration of what the workspace is; an unsupported glob shape throws + * instead of being skipped, because a silently narrower workspace makes every + * package in it read as external — which is to say, as unreachable. */ export function readWorkspacePackages(root = REPO_ROOT) { - const yaml = fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8'); - const block = yaml.slice(yaml.indexOf('packages:')); - const globs = []; - for (const line of block.split('\n').slice(1)) { - if (/^\S/.test(line)) break; - const m = /^\s*-\s*'?"?([^'"\s]+)'?"?\s*$/.exec(line); - if (m) globs.push(m[1]); - } - if (!globs.length) throw new Error('pnpm-workspace.yaml declared no package globs'); + const globs = workspaceGlobs(root); const dirs = []; for (const glob of globs) { diff --git a/scripts/check-installed-spec-pin-claims.mjs b/scripts/check-installed-spec-pin-claims.mjs index b11224477c..6e2a7698be 100644 --- a/scripts/check-installed-spec-pin-claims.mjs +++ b/scripts/check-installed-spec-pin-claims.mjs @@ -102,6 +102,7 @@ import { fileURLToPath } from 'node:url'; // answers false through a symlink and the gate then does nothing, with exit 0 // and no output. `scripts/check-entry-guard.mjs` fails on the hand-typed form. import { isEntrypoint } from './invoked-as.mjs'; +import { lockfilePath } from './workspace-root.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(HERE, '..'); @@ -1107,7 +1108,7 @@ export function run(root = ROOT, ledger = LEDGER) { const files = execFileSync('git', ['-C', root, 'ls-files'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) .split('\n') .filter(Boolean); - const lockPins = pinsFromLockfile(readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8'), root); + const lockPins = pinsFromLockfile(readFileSync(lockfilePath(root), 'utf8'), root); const treePins = pinsFromResolvedTree(root, workspaceDirs(root)); const { pins, conflicts, unlinked } = reconcilePins(lockPins, treePins); const bareMajors = bareMajorsFor(pins, ledger); diff --git a/scripts/check-lint-rule-coverage.mjs b/scripts/check-lint-rule-coverage.mjs index 6d4b7abb0a..1eee761e6b 100644 --- a/scripts/check-lint-rule-coverage.mjs +++ b/scripts/check-lint-rule-coverage.mjs @@ -619,6 +619,18 @@ export const UNREACHED_GROUPS = [ 'STRENGTH decision this gate reports and does not take (objectstack ruling D1: objectui as `ui/`).', card: 'objectui#13472', }, + { + glob: 'scripts/workspace-root.d.mts', + reason: + 'The hand-written declaration twin of `scripts/workspace-root.mjs`, the helper that answers where the ' + + "pnpm workspace's root files are in both layouts (objectstack#25723); package tests and the e2e program " + + '(both compiled with `allowJs: false`) import it typed through this twin. It holds `export declare` lines ' + + 'and no executable code, and `scripts/__tests__/workspace-root.test.ts` pins its names to the module\'s ' + + 'real exports. Declared rather than repaired for the same reason as the row above: reaching a `.d.mts` ' + + 'means widening a rule-bearing `files` glob to `.mts`, a rule STRENGTH decision this gate reports and ' + + 'does not take.', + card: 'objectui#13472', + }, ]; /** diff --git a/scripts/check-lockfile-dedupe.mjs b/scripts/check-lockfile-dedupe.mjs index b38ddadf65..1ff08cc6a5 100644 --- a/scripts/check-lockfile-dedupe.mjs +++ b/scripts/check-lockfile-dedupe.mjs @@ -123,7 +123,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; -import { CACHE_DIR, STAMP_FILE } from './objectstack-pin.mjs'; +import { CACHE_DIR, OVERRIDE_PREFIX, STAMP_FILE } from './objectstack-pin.mjs'; +import { rootManifest, workspaceRoot } from './workspace-root.mjs'; const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..'); @@ -331,6 +332,22 @@ export function pinnedBuildMissing(root = REPO_ROOT) { return !fs.existsSync(path.join(root, CACHE_DIR, STAMP_FILE)); } +/** + * Whether the workspace resolves any `@objectstack/*` package through the + * pinned build -- the manifest pnpm reads overrides from declares a + * `file:.objectstack-pin/...` override. Standalone it does, and the stamp + * check below applies. Under the objectstack monorepo (objectstack#25723) the + * `@objectstack/*` packages are workspace members and no manifest declares the + * pin, so there is no build whose absence could make `pnpm dedupe --check` + * resolve from the registry; the check is then not a precondition. + * + * @param {{ pnpm?: { overrides?: Record } }} [manifest] + * @returns {boolean} + */ +export function pinDeclared(manifest = rootManifest()) { + return Object.values(manifest?.pnpm?.overrides ?? {}).some((v) => typeof v === 'string' && v.startsWith(OVERRIDE_PREFIX)); +} + /** * @param {string[]} argv * @returns {number} process exit code @@ -341,7 +358,7 @@ export function main(argv = process.argv.slice(2)) { const annotate = (message) => console.error(`::${reportOnly ? 'warning' : 'error'} title=Lockfile dedupe::${message}`); - if (pinnedBuildMissing()) { + if (pinDeclared() && pinnedBuildMissing()) { annotate( `could not take a reading — the pinned @objectstack/* build is not in ${CACHE_DIR}, so ` + '`pnpm dedupe --check` cannot resolve those packages (objectui#12957). Run `pnpm objectstack:build` ' + @@ -352,8 +369,9 @@ export function main(argv = process.argv.slice(2)) { return reportOnly ? EXIT_CLEAN : EXIT_CANNOT_RUN; } + // At the workspace root: this repository's own, or the monorepo's one level up. const run = spawnSync('pnpm', ['dedupe', '--check'], { - cwd: REPO_ROOT, + cwd: workspaceRoot(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, }); diff --git a/scripts/check-lockfile-integrity.mjs b/scripts/check-lockfile-integrity.mjs index 115bba5e59..8a28b9d0d9 100644 --- a/scripts/check-lockfile-integrity.mjs +++ b/scripts/check-lockfile-integrity.mjs @@ -113,14 +113,10 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; -import path from 'node:path'; import process from 'node:process'; -import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; - -const REPO_ROOT = path.join(fileURLToPath(new URL('.', import.meta.url)), '..'); -const LOCKFILE = 'pnpm-lock.yaml'; +import { LOCKFILE, lockfilePath, workspaceRoot } from './workspace-root.mjs'; export const EXIT_CLEAN = 0; export const EXIT_FINDINGS = 1; @@ -377,7 +373,7 @@ function readSource(spec) { if (spec.includes(':') && !fs.existsSync(spec)) { const [rev, file] = [spec.slice(0, spec.indexOf(':')), spec.slice(spec.indexOf(':') + 1)]; const out = spawnSync('git', ['show', `${rev}:${file}`], { - cwd: REPO_ROOT, + cwd: workspaceRoot(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, }); @@ -394,13 +390,16 @@ function readSource(spec) { * @returns {{ base: string, head: string }} */ export function resolveSources(argv) { - const head = flagValue(argv, '--head') ?? path.join(REPO_ROOT, LOCKFILE); + // The one lockfile sits at the workspace root -- this repository's own, or + // the monorepo's one level up (objectstack#25723). `:` below is + // relative to the repository root, which in both layouts is that same directory. + const head = flagValue(argv, '--head') ?? lockfilePath(); const explicitBase = flagValue(argv, '--base'); if (explicitBase) return { base: explicitBase, head }; const baseRef = flagValue(argv, '--base-ref') ?? 'origin/main'; const mergeBase = spawnSync('git', ['merge-base', baseRef, 'HEAD'], { - cwd: REPO_ROOT, + cwd: workspaceRoot(), encoding: 'utf8', }); const rev = mergeBase.status === 0 ? mergeBase.stdout.trim() : null; diff --git a/scripts/check-side-effects-array.mjs b/scripts/check-side-effects-array.mjs index 71dd64e6fb..95b13033ac 100644 --- a/scripts/check-side-effects-array.mjs +++ b/scripts/check-side-effects-array.mjs @@ -248,6 +248,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; import { isEntrypoint } from './invoked-as.mjs'; +import { workspaceGlobs as readWorkspaceGlobs } from './workspace-root.mjs'; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -269,34 +270,19 @@ export const normalize = (p) => (p.startsWith('./') ? p.slice(2) : p); /* -------------------------------------------------------------------------- */ /** - * The `packages:` globs from `pnpm-workspace.yaml`, read rather than hardcoded - * so a new workspace root is covered the day it is added. Understands only the - * two shapes the file uses; anything else THROWS rather than being skipped, - * because a guard that silently stops looking at part of the workspace goes on - * reporting success over a shrinking surface. + * The `packages:` globs of the workspace file that governs `root`, relative to + * `root` -- read rather than hardcoded so a new workspace root is covered the + * day it is added, and THROWING on a shape the one parser does not know rather + * than skipping it, because a guard that silently stops looking at part of the + * workspace goes on reporting success over a shrinking surface. Where that file + * is (the objectui root, or the monorepo root one level up) is + * `scripts/workspace-root.mjs`'s question (objectstack#25723). * * @param {string} [root] * @returns {string[]} */ export function workspaceGlobs(root = REPO_ROOT) { - const yaml = fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8'); - const lines = yaml.split('\n'); - const start = lines.findIndex((l) => /^packages:\s*$/.test(l)); - if (start === -1) throw new Error('pnpm-workspace.yaml no longer declares a top-level `packages:` key.'); - - const globs = []; - for (const line of lines.slice(start + 1)) { - if (/^\s*(#.*)?$/.test(line)) continue; - if (!/^\s/.test(line)) break; - const match = line.match(/^\s*-\s*['"]?([^'"#\s]+)['"]?\s*(#.*)?$/); - if (!match) { - throw new Error( - `Unparsed entry in pnpm-workspace.yaml \`packages:\`: ${JSON.stringify(line)} — teach this guard the new syntax.`, - ); - } - globs.push(match[1]); - } - return globs; + return readWorkspaceGlobs(root); } /** @param {string} [root] @returns {string[]} absolute package directories. */ diff --git a/scripts/check-ui-root-lookups.mjs b/scripts/check-ui-root-lookups.mjs index b75aea319e..a04f2f0af4 100644 --- a/scripts/check-ui-root-lookups.mjs +++ b/scripts/check-ui-root-lookups.mjs @@ -37,10 +37,16 @@ * directly or through a `const` bound earlier in the file; * - FIRES a `git rev-parse --show-toplevel` / `--git-common-dir` argument; * - FIRES an import of `find-up` or a call of `findUp(...)`; - * - SILENT a `readFileSync` of `pnpm-workspace.yaml`: that reads the - * workspace's package globs as DATA from a root already known, and - * whether the file exists after the merge is the workspace card's - * question, not this one's; + * - FIRES a `readFileSync` (or any probe above) of a marker INSIDE A LOOP + * whose body steps a directory upward -- `dir = dirname(dir)`, + * `dir = resolve(dir, '..')`: the read is the walk's existence test + * with `try/catch` for the probe. This shape escaped the first + * migration (objectstack#25685) and found the objectstack root from + * five e2e specs on the merged tree (objectstack#25723); + * - SILENT a `readFileSync` of `pnpm-workspace.yaml` outside such a loop: + * that reads the workspace's package globs as DATA from a root + * already known -- `scripts/workspace-root.mjs` is where that root + * is decided; * - SILENT a `.git` entry in a directory skip-list, a fixture that WRITES a * `pnpm-workspace.yaml`, and any mention inside a comment. * @@ -48,8 +54,10 @@ * * Every entry names a file and the reason its probe is not a repo-root lookup * (a CLI asking whether the USER's cwd is a workspace; the pin script, which - * the merge deletes). An entry whose file no longer carries a hit is STALE and - * reds the run, so the list can only shrink without being tended. + * the merge deletes; `scripts/workspace-root.mjs`, whose two probes are + * anchored on `uiRoot()` and decide the WORKSPACE root once for every reader). + * An entry whose file no longer carries a hit is STALE and reds the run, so the + * list can only shrink without being tended. */ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; @@ -75,6 +83,11 @@ export const MARKERS = new Set(['.git', 'pnpm-workspace.yaml']); const MARKER_SEGMENT = /(^|[\\/])(\.git|pnpm-workspace\.yaml)$/; const namesMarkerText = (text) => MARKERS.has(text) || MARKER_SEGMENT.test(text); const PROBES = new Set(['existsSync', 'statSync', 'lstatSync', 'accessSync']); +/** Reads that double as a probe when they sit in an upward walk (`try { readFileSync(marker) } catch { dir = dirname(dir) }`). */ +const READS = new Set(['readFileSync', 'readdirSync', 'openSync']); +/** The calls a walk steps a directory upward with. */ +const UPWARD_STEPS = new Set(['dirname']); +const PATH_COMBINERS = new Set(['join', 'resolve']); const GIT_ROOT_FLAGS = new Set(['--show-toplevel', '--git-common-dir']); /** @@ -82,6 +95,10 @@ const GIT_ROOT_FLAGS = new Set(['--show-toplevel', '--git-common-dir']); * lookup. Path → reason. A path listed here that carries no hit is stale. */ export const ALLOWLIST = new Map([ + [ + 'scripts/workspace-root.mjs', + "decides the WORKSPACE root for every reader of pnpm-workspace.yaml / pnpm-lock.yaml / the root manifest: one probe at `uiRoot()` and one at its parent, anchored on ui-root.mjs -- never a walk, never the cwd (objectstack#25723)", + ], [ 'scripts/objectstack-pin.mjs', 'packs the pin from an objectstack tree and refuses one with no pnpm-workspace.yaml -- a probe of THAT tree, not of this root; the file itself is deleted at cutover', @@ -140,12 +157,40 @@ function namesMarker(node, binds, depth = 0) { return found; } +/** + * Whether a call steps a directory upward: `dirname(x)` / `path.dirname(x)`, or + * `join(x, '..')` / `resolve(x, '..')` with a literal `..` argument. + */ +function stepsUpward(call) { + const name = calleeName(call); + if (UPWARD_STEPS.has(name)) return true; + if (!PATH_COMBINERS.has(name)) return false; + return call.arguments.some((a) => (ts.isStringLiteral(a) || ts.isNoSubstitutionTemplateLiteral(a)) && a.text === '..'); +} + +/** Whether a loop body reassigns some variable to an upward step: `dir = dirname(dir)`. */ +function loopWalksUpward(loop) { + let found = false; + const visit = (n) => { + if (found) return; + if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isCallExpression(n.right) && stepsUpward(n.right)) { + found = true; + return; + } + ts.forEachChild(n, visit); + }; + visit(loop.statement); + return found; +} + +const isLoop = (node) => ts.isForStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node); + /** * Every root lookup in one source text. * * @param {string} file Path used for reporting and for the script kind. * @param {string} text - * @returns {{ line: number, shape: 'marker-probe' | 'git-toplevel' | 'find-up', text: string }[]} + * @returns {{ line: number, shape: 'marker-probe' | 'marker-walk' | 'git-toplevel' | 'find-up', text: string }[]} */ export function lookupsIn(file, text) { const kind = /\.tsx$/.test(file) ? ts.ScriptKind.TSX : /\.[cm]?js$/.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS; @@ -159,6 +204,10 @@ export function lookupsIn(file, text) { text: node.getText(src).replace(/\s+/g, ' ').slice(0, 120), }); }; + // The loops enclosing the node being visited, innermost last; a marker read + // inside one that steps upward is the walk's existence test, not a data read. + const loops = []; + const inUpwardWalk = () => loops.some(loopWalksUpward); const walk = (node) => { if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === 'find-up') { record(node, 'find-up'); @@ -167,10 +216,17 @@ export function lookupsIn(file, text) { if (name === 'require' && node.arguments[0] && ts.isStringLiteral(node.arguments[0]) && node.arguments[0].text === 'find-up') record(node, 'find-up'); else if (name === 'findUp' || name === 'findUpSync') record(node, 'find-up'); else if (PROBES.has(name) && namesMarker(node.arguments[0], binds)) record(node, 'marker-probe'); + else if (READS.has(name) && namesMarker(node.arguments[0], binds) && inUpwardWalk()) record(node, 'marker-walk'); } else if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && GIT_ROOT_FLAGS.has(node.text)) { record(node, 'git-toplevel'); } - ts.forEachChild(node, walk); + if (isLoop(node)) { + loops.push(node); + ts.forEachChild(node, walk); + loops.pop(); + } else { + ts.forEachChild(node, walk); + } }; walk(src); return hits; @@ -229,8 +285,17 @@ export function selfTest() { run('`--git-common-dir`', `${head}const common = execFileSync('git', ['rev-parse', '--git-common-dir']);`, 'fires'); run('an import of find-up', `import { findUp } from 'find-up';\nconst root = await findUp('pnpm-workspace.yaml');`, 'fires'); run('a statSync probe in a .mjs file', `import { statSync } from 'node:fs';\ntry { statSync(\`\${dir}/.git\`); } catch {}`, 'fires', 'scripts/x.mjs'); + run( + 'a readFileSync of the marker as the existence test of a dirname walk (the e2e IIFE, objectstack#25723)', + `${head}const REPO_ROOT = (() => {\n let dir = dirname(__filename);\n for (let i = 0; i < 10; i++) {\n try {\n readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8');\n return dir;\n } catch {\n dir = dirname(dir);\n }\n }\n throw new Error('no root');\n})();`, + 'fires', + 'e2e/x.spec.ts', + ); + run('a readFileSync of .git in a while loop stepping with resolve(dir, "..")', `import fs from 'node:fs';\nimport path from 'node:path';\nwhile (dir !== '/') { try { fs.readFileSync(path.join(dir, '.git')); break; } catch { dir = path.resolve(dir, '..'); } }`, 'fires'); // ── SILENT ─────────────────────────────────────────────────────────────── run('reading the workspace globs as data from a known root', `${head}const yaml = readFileSync(join(repoRoot, 'pnpm-workspace.yaml'), 'utf8');`, 'silent'); + run('reading the marker as data inside a loop over KNOWN roots (no upward step)', `${head}for (const root of roots) {\n const yaml = readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8');\n globs.push(yaml);\n}`, 'silent'); + run('an upward walk that reads something other than a marker', `${head}let dir = __dirname;\nwhile (dir !== '/') { try { readFileSync(join(dir, 'package.json')); break; } catch { dir = dirname(dir); } }`, 'silent'); run('a directory skip-list naming .git', `const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo']);`, 'silent'); run('a fixture that writes a pnpm-workspace.yaml', `${head}writeFileSync(join(dir, 'pnpm-workspace.yaml'), "packages:\\n - 'packages/*'\\n");`, 'silent'); run('a comment that mentions every spelling', `// walks up to .git or pnpm-workspace.yaml, like git rev-parse --show-toplevel or find-up\nexport const x = 1;`, 'silent'); diff --git a/scripts/select-affected-tests.mjs b/scripts/select-affected-tests.mjs index 18191445f2..a72a005188 100644 --- a/scripts/select-affected-tests.mjs +++ b/scripts/select-affected-tests.mjs @@ -110,6 +110,7 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +import { parseWorkspaceGlobs, workspaceGlobs } from './workspace-root.mjs'; export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -145,35 +146,12 @@ const RESOLVE_EXTENSIONS = ['', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '. const toPosix = (p) => p.split(path.sep).join('/'); /** - * The `packages:` globs of `pnpm-workspace.yaml`. Only the shapes this - * repository writes are read -- `dir/*` and a bare `dir` -- and anything else is - * a refusal, never a guess (an unread glob would silently drop packages out of - * the graph). - * - * @param {string} text - * @returns {string[]} + * The `packages:` globs of a `pnpm-workspace.yaml` text: the one parser in + * `scripts/workspace-root.mjs`, re-exported for this script's callers. Only + * `dir/*` and a bare `dir` are read, and anything else is a refusal, never a + * guess (an unread glob would silently drop packages out of the graph). */ -export function parseWorkspaceGlobs(text) { - const globs = []; - let inPackages = false; - for (const raw of text.split('\n')) { - const line = raw.replace(/#.*$/, '').trimEnd(); - if (!line.trim()) continue; - if (/^\S/.test(line)) { - inPackages = /^packages:\s*$/.test(line); - continue; - } - if (!inPackages) continue; - const m = line.match(/^\s*-\s*['"]?([^'"]+?)['"]?\s*$/); - if (!m) throw new Error(`pnpm-workspace.yaml: cannot read the packages entry ${JSON.stringify(raw)}`); - if (!/^[A-Za-z0-9._-]+(?:\/\*)?$/.test(m[1])) { - throw new Error(`pnpm-workspace.yaml: the packages glob ${JSON.stringify(m[1])} is a shape this reader does not know`); - } - globs.push(m[1]); - } - if (globs.length === 0) throw new Error('pnpm-workspace.yaml declares no packages globs'); - return globs; -} +export { parseWorkspaceGlobs }; /** * Every workspace package: its name, its repository-relative directory and the @@ -183,7 +161,9 @@ export function parseWorkspaceGlobs(text) { * @returns {Array<{ name: string, dir: string, deps: string[] }>} */ export function listWorkspacePackages(root = REPO_ROOT) { - const globs = parseWorkspaceGlobs(fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8')); + // Relative to `root` in both layouts: the objectui root's own file, or the + // monorepo root's `ui/...` globs with the prefix stripped (objectstack#25723). + const globs = workspaceGlobs(root); const dirs = []; for (const glob of globs) { if (glob.endsWith('/*')) { diff --git a/scripts/select-type-check-scope.mjs b/scripts/select-type-check-scope.mjs index bfeb7daf36..de763b4b55 100644 --- a/scripts/select-type-check-scope.mjs +++ b/scripts/select-type-check-scope.mjs @@ -61,6 +61,19 @@ * `inputs` reads every file in the repository, and then EVERY change is a * root change -- the rule degrades to the whole workspace, never to a skip. * + * Which turbo.json: the nearest one upward, through `uiScopedTurboConfig` in + * `scripts/workspace-root.mjs` (objectstack#25723). Standalone that is this + * repository's own. Under the objectstack monorepo the objectui tree is `ui/` + * and its turbo.json is parked (a nested one must `extends` the root), so the + * root's is read and narrowed to the objectui SUBSET: its `$TURBO_ROOT$/ui/x` + * inputs are the re-rooted spelling of `$TURBO_ROOT$/x` and are read as such, + * and every other `$TURBO_ROOT$` input and `globalDependencies` entry is the + * monorepo's own and is dropped -- read whole, the rule would widen every + * objectui change to the whole monorepo. The changed files are listed + * `--relative` to this tree for the same reason: they are matched against + * globs that are relative to it, and a change outside the tree is not this + * selector's question. + * * ## The repo-root programs need their packages BUILT, whatever the set * * The job also compiles programs that sit in no package, after the turbo @@ -98,6 +111,7 @@ import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +import { nestedPrefix, uiScopedTurboConfig } from './workspace-root.mjs'; export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -245,10 +259,15 @@ export function rootProgramPackages(root, tsconfigs = ROOT_PROGRAMS_AFTER_BUILD) return [...names].sort(); } -/** `git diff --name-only `, or a thrown error the caller turns into "no verdict". */ +/** + * `git diff --name-only --relative ` from `root`, or a thrown error + * the caller turns into "no verdict". `--relative` is a no-op when `root` is the + * repository root; under the monorepo it lists the paths relative to `ui/`, the + * frame the root rule's globs are in. + */ export function changedFiles(root, base, head) { execFileSync('git', ['rev-parse', '--verify', '--quiet', `${base}^{commit}`], { cwd: root, stdio: 'ignore' }); - const out = execFileSync('git', ['diff', '--name-only', base, head], { cwd: root, encoding: 'utf8' }); + const out = execFileSync('git', ['diff', '--name-only', '--relative', base, head], { cwd: root, encoding: 'utf8' }); return out.split('\n').map((s) => s.trim()).filter(Boolean); } @@ -265,7 +284,11 @@ export function affectedPackages(root, base) { maxBuffer: 1 << 28, }); const dry = JSON.parse(out.slice(out.indexOf('{'))); - return dry.tasks.filter((t) => t.task === 'type-check').map((t) => t.package).sort(); + // Under the monorepo turbo's dry run spans the whole workspace; only the + // tasks of packages under this tree are this selector's answer. + const prefix = nestedPrefix(root); + const ours = (t) => !prefix || String(t.directory ?? '').startsWith(prefix); + return dry.tasks.filter((t) => t.task === 'type-check' && ours(t)).map((t) => t.package).sort(); } function parseArgs(argv) { @@ -297,7 +320,7 @@ export function main(argv, deps = {}) { for (const name of rootProgramPackages(root)) log(name); return 0; } - const turbo = JSON.parse(fs.readFileSync(path.join(root, 'turbo.json'), 'utf8')); + const turbo = uiScopedTurboConfig(root); const rule = rootRule(turbo); const changed = deps.changed ?? changedFiles(root, args.base, args.head); log(`${changed.length} file(s) changed against ${args.base}.`); diff --git a/scripts/workspace-root.d.mts b/scripts/workspace-root.d.mts new file mode 100644 index 0000000000..42a1503262 --- /dev/null +++ b/scripts/workspace-root.d.mts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Declaration twin of `workspace-root.mjs`, so a TypeScript test under a package +// (compiled with `allowJs: false`) or under `e2e/` imports the helper typed, +// with no `@ts-expect-error` and no local annotation. +// `scripts/__tests__/workspace-root.test.ts` pins that the names declared here +// are exactly the names the module exports. + +export declare const WORKSPACE_MANIFEST: 'pnpm-workspace.yaml'; +export declare const LOCKFILE: 'pnpm-lock.yaml'; +export declare const ROOT_MANIFEST: 'package.json'; +export declare const TURBO_CONFIG: 'turbo.json'; +export declare const PARKED_TURBO_CONFIG: 'turbo.objectui.json'; +export declare const TURBO_ROOT_PREFIX: '$TURBO_ROOT$/'; + +/** The directory whose `pnpm-workspace.yaml` governs the tree: `root`, else its parent, else a throw. */ +export declare function workspaceRoot(root?: string): string; +/** Whether the workspace root is the parent of the tree (the monorepo layout). */ +export declare function isNestedWorkspace(root?: string): boolean; +/** The tree's directory-name prefix in the parent's globs (`ui/`), or `''`. */ +export declare function nestedPrefix(root?: string): string; +export declare function workspaceManifestPath(root?: string): string; +export declare function lockfilePath(root?: string): string; +export declare function rootManifestPath(root?: string): string; +export declare function rootManifest(root?: string): { + name?: string; + pnpm?: { overrides?: Record; patchedDependencies?: Record }; +} & Record; +/** The `packages:` globs of a workspace file's text; throws on every shape it does not know. */ +export declare function parseWorkspaceGlobs(text: string): string[]; +/** The globs that describe this tree, relative to `root` in both layouts. */ +export declare function workspaceGlobs(root?: string): string[]; +/** The nearest `turbo.json` upward: what turbo hashes from. */ +export declare function turboConfigPath(root?: string): string; +/** objectui's own task declaration: `turbo.json` at `root`, else the parked copy. */ +export declare function uiTurboConfigPath(root?: string): string; + +export interface TurboConfigLike { + globalDependencies?: string[]; + tasks?: Record>; +} +/** `$TURBO_ROOT$/x` read as `$TURBO_ROOT$/x`; other root-anchored inputs dropped. */ +export declare function narrowTurboConfig(config: T, prefix: string): T; +/** The nearest `turbo.json`, parsed and narrowed to this tree's subset when it is the parent's. */ +export declare function uiScopedTurboConfig(root?: string): { + globalDependencies?: string[]; + tasks: Record; +}; diff --git a/scripts/workspace-root.mjs b/scripts/workspace-root.mjs new file mode 100644 index 0000000000..3e5e068e46 --- /dev/null +++ b/scripts/workspace-root.mjs @@ -0,0 +1,301 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * workspace-root -- the ONE answer to "where are the pnpm workspace's files?" + * + * import { workspaceGlobs, lockfilePath, rootManifest } from '../scripts/workspace-root.mjs'; + * for (const glob of workspaceGlobs()) ... // relative to uiRoot(), in both layouts + * const lock = readFileSync(lockfilePath(), 'utf8'); + * + * ## Why a second helper beside `ui-root.mjs` + * + * `ui-root.mjs` answers "where is the objectui TREE" from its own location, and + * that answer is the same standalone and under the objectstack monorepo's `ui/` + * (objectstack ruling D1). Four files are NOT at that root in both layouts: the + * import deletes or moves them, because a nested pnpm workspace root is ignored + * and a nested `turbo.json` must `extends` the root. + * + * - `pnpm-workspace.yaml` deleted; the parent's declares `ui`, `ui/packages/*`, ... + * - `pnpm-lock.yaml` deleted; one lockfile at the git root + * - the root `package.json`'s `pnpm` block (overrides, patchedDependencies) + * moved to the workspace root's manifest and + * workspace file + * - `turbo.json` parked as `turbo.objectui.json`; the parent's + * carries objectui's tasks with their repo-root + * inputs re-rooted to `$TURBO_ROOT$/ui/...` + * + * Every reader that joined `uiRoot()` with one of those names was the first red + * in the merged tree's CI (objectstack#25723). This module is where that join + * now happens, once, with one rule. + * + * ## The rule + * + * (a) `workspaceRoot()` is `uiRoot()` when `/pnpm-workspace.yaml` + * exists; else the PARENT directory when THAT holds one; else it throws. + * The objectui root is preferred so a deleted file in the standalone + * layout is a loud error, never a silent read of some other workspace. + * (b) `workspaceGlobs()` are the `packages:` globs of that file. When the root + * is the parent, only the globs under the objectui tree's own directory + * name (`ui/` in the monorepo) are returned, with that prefix stripped; + * the bare `ui` entry is the objectui root itself and is dropped. So the + * globs are relative to `uiRoot()` in both layouts. + * (c) `lockfilePath()` and `rootManifestPath()` / `rootManifest()` follow the + * same root. + * (d) `turboConfigPath()` is the nearest `turbo.json` upward (what turbo + * hashes from); `uiTurboConfigPath()` is objectui's OWN task declaration, + * `turbo.json` at `uiRoot()` or the parked `turbo.objectui.json`; and + * `uiScopedTurboConfig()` is the nearest config narrowed to the objectui + * subset (`$TURBO_ROOT$/ui/x` read as `$TURBO_ROOT$/x`, the rest dropped). + * + * Every function takes an optional explicit root for fixtures: a test that + * writes its own `pnpm-workspace.yaml` into a scratch directory is a workspace + * root by rule (a). + * + * Standalone, every answer here is exactly the path or list the readers + * computed before: the file is at the objectui root, so rule (a) picks it and + * rule (b) returns the globs verbatim. + * + * ## The one parser + * + * Three scripts carried their own `packages:` parser; `parseWorkspaceGlobs` is + * their union of refusals: no `packages:` key, an entry it cannot read, a glob + * shape other than `dir` or `dir/*` (so `**`, a mid-path `*`, a negation), and + * an empty list all THROW. A reader that silently skipped part of the workspace + * would go on reporting success over a shrinking surface. + * + * ⛔ Node builtins only: `check-lockfile-dedupe.mjs` and + * `check-lockfile-integrity.mjs` import this before `pnpm install` + * (`scripts/check-pre-install-import-graph.mjs` holds that property). + * + * `pnpm check:ui-root-lookups` allowlists this file's `existsSync` of the + * marker: it is one probe at a KNOWN root and one at its parent, anchored on + * `ui-root.mjs`, never a walk and never a search from the cwd. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import { uiRoot } from './ui-root.mjs'; + +export const WORKSPACE_MANIFEST = 'pnpm-workspace.yaml'; +export const LOCKFILE = 'pnpm-lock.yaml'; +export const ROOT_MANIFEST = 'package.json'; +export const TURBO_CONFIG = 'turbo.json'; +/** Where the cutover parks objectui's own `turbo.json` (a nested one must `extends` the root). */ +export const PARKED_TURBO_CONFIG = 'turbo.objectui.json'; +export const TURBO_ROOT_PREFIX = '$TURBO_ROOT$/'; + +/** + * The directory whose `pnpm-workspace.yaml` governs the objectui tree: `root` + * itself when it holds one, else its parent when that does, else a throw. + * + * @param {string} [root] the objectui tree (default `uiRoot()`), or a fixture root + * @returns {string} absolute, no trailing separator + */ +export function workspaceRoot(root = uiRoot()) { + const own = path.resolve(root); + if (fs.existsSync(path.join(own, WORKSPACE_MANIFEST))) return own; + const parent = path.dirname(own); + if (parent !== own && fs.existsSync(path.join(parent, WORKSPACE_MANIFEST))) return parent; + throw new Error( + `no ${WORKSPACE_MANIFEST} at ${own} or at ${parent}: the objectui tree is neither a pnpm workspace root ` + + 'nor nested one level below one (objectstack#25723).', + ); +} + +/** + * Whether the workspace root is the PARENT of the objectui tree -- the merged + * layout, where the tree is `ui/` of the objectstack monorepo. + * + * @param {string} [root] + * @returns {boolean} + */ +export function isNestedWorkspace(root = uiRoot()) { + return workspaceRoot(root) !== path.resolve(root); +} + +/** + * The directory-name prefix the parent's globs carry for this tree (`ui/` in + * the monorepo), or `''` when the tree is its own workspace root. + * + * @param {string} [root] + * @returns {string} + */ +export function nestedPrefix(root = uiRoot()) { + return isNestedWorkspace(root) ? `${path.basename(path.resolve(root))}/` : ''; +} + +/** @param {string} [root] @returns {string} absolute path of the governing `pnpm-workspace.yaml`. */ +export function workspaceManifestPath(root = uiRoot()) { + return path.join(workspaceRoot(root), WORKSPACE_MANIFEST); +} + +/** @param {string} [root] @returns {string} absolute path of the one `pnpm-lock.yaml`. */ +export function lockfilePath(root = uiRoot()) { + return path.join(workspaceRoot(root), LOCKFILE); +} + +/** + * The manifest at the workspace root: standalone the one whose `pnpm` block + * declares the `@objectstack/*` pin overrides and `patchedDependencies`. ⚠️ In + * the monorepo pnpm 10 reads `overrides` and `patchedDependencies` from the + * workspace file's own `overrides:` / `patchedDependencies:` keys, so that + * manifest's `pnpm` block may hold neither; a reader that needs the resolved + * overrides reads the file at `workspaceManifestPath()` as well. + * + * @param {string} [root] + * @returns {string} + */ +export function rootManifestPath(root = uiRoot()) { + return path.join(workspaceRoot(root), ROOT_MANIFEST); +} + +/** + * @param {string} [root] + * @returns {{ name?: string, pnpm?: { overrides?: Record, patchedDependencies?: Record } } & Record} + */ +export function rootManifest(root = uiRoot()) { + return JSON.parse(fs.readFileSync(rootManifestPath(root), 'utf8')); +} + +/** + * The `packages:` globs of a `pnpm-workspace.yaml`, read as text (a flat list of + * scalars under one key; the gates in this repository read their inputs as text + * by convention). Comments are stripped, quotes are optional, and the block + * ends at the next column-0 key. + * + * Refuses (throws) rather than guesses: a file with no `packages:` key, an entry + * the item regex cannot read, a glob that is not `dir` or `dir/*` (a nested + * `a/b/*` is still `dir/*`; `**`, a mid-path `*`, `?`, braces and `!` are not), + * and a list that came out empty. + * + * @param {string} text + * @returns {string[]} + */ +export function parseWorkspaceGlobs(text) { + const globs = []; + let inPackages = false; + let sawKey = false; + for (const raw of text.split('\n')) { + const line = raw.replace(/#.*$/, '').trimEnd(); + if (!line.trim()) continue; + if (/^\S/.test(line)) { + inPackages = /^packages:\s*$/.test(line); + if (inPackages) sawKey = true; + continue; + } + if (!inPackages) continue; + const m = line.match(/^\s*-\s*(?:'([^']*)'|"([^"]*)"|(\S+))\s*$/); + const glob = m ? (m[1] ?? m[2] ?? m[3]) : null; + if (!glob) throw new Error(`${WORKSPACE_MANIFEST}: cannot read the packages entry ${JSON.stringify(raw)}`); + if (!/^[A-Za-z0-9._@-]+(?:\/[A-Za-z0-9._@-]+)*(?:\/\*)?$/.test(glob)) { + throw new Error(`${WORKSPACE_MANIFEST}: the packages glob ${JSON.stringify(glob)} is a shape this reader does not know`); + } + globs.push(glob); + } + if (!sawKey) throw new Error(`${WORKSPACE_MANIFEST} declares no packages globs: it has no top-level \`packages:\` key.`); + if (globs.length === 0) throw new Error(`${WORKSPACE_MANIFEST} declares no packages globs`); + return globs; +} + +/** + * The workspace globs that describe THIS tree, relative to `root` in both + * layouts: the file's globs verbatim when `root` is the workspace root; when + * the parent is, only the globs under this tree's directory name with that + * prefix stripped (the bare directory entry, the tree itself, is dropped). + * + * @param {string} [root] + * @returns {string[]} + */ +export function workspaceGlobs(root = uiRoot()) { + const globs = parseWorkspaceGlobs(fs.readFileSync(workspaceManifestPath(root), 'utf8')); + const prefix = nestedPrefix(root); + if (!prefix) return globs; + const own = globs.filter((g) => g.startsWith(prefix)).map((g) => g.slice(prefix.length)); + if (own.length === 0) { + throw new Error(`${workspaceManifestPath(root)} declares no packages glob under ${prefix}, so it does not describe ${path.resolve(root)}`); + } + return own; +} + +/** + * The nearest `turbo.json` upward from `root` -- the file turbo itself hashes + * from: at `root`, else at its parent, else a throw. + * + * @param {string} [root] + * @returns {string} + */ +export function turboConfigPath(root = uiRoot()) { + const own = path.resolve(root); + if (fs.existsSync(path.join(own, TURBO_CONFIG))) return path.join(own, TURBO_CONFIG); + const parent = path.dirname(own); + if (parent !== own && fs.existsSync(path.join(parent, TURBO_CONFIG))) return path.join(parent, TURBO_CONFIG); + throw new Error(`no ${TURBO_CONFIG} at ${own} or at ${parent} (objectstack#25723).`); +} + +/** + * objectui's OWN task declaration, for a test that pins objectui's task shapes + * (`$TURBO_ROOT$` inputs relative to the objectui tree, `//#` root tasks of its + * own manifest): `turbo.json` at `root`, else the parked `turbo.objectui.json`, + * else a throw. + * + * @param {string} [root] + * @returns {string} + */ +export function uiTurboConfigPath(root = uiRoot()) { + const own = path.resolve(root); + for (const name of [TURBO_CONFIG, PARKED_TURBO_CONFIG]) { + const file = path.join(own, name); + if (fs.existsSync(file)) return file; + } + throw new Error(`neither ${TURBO_CONFIG} nor ${PARKED_TURBO_CONFIG} at ${own} (objectstack#25723).`); +} + +/** + * A turbo config narrowed to one nested tree: every `$TURBO_ROOT$/x` + * input becomes `$TURBO_ROOT$/x`, every other `$TURBO_ROOT$/...` input (the + * monorepo's own files) is dropped, and `globalDependencies` are filtered the + * same way. Package-relative inputs and `dependsOn` are untouched. With an + * empty prefix the config comes back as it was. + * + * @template {{ globalDependencies?: string[], tasks?: Record }} T + * @param {T} config + * @param {string} prefix `ui/` or `''` + * @returns {T} + */ +export function narrowTurboConfig(config, prefix) { + if (!prefix) return config; + const keep = (entry, anchor) => { + if (!entry.startsWith(anchor)) return entry; + const rel = entry.slice(anchor.length); + return rel.startsWith(prefix) ? `${anchor}${rel.slice(prefix.length)}` : null; + }; + const narrowed = { ...config }; + if (Array.isArray(config.globalDependencies)) { + narrowed.globalDependencies = config.globalDependencies.map((g) => keep(g, '')).filter((g) => g !== null); + } + if (config.tasks) { + narrowed.tasks = {}; + for (const [name, def] of Object.entries(config.tasks)) { + narrowed.tasks[name] = Array.isArray(def?.inputs) + ? { ...def, inputs: def.inputs.map((i) => keep(i, TURBO_ROOT_PREFIX)).filter((i) => i !== null) } + : def; + } + } + return narrowed; +} + +/** + * The nearest `turbo.json`, parsed, narrowed to this tree's subset when it is + * the parent's (see `narrowTurboConfig`). Standalone this is `/turbo.json` + * verbatim. + * + * @param {string} [root] + * @returns {{ globalDependencies?: string[], tasks: Record }} + */ +export function uiScopedTurboConfig(root = uiRoot()) { + const file = turboConfigPath(root); + const config = JSON.parse(fs.readFileSync(file, 'utf8')); + const nested = path.dirname(file) !== path.resolve(root); + return narrowTurboConfig(config, nested ? `${path.basename(path.resolve(root))}/` : ''); +} diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json index 4d5e7a94c5..248bc75c56 100644 --- a/tsconfig.e2e.json +++ b/tsconfig.e2e.json @@ -23,9 +23,10 @@ // true, for its whole life. Playwright would never have told anyone. // // This project is standalone and cheap: it imports no workspace package — every -// import in it is `@playwright/test`, `./helpers`, or a `node:` builtin — so unlike -// `tsconfig.vitest-setup.json` it needs no built declarations and can run before, -// or independently of, any build. `scripts/__tests__/e2e-type-check.test.ts` pins +// import in it is `@playwright/test`, `./helpers`, a `node:` builtin, or the +// builtins-only `scripts/ui-root.mjs` (typed through its `.d.mts` twin) — so +// unlike `tsconfig.vitest-setup.json` it needs no built declarations and can run +// before, or independently of, any build. `scripts/__tests__/e2e-type-check.test.ts` pins // that premise, so if a spec ever imports `@object-ui/*` the wiring has to be // reconsidered rather than silently starting to depend on `dist`. // @@ -114,10 +115,11 @@ // the loader genuinely imposes. "isolatedModules": true, - // No `allowJs`, unlike `tsconfig.scripts.json`: nothing under `e2e/` imports - // a `.js`/`.mjs` module and nothing carries a `@ts-expect-error`, so it would - // buy nothing while walking into the TS2578 interaction documented in - // `apps/console/tsconfig.node.json` (objectui#3513). + // No `allowJs`, unlike `tsconfig.scripts.json`: the one `.mjs` module the + // specs import (`scripts/ui-root.mjs`, objectstack#25723) is typed by its + // hand-written `.d.mts` twin, and nothing carries a `@ts-expect-error`, so + // it would buy nothing while walking into the TS2578 interaction documented + // in `apps/console/tsconfig.node.json` (objectui#3513). "allowJs": false, // Legal only because this project is standalone — TS6310 forbids `noEmit` on