diff --git a/.changeset/25733-workspace-root-test-assumptions.md b/.changeset/25733-workspace-root-test-assumptions.md new file mode 100644 index 0000000000..83721fc69e --- /dev/null +++ b/.changeset/25733-workspace-root-test-assumptions.md @@ -0,0 +1,4 @@ +--- +--- + +Tooling-only change (objectstack#25733): three tests no longer assume this tree is the pnpm workspace root. `check-doc-snippet-types` finds installed copies in the workspace root's `node_modules/.pnpm` (`virtualStorePath()` in `scripts/workspace-root.mjs`), the Console vendor-chunk tests read their standalone and merged arms off fixture layouts, and the CLI generator test runs its in-workspace case from a fixture workspace. `@object-ui/cli`'s `currentContext()` now asks `isWorkspaceRoot(cwd)`, the predicate `dev`, `serve` and `build` already use: the same expression, so no published behaviour changes. diff --git a/packages/cli/src/__tests__/app-generator.test.ts b/packages/cli/src/__tests__/app-generator.test.ts index 69dc4c9495..8dae097b3e 100644 --- a/packages/cli/src/__tests__/app-generator.test.ts +++ b/packages/cli/src/__tests__/app-generator.test.ts @@ -1380,15 +1380,46 @@ describe('generation onto disk', () => { } } + /** + * A workspace root this file builds itself: a directory holding its own + * `pnpm-workspace.yaml`, with one member below it. Not `REPO_ROOT`: the + * objectui tree is a workspace root only standalone, and under the + * objectstack monorepo it is `ui/`, a member whose workspace file is one + * level up (objectstack#25733). A fixture names the branch in both layouts. + */ + function withWorkspace(run: (root: string, member: string) => void): void { + withTempDir((root) => { + writeFileSync(join(root, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n"); + const member = join(root, 'packages', 'app'); + mkdirSync(member, { recursive: true }); + run(root, member); + }); + } + it('writes the IN-WORKSPACE map when the generator runs at a workspace root', () => { - // `REPO_ROOT` walks up from `import.meta.url`, so this pins the same branch - // whatever directory the run was launched from — and `IN_WORKSPACE` is that - // same file-derived root, which is what keeps the expectation independent - // too. Put either side back on `process.cwd()` and this case silently - // becomes a third copy of the ambient mirror. - withTempDir((dir) => { - writingFrom(REPO_ROOT, () => createTempApp(dir, SCHEMA)); - expect(filesOnDisk(dir)).toEqual(buildAppFiles(SCHEMA, IN_WORKSPACE)); + // The fixture root is derived here, not from `process.cwd()`, so this pins + // the same branch whatever directory the run was launched from, and the + // expectation names that same root. Put either side back on + // `process.cwd()` and this case silently becomes a third copy of the + // ambient mirror. + withWorkspace((root) => { + withTempDir((dir) => { + writingFrom(root, () => createTempApp(dir, SCHEMA)); + expect(filesOnDisk(dir)).toEqual(buildAppFiles(SCHEMA, { cwd: root, isMonorepo: true })); + }); + }); + }); + + it('writes the STANDALONE map from a workspace MEMBER — the generator does not walk up', () => { + // `currentContext()` asks `isWorkspaceRoot(cwd)`, the predicate `dev` and + // `serve` alias by: a `pnpm-workspace.yaml` ABOVE cwd makes no alias table, + // so it must not make empty dependency maps either. This is a user's + // package inside their own monorepo, and the objectui tree under `ui/`. + withWorkspace((_root, member) => { + withTempDir((dir) => { + writingFrom(member, () => createTempApp(dir, SCHEMA)); + expect(filesOnDisk(dir)).toEqual(buildAppFiles(SCHEMA, { cwd: member, isMonorepo: false })); + }); }); }); diff --git a/packages/cli/src/utils/app-generator.ts b/packages/cli/src/utils/app-generator.ts index cc202c9a0a..c6e1ff2878 100644 --- a/packages/cli/src/utils/app-generator.ts +++ b/packages/cli/src/utils/app-generator.ts @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from 'fs'; +import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs'; import { dirname, join } from 'path'; import chalk from 'chalk'; import * as yaml from 'js-yaml'; @@ -16,6 +16,7 @@ import { REACT_RANGE, SCAFFOLD_DEV_DEPENDENCIES } from './scaffold-dependencies.js'; +import { isWorkspaceRoot } from './workspace-vite.js'; export interface RouteInfo { path: string; @@ -281,10 +282,25 @@ export interface AppGeneratorContext { isMonorepo: boolean; } -/** The context the CLI commands themselves generate under. */ +/** + * The context the CLI commands themselves generate under. + * + * "Runs at a workspace root" means `cwd` ITSELF holds a `pnpm-workspace.yaml`, + * and nothing wider: the answer is `isWorkspaceRoot(cwd)`, the same predicate + * `dev`, `serve` and `build` use to skip `npm install` and to build the alias + * table from that file's globs (`utils/workspace-vite.ts`). The two must agree, + * because this branch writes both dependency maps EMPTY on the promise that the + * command will alias the platform packages instead. So a directory that is only + * a workspace MEMBER is not a workspace root, whatever lies above it: a user's + * package inside their own monorepo, and equally the objectui tree when it is + * `ui/` of the objectstack monorepo (objectstack#25733), get the standalone map. + * Walking up to the nearest `pnpm-workspace.yaml` would hand them empty maps + * that no alias table backs. A user's project behaves as it always has: the + * file in `cwd` means in-workspace, no file means standalone. + */ function currentContext(): AppGeneratorContext { const cwd = process.cwd(); - return { cwd, isMonorepo: existsSync(join(cwd, 'pnpm-workspace.yaml')) }; + return { cwd, isMonorepo: isWorkspaceRoot(cwd) }; } /** diff --git a/scripts/__tests__/check-doc-snippet-emitted-census.test.ts b/scripts/__tests__/check-doc-snippet-emitted-census.test.ts index 3bec08f0b6..17e360c2be 100644 --- a/scripts/__tests__/check-doc-snippet-emitted-census.test.ts +++ b/scripts/__tests__/check-doc-snippet-emitted-census.test.ts @@ -503,7 +503,9 @@ describe("objectui#8397 — the manifest the emitted file's reader installs", () [`node_modules/${ROOT_DECLARED_CONTROL}/index.d.ts`]: 'export declare const control: number;\n', // The gate reads "is it installed?" from pnpm's virtual store, so the // fixture lays one out: an UNINSTALLED control produces TS2307 whether the - // bound ran or not, and would pin nothing. + // bound ran or not, and would pin nothing. The store is the WORKSPACE + // root's (objectstack#25733), so the fixture is one. + 'pnpm-workspace.yaml': "packages:\n - 'packages/*'\n", [`node_modules/.pnpm/${ROOT_DECLARED_CONTROL}@4.0.0/node_modules/${ROOT_DECLARED_CONTROL}/package.json`]: JSON.stringify({ name: ROOT_DECLARED_CONTROL, version: '4.0.0', types: './index.d.ts' }), [`node_modules/.pnpm/${ROOT_DECLARED_CONTROL}@4.0.0/node_modules/${ROOT_DECLARED_CONTROL}/index.d.ts`]: diff --git a/scripts/__tests__/check-doc-snippet-types.test.ts b/scripts/__tests__/check-doc-snippet-types.test.ts index 0dcd5fcdcc..8a245f2901 100644 --- a/scripts/__tests__/check-doc-snippet-types.test.ts +++ b/scripts/__tests__/check-doc-snippet-types.test.ts @@ -1376,6 +1376,34 @@ describe('third-party resolution reaches exactly as far as the imported packages ).toBeTruthy(); }); + it('finds an installed copy in the WORKSPACE root store, in both layouts (objectstack#25733)', () => { + // Standalone the tree is the workspace root; under the monorepo it is + // `ui/`, whose node_modules has no `.pnpm`, and the store is one level up. + // A tree no workspace governs has nothing installed in it. + const store = 'node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@scope/pkg/package.json'; + const workspace = "packages:\n - 'packages/*'\n"; + const root = tempTree({ + 'ws/pnpm-workspace.yaml': workspace, + [`ws/${store}`]: '{}', + 'mono/pnpm-workspace.yaml': workspace, + [`mono/${store}`]: '{}', + 'mono/ui/node_modules/other/package.json': '{}', + [`loose/${store}`]: '{}', + }); + try { + expect(findInstalledCopy(path.join(root, 'ws'), '@scope/pkg')).toBe( + 'node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@scope/pkg', + ); + expect(findInstalledCopy(path.join(root, 'mono/ui'), '@scope/pkg')).toBe( + '../node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@scope/pkg', + ); + expect(findInstalledCopy(path.join(root, 'mono/ui'), 'absent')).toBeNull(); + expect(findInstalledCopy(path.join(root, 'loose'), '@scope/pkg')).toBeNull(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it('neither control specifier is declared by any workspace package, in EITHER field the map reads', () => { // Widened with the map (objectui#8919). A control that stays green only // because the suite asks about one of two fields is a control that can be diff --git a/scripts/__tests__/check-ui-root-lookups.test.ts b/scripts/__tests__/check-ui-root-lookups.test.ts index e877bb36e0..f2af5d7b5a 100644 --- a/scripts/__tests__/check-ui-root-lookups.test.ts +++ b/scripts/__tests__/check-ui-root-lookups.test.ts @@ -39,6 +39,16 @@ describe('check-ui-root-lookups (objectstack ruling D1)', () => { expect(data).toEqual([]); }); + it('reads the store joined to the tree once, where the path first names it, and the root from the file it is in (objectstack#25733)', () => { + const head = "import { join, resolve } from 'node:path';\nimport { uiRoot } from './ui-root.mjs';\n"; + const nested = lookupsIn('scripts/x.mjs', `${head}const store = join(uiRoot(), 'node_modules', '.pnpm');\nconst entry = join(store, 'x@1.0.0', 'node_modules');`); + expect(nested.map((h) => [h.shape, h.text])).toEqual([['workspace-file-at-ui-root', "join(uiRoot(), 'node_modules', '.pnpm')"]]); + // The same climb lands on the tree from one file and on `scripts/` from another. + const climb = "const root = resolve(__dirname, '../..');\nconst store = join(root, 'node_modules', '.pnpm');"; + expect(lookupsIn('scripts/__tests__/x.test.ts', `${head}${climb}`).map((h) => h.shape)).toEqual(['workspace-file-at-ui-root']); + expect(lookupsIn('scripts/__tests__/deeper/x.test.ts', `${head}${climb}`)).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__/vite-objectstack-client-dist.test.ts b/scripts/__tests__/vite-objectstack-client-dist.test.ts index dd56d4c567..597855bdf6 100644 --- a/scripts/__tests__/vite-objectstack-client-dist.test.ts +++ b/scripts/__tests__/vite-objectstack-client-dist.test.ts @@ -95,14 +95,46 @@ function makeFixtureRoot(): string { * A copy of the installed client placed outside the workspace — manifest and * `dist/` intact, no reachable `node_modules` for its two dependencies. * - * This is the card's reproduction, reduced to a fixture. + * This is the card's reproduction, reduced to a fixture. The package's OWN + * `node_modules` stays behind: a store copy has none (its dependencies are its + * siblings in the store), but under the objectstack monorepo the installed + * client is a workspace link to a package that carries one, and copying that + * would carry the dependencies along (objectstack#25733). */ function outOfTreeClientCopy(): string { const dest = path.join(makeFixtureRoot(), 'client'); - fs.cpSync(fs.realpathSync(installedClientDir), dest, { recursive: true, dereference: true }); + const source = fs.realpathSync(installedClientDir); + const ownNodeModules = path.join(source, 'node_modules'); + fs.cpSync(source, dest, { recursive: true, dereference: true, filter: (src) => src !== ownNodeModules }); return dest; } +/** + * pnpm's store layout, on disk: the client in + * `.pnpm/@objectstack+client@1.0.0/node_modules/@objectstack/client` with its + * dependency as a sibling there, and a console whose `node_modules` holds only + * a SYMLINK to it. That is how a standalone checkout installs the client; under + * the objectstack monorepo it is a workspace link instead, so the realpath + * measurement below builds this layout rather than reading the checkout's + * (objectstack#25733). + */ +function storeLayoutClient(): { linked: string; real: string } { + const root = makeFixtureRoot(); + const storeModules = path.join(root, 'node_modules/.pnpm/@objectstack+client@1.0.0/node_modules'); + const real = path.join(storeModules, '@objectstack/client'); + fs.mkdirSync(real, { recursive: true }); + fs.writeFileSync( + path.join(real, 'package.json'), + JSON.stringify({ name: CLIENT_PACKAGE_NAME, dependencies: { '@objectstack/core': '1.0.0' } }) + ); + fs.mkdirSync(path.join(storeModules, '@objectstack/core'), { recursive: true }); + fs.writeFileSync(path.join(storeModules, '@objectstack/core/package.json'), '{"name":"@objectstack/core"}'); + const linked = path.join(root, 'console/node_modules/@objectstack/client'); + fs.mkdirSync(path.dirname(linked), { recursive: true }); + fs.symlinkSync(real, linked, 'dir'); + return { linked, real: fs.realpathSync(real) }; +} + /** * The walk `packageResolvesFrom` performs, re-stated here as a MEASUREMENT * rather than an import: these cases exist to show what the production walk @@ -190,22 +222,23 @@ describe('objectui#6094: a VALID override is still injected — all three spelli }); it('resolves the dependency walk through the pnpm symlink, not around it', () => { - // Skipped only where the installed client is NOT a symlink (a hoisted, - // non-pnpm install), because then there is no symlink for the realpath to - // make a difference to and the counter-probe would be measuring nothing. - if (fs.realpathSync(installedClientDir) === installedClientDir) return; - // The measurement that makes `realpathSync` load-bearing rather than - // tidy: from the symlink path, the client's own dependency is NOT found — - // the walk climbs `apps/console/node_modules` → `apps/console` → the repo - // root, none of which carry `node_modules/@objectstack/core`. From the - // store path it is found, beside the store copy of the client. - expect(walkResolves(installedClientDir, '@objectstack/core')).toBe(false); - expect(walkResolves(fs.realpathSync(installedClientDir), '@objectstack/core')).toBe(true); + // tidy, on the store layout: from the symlink path, the client's own + // dependency is NOT found — the walk climbs `console/node_modules` → + // `console` → the fixture root, none of which carry + // `node_modules/@objectstack/core`. From the store path it is found, + // beside the store copy of the client. + const { linked, real } = storeLayoutClient(); + expect(real).not.toBe(linked); + expect(walkResolves(linked, '@objectstack/core')).toBe(false); + expect(walkResolves(real, '@objectstack/core')).toBe(true); // …so the injection, which walks from `packageDir`, must be reading the // store path. Without this the check would reject the installed client — // the one input it must accept. - expect(resolveClientDistInjection(installedClientDir)!.packageDir).not.toBe(installedClientDir); + expect(resolveClientDistInjection(linked)!.packageDir).toBe(real); + // And this checkout's own installed client, store copy or workspace link, + // is judged at its real path too. + expect(resolveClientDistInjection(installedClientDir)!.packageDir).toBe(realClientDir()); }); it('does not demand `peerDependencies` — they come from the consuming app', () => { diff --git a/scripts/__tests__/vite-objectstack-spec-dist.test.ts b/scripts/__tests__/vite-objectstack-spec-dist.test.ts index 7a73510b6b..4da035aa93 100644 --- a/scripts/__tests__/vite-objectstack-spec-dist.test.ts +++ b/scripts/__tests__/vite-objectstack-spec-dist.test.ts @@ -21,6 +21,7 @@ import { versionSatisfiesRange, zodPackageDirOf, } from '../vite-objectstack-spec-dist'; +import { BASELINE_CHUNK_TESTS, frameworkPackagesDir, workspaceChunkTests } from '../vite-objectstack-workspace.ts'; /** * objectui#4854 — `apps/console/vite.config.ts` honours `OBJECTSTACK_SPEC_DIST`, @@ -93,6 +94,16 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../ const BASE_VENDOR_TEST = /([\\/]node_modules[\\/]@objectstack[\\/](?!lint[\\/])|[\\/]@objectstack\+(?!lint@))/; +/** + * The vendor test the console config builds in THIS checkout's layout with no + * override set: `BASE_VENDOR_TEST` standalone, the same arms plus the + * framework's `packages/` when the tree is `ui/` of the objectstack monorepo + * (objectstack#25733). "Inert" means "unmoved from this", in either layout; + * that the standalone arm IS the baseline literal, byte for byte, is pinned on + * a fixture layout in `vite-objectstack-workspace.test.ts`. + */ +const LAYOUT_VENDOR_TEST = workspaceChunkTests(frameworkPackagesDir(path.join(repoRoot, 'apps/console'))).vendor; + /** * The baseline "this module id is the spec" test, as the console config spells it. * @@ -930,11 +941,13 @@ describe('objectui#4854: the four flagged surfaces in the console config', () => 'react-map-gl/maplibre', ]); - // 3. the vendor-objectstack chunk test — the literal, unwidened. + // 3. the vendor-objectstack chunk test — the layout's own, unwidened. The + // mirror above is still the helper's baseline, so it cannot drift alone. const groups = config.build.rollupOptions.output.advancedChunks.groups as { name: string; test: RegExp }[]; const vendor = groups.find((g) => g.name === 'vendor-objectstack'); expect(vendor).toBeDefined(); - expect(vendor!.test.source).toBe(BASE_VENDOR_TEST.source); + expect(BASE_VENDOR_TEST.source).toBe(BASELINE_CHUNK_TESTS.vendor.source); + expect(vendor!.test.source).toBe(LAYOUT_VENDOR_TEST.source); // …and what that literal MEANS, so a future rewrite that keeps the shape // but changes the membership cannot pass by pasting a new string in. // Reads off the live config's own regex, never the mirror above. @@ -990,7 +1003,7 @@ describe('objectui#4854: the four flagged surfaces in the console config', () => (config.build.rollupOptions.output.advancedChunks.groups as { name: string; test: RegExp }[]).find( (g) => g.name === 'vendor-objectstack' )!.test; - expect(vendorOf(injected).source.startsWith(BASE_VENDOR_TEST.source)).toBe(true); + expect(vendorOf(injected).source.startsWith(LAYOUT_VENDOR_TEST.source)).toBe(true); expect(vendorOf(injected).test(`${fs.realpathSync(installedSpecDir)}/dist/ui/index.mjs`)).toBe(true); // Widening appends an arm; it must not resurrect the lint exclusion the // baseline arms carry, or a spec-dist build would silently re-eagerize the diff --git a/scripts/__tests__/vite-objectstack-workspace.test.ts b/scripts/__tests__/vite-objectstack-workspace.test.ts index 5b49e025ac..198edec1f6 100644 --- a/scripts/__tests__/vite-objectstack-workspace.test.ts +++ b/scripts/__tests__/vite-objectstack-workspace.test.ts @@ -3,17 +3,19 @@ * module-id tests, with the objectstack monorepo's `packages/` directory as * one more alternative (objectstack ruling D1: objectui becomes `ui/`). * - * Two directions are pinned. STANDALONE, the directory the alternative names - * does not exist beside this checkout and the three tests ARE the baseline - * literals, byte for byte -- `vite-objectstack-spec-dist.test.ts` pins the + * Two directions are pinned, each on a FIXTURE layout so the pins read the same + * whichever layout this checkout is (objectstack#25733). STANDALONE, the + * directory the alternative names does not exist beside the objectui tree and + * the three tests ARE the baseline literals, byte for byte -- `vite-objectstack-spec-dist.test.ts` pins the * vendor literal as a flagged surface (objectui#4854), so "matches nothing" * is not enough; the alternative must not be there at all. * MERGED, an id under `/packages//dist/` is the vendor group * (spec in, lint out) and the two probes find their subjects; measured on the * spike, the baseline missed all three and the console build failed on its own - * lazy-linter guard. + * lazy-linter guard. The checkout itself is asked only which arm its own + * layout selects. */ -import { describe, expect, it } from 'vitest'; +import { afterAll, describe, expect, it } from 'vitest'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -26,6 +28,7 @@ import { frameworkPackagesDir, workspaceChunkTests, } from '../vite-objectstack-workspace.ts'; +import { isNestedWorkspace } from '../workspace-root.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const consoleDir = path.join(repoRoot, 'apps/console'); @@ -33,7 +36,17 @@ const consoleDir = path.join(repoRoot, 'apps/console'); /** A monorepo laid out as the spike wired it: `/ui/apps/console`, `/packages/`. */ const MONOREPO = '/srv/objectstack'; const merged = workspaceChunkTests(candidateFrameworkPackagesDir(path.join(MONOREPO, 'ui/apps/console'))); -const standalone = workspaceChunkTests(frameworkPackagesDir(consoleDir)); + +/** + * A standalone objectui tree laid out on disk: `/objectui/apps/console`, + * with no `packages/` beside the tree. Not this checkout's own console, which + * under the monorepo HAS the framework's `packages/` three levels up. + */ +const standaloneFixture = mkdtempSync(path.join(os.tmpdir(), 'objectui-standalone-')); +const standaloneConsoleDir = path.join(standaloneFixture, 'objectui/apps/console'); +mkdirSync(standaloneConsoleDir, { recursive: true }); +afterAll(() => rmSync(standaloneFixture, { recursive: true, force: true })); +const standalone = workspaceChunkTests(frameworkPackagesDir(standaloneConsoleDir)); const PLAIN_SPEC = '/w/node_modules/@objectstack/spec/dist/index.mjs'; const STORE_SPEC = '/w/node_modules/.pnpm/@objectstack+spec@18.1.0/node_modules/@objectstack/spec/dist/index.mjs'; @@ -45,9 +58,10 @@ describe('workspaceChunkTests (objectstack ruling D1)', () => { it('locates the monorepo packages dir three levels above the console, and names it only when it exists', () => { expect(candidateFrameworkPackagesDir(path.join(MONOREPO, 'ui/apps/console'))).toBe(path.join(MONOREPO, 'packages')); expect(candidateFrameworkPackagesDir(consoleDir)).toBe(path.resolve(repoRoot, '../packages')); - // The standing fact the standalone pin rests on, checked rather than assumed. - expect(existsSync(candidateFrameworkPackagesDir(consoleDir))).toBe(false); - expect(frameworkPackagesDir(consoleDir)).toBeNull(); + // The standing fact the standalone pin rests on, checked on its fixture + // rather than assumed of the checkout. + expect(existsSync(candidateFrameworkPackagesDir(standaloneConsoleDir))).toBe(false); + expect(frameworkPackagesDir(standaloneConsoleDir)).toBeNull(); expect(frameworkPackagesDir(path.join(MONOREPO, 'ui/apps/console'))).toBeNull(); // A real monorepo-shaped tree: the directory exists, so it is named. const root = mkdtempSync(path.join(os.tmpdir(), 'objectstack-workspace-')); @@ -79,6 +93,20 @@ describe('workspaceChunkTests (objectstack ruling D1)', () => { } }); + it('this checkout: the console takes the arm its own layout names', () => { + // Standalone no `packages/` sits beside the tree and the baseline literals + // apply; under the monorepo the framework's `packages/` is that directory. + // The workspace root is the independent witness of which layout this is. + const own = frameworkPackagesDir(consoleDir); + if (isNestedWorkspace()) { + expect(own).toBe(path.resolve(repoRoot, '../packages')); + expect(workspaceChunkTests(own)).not.toBe(BASELINE_CHUNK_TESTS); + } else { + expect(own).toBeNull(); + expect(workspaceChunkTests(own)).toBe(BASELINE_CHUNK_TESTS); + } + }); + it('keeps every baseline spelling: plain install and pnpm store, spec in, lint out', () => { for (const tests of [standalone, merged]) { expect(tests.vendor.test(PLAIN_SPEC)).toBe(true); diff --git a/scripts/__tests__/workspace-root.test.ts b/scripts/__tests__/workspace-root.test.ts index 3cb1a6cbcc..47c8aa72bf 100644 --- a/scripts/__tests__/workspace-root.test.ts +++ b/scripts/__tests__/workspace-root.test.ts @@ -26,6 +26,7 @@ import { turboConfigPath, uiScopedTurboConfig, uiTurboConfigPath, + virtualStorePath, workspaceGlobs, workspaceManifestPath, workspaceRoot, @@ -103,6 +104,7 @@ describe('workspaceRoot() -- rule (a): the tree, else its parent, else a throw', 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' }); + expect(virtualStorePath(ws)).toBe(path.join(ws, 'node_modules', '.pnpm')); }); }); @@ -116,6 +118,9 @@ describe('workspaceRoot() -- rule (a): the tree, else its parent, else a throw', 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'); + // The store is the workspace root's, never `/node_modules/.pnpm` + // (objectstack#25733): the tree's own node_modules holds only symlinks. + expect(virtualStorePath(ui)).toBe(path.join(mono, 'node_modules', '.pnpm')); }); }); @@ -123,6 +128,7 @@ describe('workspaceRoot() -- rule (a): the tree, else its parent, else a throw', 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/); + expect(() => virtualStorePath(path.join(tmp, 'none/sub'))).toThrow(/no pnpm-workspace\.yaml at/); }); }); }); @@ -220,6 +226,7 @@ describe('this checkout', () => { expect(isNestedWorkspace()).toBe(ws !== uiRoot()); expect(fs.existsSync(lockfilePath())).toBe(true); expect(fs.existsSync(workspaceManifestPath())).toBe(true); + expect(fs.existsSync(virtualStorePath())).toBe(true); expect(typeof rootManifest().name).toBe('string'); const globs = workspaceGlobs(); expect(globs.length).toBeGreaterThan(2); diff --git a/scripts/check-doc-snippet-types.mjs b/scripts/check-doc-snippet-types.mjs index 6293a8d42d..be57dcdc78 100644 --- a/scripts/check-doc-snippet-types.mjs +++ b/scripts/check-doc-snippet-types.mjs @@ -478,6 +478,7 @@ import { fileURLToPath } from 'node:url'; import ts from 'typescript'; import { isEntrypoint } from './invoked-as.mjs'; import { TOOLING_FILE } from './check-phantom-dependencies.mjs'; +import { virtualStorePath } from './workspace-root.mjs'; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -1592,12 +1593,24 @@ export function peerMappedCount(state = {}) { * Where a package is physically installed in this workspace, found WITHOUT * assuming it resolves from anywhere in particular: pnpm's virtual store holds * one directory per (package, version, peer-set), named with `/` replaced by `+`. - * Used only by the UNDECLARED control, which must never confuse "this specifier - * does not resolve" with "this package is not installed" — the second proves - * nothing about how far resolution reaches. + * Used by both controls, which must never confuse "this specifier does not + * resolve" with "this package is not installed" — the second proves nothing + * about how far resolution reaches. + * + * The store is the WORKSPACE root's (`virtualStorePath`), not `root`'s: under + * the objectstack monorepo `root` is `ui/`, whose `node_modules` holds only the + * symlinks of its own manifest's dependencies, and the store every installed + * copy lives in is one level up (objectstack#25733). Standalone the two are the + * same directory. A tree no `pnpm-workspace.yaml` governs has nothing installed + * in it, so the answer there is `null`, the same as a missing store. */ export function findInstalledCopy(root = repoRoot, specifier = '') { - const storeDir = join(root, 'node_modules', '.pnpm'); + let storeDir; + try { + storeDir = virtualStorePath(root); + } catch { + return null; + } if (!existsSync(storeDir)) return null; const prefix = `${specifier.replace(/\//g, '+')}@`; for (const entry of readdirSync(storeDir).sort()) { diff --git a/scripts/check-ui-root-lookups.mjs b/scripts/check-ui-root-lookups.mjs index 9aa5b238e1..e37d979cb4 100644 --- a/scripts/check-ui-root-lookups.mjs +++ b/scripts/check-ui-root-lookups.mjs @@ -43,10 +43,12 @@ * 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 `readFileSync` of `pnpm-workspace.yaml` outside such a loop, + * from a root that is NOT the objectui tree (a fixture, a root + * handed in): that reads the workspace's package globs as DATA from + * a root already known -- `scripts/workspace-root.mjs` is where that + * root is decided. Joined to the objectui tree itself it is the + * next section's finding; * - SILENT a `.git` entry in a directory skip-list, a fixture that WRITES a * `pnpm-workspace.yaml`, and any mention inside a comment. * @@ -84,6 +86,38 @@ * of its reach; the idiom in `scripts/workspace-root.mjs` is the rule, this is * its tripwire. * + * ## The workspace's own files, joined to the objectui tree (objectstack#25733) + * + * Standalone the objectui tree IS the pnpm workspace root; under the monorepo + * the workspace root is one level up, and two of its files are only there: + * `pnpm-workspace.yaml`, and pnpm's virtual store `node_modules/.pnpm` (the + * tree's own `node_modules` holds only the symlinks of its manifest's + * dependencies). `scripts/workspace-root.mjs` answers where they are + * (`workspaceManifestPath()`, `virtualStorePath()`): + * + * - FIRES `workspace-file-at-ui-root`: a `join` / `resolve` call, or a + * template led by a substitution, whose path names + * `pnpm-workspace.yaml` or `node_modules/.pnpm` directly below the + * OBJECTUI TREE -- a root derived from `uiRoot()`, `UI_ROOT`, or + * this file's own location (`import.meta.url`, `__dirname`, + * `import.meta.dirname`) stepped back up to the tree with + * `dirname` / `..`, through `const` bindings and parameter + * defaults (never a `let`, which a walk reassigns). Judged where the path first names the file, so a join + * built on that join is not a second finding; + * - SILENT the same files under a root the detector cannot derive (a + * fixture directory, a parameter with no default, the cwd), under + * `workspaceRoot()` / `virtualStorePath()` themselves, a climb that + * stops above or below the tree, a module-id STRING that merely + * contains `/node_modules/.pnpm/`, and every other `node_modules` + * path under the tree: `/node_modules/` is the tree's + * own declared dependency, present in both layouts. + * + * ⚠️ The bound, said per AGENTS.md #9: a root this detector cannot derive is + * out of its reach, and so is the half of the class that is not a path: a + * test that TREATS the tree as the workspace root (passes it as the cwd of a + * workspace-root predicate, or reads a sibling directory as the framework's) + * joins nothing. Those are held by their own fixture-layout tests. + * * ## The allowlist is a ledger, not a bypass * * Every entry names a file and the reason its probe is not a repo-root lookup @@ -144,6 +178,16 @@ const HOLE = '\u0001'; */ const ROOT_REV_PATH = /^[^\s:\-][^\s:]*:(?!\.\/)[^\s]/; +/** + * The workspace's own files, as path segments below the directory that holds + * them: they sit at the WORKSPACE root, which under the monorepo is one level + * above the objectui tree (objectstack#25733). `scripts/workspace-root.mjs` + * answers where (`workspaceManifestPath()`, `virtualStorePath()`). + */ +const WORKSPACE_ROOT_FILES = [['pnpm-workspace.yaml'], ['node_modules', '.pnpm']]; +/** A path segment the detector cannot read statically (a name, a call). */ +const DYNAMIC = '\u0002'; + /** * Files that carry a probe this gate recognises and that is NOT a repo-root * lookup. Path → reason. A path listed here that carries no hit is stale. @@ -151,7 +195,7 @@ const ROOT_REV_PATH = /^[^\s:\-][^\s:]*:(?!\.\/)[^\s]/; 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)", + "decides the WORKSPACE root for every reader of pnpm-workspace.yaml / pnpm-lock.yaml / the root manifest / the .pnpm store: one probe at `uiRoot()` and one at its parent, anchored on ui-root.mjs -- never a walk, never the cwd (objectstack#25723, objectstack#25733)", ], [ 'scripts/objectstack-pin.mjs', @@ -165,14 +209,13 @@ export const ALLOWLIST = new Map([ 'scripts/check-governed-queue-guard.mjs', "its subject is the REPOSITORY's governed surfaces (`AGENTS.md`, `.github/workflows/**`, ... at the git root), so its `diff-tree --name-only` reads root-relative paths on purpose; under the monorepo the governed register is objectstack's own (objectstack#25712)", ], - ['packages/cli/src/utils/app-generator.ts', "asks whether the USER's cwd is a pnpm workspace to choose the scaffold shape -- product behaviour, not a repo-root lookup"], ['packages/cli/src/utils/workspace-vite.ts', "asks whether the USER's cwd is a pnpm workspace before reading its globs -- product behaviour, not a repo-root lookup"], ['packages/cli/src/__tests__/app-generator.test.ts', 'mirrors the generator\'s own cwd predicate to compute the expected scaffold shape'], ]); // Cheap pre-filter: a file that spells none of these cannot carry a hit. It is // a SPEED filter only; every file that mentions one is parsed in full. -const PREFILTER = /\.git['"`]|pnpm-workspace\.yaml|--show-toplevel|--git-common-dir|find-up|findUp|['"`](?:show|cat-file|diff|diff-tree|diff-index|log|whatchanged|ls-files|ls-tree|grep)['"`]|['"`]git /; +const PREFILTER = /\.git['"`]|pnpm-workspace\.yaml|\.pnpm|--show-toplevel|--git-common-dir|find-up|findUp|['"`](?:show|cat-file|diff|diff-tree|diff-index|log|whatchanged|ls-files|ls-tree|grep)['"`]|['"`]git /; function calleeName(node) { const e = node.expression; @@ -285,6 +328,102 @@ function isGitCallee(call) { return typeof name === 'string' && /git/i.test(name); } +/** The text a node denotes when it is a string, followed through `const` bindings; `null` otherwise. */ +function staticText(node, binds, depth = 0) { + if (!node || depth > 4) return null; + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isIdentifier(node) && binds.has(node.text)) return staticText(binds.get(node.text), binds, depth + 1); + return null; +} + +/** + * What an identifier names where it is USED: the nearest enclosing function's + * parameter of that name (its default, or `null` when it has none -- a value + * handed in is not a known root), else the file's first `const`/`let` of it. + */ +function bindingAt(id, binds) { + for (let n = id.parent; n; n = n.parent) { + if (!ts.isFunctionLike(n) || !n.parameters) continue; + for (const param of n.parameters) { + if (ts.isIdentifier(param.name) && param.name.text === id.text) return param.initializer ?? null; + } + } + return binds.get(id.text) ?? null; +} + +const isImportMeta = (node) => ts.isMetaProperty(node) && node.keywordToken === ts.SyntaxKind.ImportKeyword && node.name.text === 'meta'; +const isImportMetaUrl = (node) => ts.isPropertyAccessExpression(node) && isImportMeta(node.expression) && node.name.text === 'url'; + +/** `segments` with `text`'s path segments appended, `..` stepping back. */ +function appendSegments(segments, text) { + const out = [...segments]; + for (const seg of text.split(/[\\/]+/)) { + if (!seg || seg === '.') continue; + if (seg === '..' && out.length && out[out.length - 1] !== '..' && out[out.length - 1] !== DYNAMIC) out.pop(); + else out.push(seg); + } + return out; +} + +/** + * The path a node names, as segments below the OBJECTUI ROOT -- `[]` for the + * root itself -- when it is derived from a place this file knows: `uiRoot()`, + * `UI_ROOT`, or this file's own location (`import.meta.url`, `__dirname`, + * `import.meta.dirname`) stepped with `dirname` / `join` / `resolve`. `null` + * for anything else: a parameter with no default, a fixture directory, the + * cwd, and `workspaceRoot()` / `virtualStorePath()`, which ARE the answer. + * + * @param {ts.Node} node + * @param {{ binds: Map, filePath: string[] }} ctx `filePath`: this file's segments below the root + * @returns {string[] | null} + */ +function rootPath(node, ctx, hops = 0) { + if (!node || hops > 8) return null; + if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isNonNullExpression(node)) return rootPath(node.expression, ctx, hops + 1); + if (ts.isIdentifier(node)) { + const init = bindingAt(node, ctx.binds); + // A `let` may be reassigned (a walk's cursor), so only a `const` or a + // parameter default is read as the place it was set to. + const list = init && ts.isVariableDeclaration(init.parent) ? init.parent.parent : null; + if (list && ts.isVariableDeclarationList(list) && !(list.flags & ts.NodeFlags.Const)) return null; + if (init) return rootPath(init, ctx, hops + 1); + if (ctx.binds.has(node.text)) return null; + if (node.text === '__dirname') return ctx.filePath.slice(0, -1); + if (node.text === '__filename') return ctx.filePath; + if (node.text === 'UI_ROOT') return []; + return null; + } + if (ts.isPropertyAccessExpression(node) && isImportMeta(node.expression)) { + if (node.name.text === 'dirname') return ctx.filePath.slice(0, -1); + if (node.name.text === 'filename') return ctx.filePath; + return null; + } + if (!ts.isCallExpression(node)) return null; + const name = calleeName(node); + const args = node.arguments; + if (name === 'uiRoot' && args.length === 0) return []; + if (name === 'fileURLToPath' && args.length === 1 && isImportMetaUrl(args[0])) return ctx.filePath; + if (name === 'dirname' && args.length === 1) { + const inner = rootPath(args[0], ctx, hops + 1); + return inner ? appendSegments(inner, '..') : null; + } + if (PATH_COMBINERS.has(name) && args.length >= 1) { + let out = rootPath(args[0], ctx, hops + 1); + if (!out) return null; + for (const arg of args.slice(1)) { + const text = staticText(arg, ctx.binds); + if (text === null) return [...out, DYNAMIC]; + out = appendSegments(out, text); + } + return out; + } + return null; +} + +/** Whether segments below the objectui root name one of the workspace's own files. */ +const namesWorkspaceRootFile = (segments) => + Array.isArray(segments) && WORKSPACE_ROOT_FILES.some((target) => target.every((seg, i) => segments[i] === seg)); + const isLoop = (node) => ts.isForStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node); /** @@ -292,12 +431,13 @@ const isLoop = (node) => ts.isForStatement(node) || ts.isWhileStatement(node) || * * @param {string} file Path used for reporting and for the script kind. * @param {string} text - * @returns {{ line: number, shape: 'marker-probe' | 'marker-walk' | 'git-toplevel' | 'find-up' | 'git-rev-path' | 'git-root-listing', text: string }[]} + * @returns {{ line: number, shape: 'marker-probe' | 'marker-walk' | 'git-toplevel' | 'find-up' | 'git-rev-path' | 'git-root-listing' | 'workspace-file-at-ui-root', 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; const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, kind); const binds = bindingsOf(src); + const pathCtx = { binds, filePath: file.split(/[\\/]/).filter(Boolean) }; const hits = []; const seen = new Set(); const record = (node, shape) => { @@ -360,6 +500,19 @@ export function lookupsIn(file, text) { if (isGitCallee(node)) judgeArgv(node, node.arguments, null, 2); } if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node)) judgeCommand(node); + // A workspace root file joined to the objectui root (objectstack#25733): + // judged where the path FIRST names it, so a join built on that join is + // not a second finding. + if (ts.isCallExpression(node) && PATH_COMBINERS.has(calleeName(node)) && node.arguments.length > 1) { + if (namesWorkspaceRootFile(rootPath(node, pathCtx)) && !namesWorkspaceRootFile(rootPath(node.arguments[0], pathCtx))) { + record(node, 'workspace-file-at-ui-root'); + } + } else if (ts.isTemplateExpression(node) && node.head.text === '' && node.templateSpans.length > 0) { + const base = rootPath(node.templateSpans[0].expression, pathCtx); + if (base && !namesWorkspaceRootFile(base) && namesWorkspaceRootFile(appendSegments(base, node.templateSpans[0].literal.text))) { + record(node, 'workspace-file-at-ui-root'); + } + } if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === 'find-up') { record(node, 'find-up'); } else if (ts.isCallExpression(node)) { @@ -475,6 +628,29 @@ export function selfTest() { run('pathspec magic, not a revision', `${head}git(root, ['ls-files', '--others', '--', ':(glob).changeset/*.md']);\ngit(root, ['diff', '--relative', '--name-only', base, '--', ':(glob).changeset/*.md']);`, 'silent', 'scripts/x.mjs'); run('a `:` diagnostic outside any git argv', `const where = \`\${file}:\${line}\`;\nconsole.log(['show', where].join(' '));`, 'silent', 'scripts/x.mjs'); run('a list that only looks like an argv', `const ROWS = [['status', 'Aggregate closure ceiling'], ['log', 'info', 'warn']];`, 'silent', 'scripts/x.mjs'); + // ── The workspace's own files, joined to the objectui tree (objectstack#25733) ── + const fileRoot = "import { dirname, join, resolve } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n"; + // FIRES + run('the store joined to `uiRoot()`', `import { uiRoot } from './ui-root.mjs';\n${fileRoot}const store = join(uiRoot(), 'node_modules', '.pnpm');`, 'fires', 'scripts/x.mjs'); + run( + 'the store under a parameter whose default climbs from this file to the tree (the check-doc-snippet-types probe)', + `${fileRoot}const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');\nexport function findCopy(root = repoRoot, name = '') {\n return join(root, 'node_modules', '.pnpm', name);\n}`, + 'fires', + 'scripts/x.mjs', + ); + run('one literal naming the store under a four-level climb from a package test', `${fileRoot}const REPO_ROOT = resolve(__dirname, '../../../..');\nconst copy = join(REPO_ROOT, 'node_modules/.pnpm/vitest@4.1.10/node_modules/vitest');`, 'fires', 'packages/cli/src/__tests__/x.test.ts'); + run('pnpm-workspace.yaml read as data from the tree', `import { readFileSync } from 'node:fs';\nimport { uiRoot } from './ui-root.mjs';\n${fileRoot}const yaml = readFileSync(join(uiRoot(), 'pnpm-workspace.yaml'), 'utf8');`, 'fires', 'scripts/x.mjs'); + run('a climb and the marker in one call, from `import.meta.dirname`', `${fileRoot}const file = resolve(import.meta.dirname, '../..', 'pnpm-workspace.yaml');`, 'fires', 'apps/console/x.ts'); + run('a template led by the tree', `import { uiRoot } from './ui-root.mjs';\nconst store = \`\${uiRoot()}/node_modules/.pnpm\`;`, 'fires', 'scripts/x.mjs'); + run('the marker through a `const` name', `import { uiRoot } from './ui-root.mjs';\n${fileRoot}const MANIFEST = 'pnpm-workspace.yaml';\nconst file = join(uiRoot(), MANIFEST);`, 'fires', 'scripts/x.mjs'); + // SILENT + run('the helpers themselves', `import { virtualStorePath, workspaceManifestPath, workspaceRoot } from './workspace-root.mjs';\n${fileRoot}const a = virtualStorePath();\nconst b = join(workspaceRoot(), 'node_modules', '.pnpm');\nconst c = workspaceManifestPath();`, 'silent', 'scripts/x.mjs'); + run('a store laid out in a fixture directory', `import { mkdtempSync } from 'node:fs';\nimport os from 'node:os';\n${fileRoot}const root = mkdtempSync(join(os.tmpdir(), 'fx-'));\nconst store = join(root, 'node_modules/.pnpm/x@1.0.0/node_modules/x');`, 'silent', 'scripts/__tests__/x.test.ts'); + run('a parameter with no default', `${fileRoot}export function storeOf(root) {\n return join(root, 'node_modules', '.pnpm');\n}`, 'silent', 'scripts/x.mjs'); + run('a climb that stops below the tree', `${fileRoot}const scriptsDir = resolve(__dirname, '..');\nconst store = join(scriptsDir, 'node_modules', '.pnpm');`, 'silent', 'scripts/__tests__/x.test.ts'); + run('the tree\'s own declared dependencies under its node_modules', `import { uiRoot } from './ui-root.mjs';\n${fileRoot}const turbo = join(uiRoot(), 'node_modules/.bin/turbo');\nconst vitest = join(uiRoot(), 'node_modules', 'vitest');`, 'silent', 'scripts/x.mjs'); + run('a module-id string that contains the store', `const STORE_SPEC = '/w/node_modules/.pnpm/@objectstack+spec@18.1.0/node_modules/@objectstack/spec/dist/index.mjs';`, 'silent', 'scripts/__tests__/x.test.ts'); + run('a join built on a join that already named the store is one finding', `import { uiRoot } from './ui-root.mjs';\n${fileRoot}const entry = join(join(uiRoot(), 'node_modules', '.pnpm'), 'x@1.0.0');`, 'fires', 'scripts/x.mjs'); return cases; } @@ -508,7 +684,8 @@ function main(argv) { `\n✗ check-ui-root-lookups: ${offending.length} repo-root lookup(s) outside ${HELPER}` + `${result.stale.length ? ` and ${result.stale.length} stale allowlist entr${result.stale.length === 1 ? 'y' : 'ies'}` : ''}.\n` + ` Standalone these find the objectui root; inside the objectstack monorepo (\`ui/\`) they find the OBJECTSTACK root.\n` + - ` Import { uiRoot } from scripts/ui-root.mjs instead -- it resolves the tree from its own location in both layouts.`, + ` Import { uiRoot } from scripts/ui-root.mjs instead -- it resolves the tree from its own location in both layouts.\n` + + ` The workspace's own files (pnpm-workspace.yaml, node_modules/.pnpm) come from scripts/workspace-root.mjs.`, ); return 1; } diff --git a/scripts/workspace-root.d.mts b/scripts/workspace-root.d.mts index a65fa1e44b..da7f3ad172 100644 --- a/scripts/workspace-root.d.mts +++ b/scripts/workspace-root.d.mts @@ -12,6 +12,7 @@ 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$/'; +export declare const VIRTUAL_STORE: '.pnpm'; /** The directory whose `pnpm-workspace.yaml` governs the tree: `root`, else its parent, else a throw. */ export declare function workspaceRoot(root?: string): string; @@ -28,6 +29,8 @@ export declare function rootManifest(root?: string): { name?: string; pnpm?: { overrides?: Record; patchedDependencies?: Record }; } & Record; +/** pnpm's `node_modules/.pnpm` at the workspace root, where every installed copy lives. */ +export declare function virtualStorePath(root?: string): string; /** 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. */ diff --git a/scripts/workspace-root.mjs b/scripts/workspace-root.mjs index 818116ac73..bdffb350ad 100644 --- a/scripts/workspace-root.mjs +++ b/scripts/workspace-root.mjs @@ -40,7 +40,10 @@ * 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. + * same root, and so does `virtualStorePath()`, pnpm's `node_modules/.pnpm`: + * under the monorepo `/node_modules` holds only the symlinks of + * the objectui root package's own declared dependencies, and the store + * every installed copy lives in is the workspace root's (objectstack#25733). * (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 @@ -113,6 +116,8 @@ 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$/'; +/** pnpm's virtual store directory, inside the workspace root's `node_modules`. */ +export const VIRTUAL_STORE = '.pnpm'; /** * The directory whose `pnpm-workspace.yaml` governs the objectui tree: `root` @@ -199,6 +204,20 @@ export function rootManifest(root = uiRoot()) { return JSON.parse(fs.readFileSync(rootManifestPath(root), 'utf8')); } +/** + * pnpm's virtual store at the workspace root: one directory per installed + * (package, version, peer-set), named with `/` spelled `+`. A reader asking + * "is this package installed ANYWHERE in the workspace" looks here; the + * objectui root's own `node_modules` answers only for what its manifest + * declares, and in the monorepo it has no `.pnpm` at all. + * + * @param {string} [root] + * @returns {string} + */ +export function virtualStorePath(root = uiRoot()) { + return path.join(workspaceRoot(root), 'node_modules', VIRTUAL_STORE); +} + /** * 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