diff --git a/.changeset/hook-speed.md b/.changeset/hook-speed.md new file mode 100644 index 00000000..af6f8b29 --- /dev/null +++ b/.changeset/hook-speed.md @@ -0,0 +1,9 @@ +--- +'onsystem': patch +--- + +`check` on one file starts sooner, which the Claude Code hook does on every edit. The CLI turns on Node's compile cache (Node.js 22.1 and later), so the CLI and TypeScript are not compiled again on each run; it is kept in the user's own folder in the system temp folder, `onsystem-`, in a `compile-cache` folder onsystem creates for the user alone. An app whose design system zero config finds in a package's `exports` keeps what it found beside the cached model, and reads it from there until a file in that package, or the app's `package.json` or `components.json`, changes, or another build of onsystem runs: following the package's barrels parsed every file they lead to, on every run. `--no-cache` finds it again. On midday's dashboard and dub's web app, `check` on one file went from 0.51 s to 0.33 s and from 0.77 s to 0.43 s. + +In a project without onsystem installed, the hook runs it through npx on the first edit in that project only: the CLI npx started says where it is, and the next edits there run it with Node directly, without starting npm, while it is still the plugin's release. When that CLI gives no results, as when part of npm's copy was removed, the hook forgets it and runs npx in that same edit. + +`onsystem-` is used only while it is the user's alone. One that others can write to is now skipped, for the cached model too, rather than closed and used, and on Windows the folder is used only inside the user's home folder. SECURITY.md lists what is kept there. diff --git a/SECURITY.md b/SECURITY.md index 431ed8ee..5bbceaef 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,7 +23,8 @@ It verifies the registry signature of every installed package and the attestatio - The server runs locally over stdio, opens no ports and makes no network requests of its own. `npx` downloads the package from npm when the client starts it. - It reads files under the project root (components, tokens, docs, `tsconfig`, `tailwind.config`) and the workspace packages they point at. In `node_modules` it reads the config a design-system package declares, as JSON (never run), and the type declarations of a library or an installed design-system package it reads components from (MUI, `@acme/ui`). - It writes its cache, the baseline file (creating its folder) when you run `check --update-baseline` or `check --prune-baseline`, and the file `export --out` names. `init` writes `onsystem.config.json`; in a design-system package also `ONSYSTEM.md` and the `onsystem` field (and `files`) of `package.json`; and, when you say yes, an onsystem section in `AGENTS.md` and `CLAUDE.md`. -- The cache is in `node_modules/.cache/onsystem`. A project without `node_modules` has it in a folder of the system temp folder that only you can open (`onsystem-`): one that is a link, belongs to another user or that others can write to is not read, since on a shared machine a model planted there would reach your agent. +- The cache is in `node_modules/.cache/onsystem`: the model it built from the design system (`model.json`) and, when zero config found the design system in a dependency's `exports`, where it found it (`detection.json`), which decides what is read as the design system. A project without `node_modules` keeps both in a folder of the system temp folder that only you can open, `onsystem-` (`onsystem` on Windows). The CLI keeps Node.js's compile cache for its own code there too (`compile-cache`), which Node.js runs, and the Claude Code hook keeps the path of the CLI npx installed there ([below](#the-claude-code-plugin)). `--no-cache` reads neither the model nor the detection. +- onsystem creates that folder, and `compile-cache` in it, for you alone (mode 0700), rather than leave `compile-cache` to Node.js, which creates folders with your umask. It uses each only while it is a folder, not a link, that you own and no one else can write to. One that others can write to is neither read nor written, rather than closed for you, since a file put in it before would stay. On Windows, which has no such owner to check here, it is used only when it is inside your home folder, links resolved, where Windows keeps each user's temp folder: with `TEMP` pointing anywhere else, the CLI runs without these caches and the hook without the remembered path. - Component, token and docs files are read only when their real path, links resolved, is in the project, the workspace or git repository it is in, the package whose declared config it takes on, or a package installed in a `node_modules` folder of those, which pnpm may link from a store elsewhere. A link, or a `../` or absolute path in the config, that leads anywhere else is skipped, and `inspect` and the server's log name it. The home folder, the folders above it and the filesystem root never count as the workspace, the repository or a package's folder. The `node_modules` rule is the trade-off for pnpm's store: a link committed inside `node_modules` is followed like an installed package, so it can reach a package installed in another project on the machine. - To show a component's props, TypeScript reads the modules the component imports wherever they resolve, outside the project too. A literal type from such a file (`export const KEY = "..." as const`) can show in `get_component`. - `check_ui` with a `path` reads only files inside the project root, symlinks resolved, and limits input size. @@ -46,6 +47,7 @@ The plugin's hook and server run in every project you open in Claude Code, so us - Both import the project's `onsystem.config.ts`, `.mjs` or `.js`, as above. - The hook runs the project's own install of the CLI when there is one (`node_modules/onsystem`, whose `bin` it runs with Node.js), whatever its version. Without one, it runs `npx` with the exact version the plugin was released with. The server runs `npx -y onsystem@`, which uses a project install of the same version. - `npx` runs in the project folder, so the project's npm config applies to it, such as a `registry` in its `.npmrc`. +- Without a project install, the hook runs `npx` on the first edit in a project. The CLI that `npx` started writes its own path to a file in `onsystem-`, `npx-cli-`, one per release, `npx` and project folder, and the next edits in that project run that script with Node.js directly, without npm. The hook runs it only while the folder is yours alone, as above, the file is a file, not a link, that no one else can write to, and the script is the `bin` of an `onsystem` package at the plugin's exact version; otherwise it runs `npx` again. When that script gives no results, as when part of npm's copy was removed, the hook removes the file and runs `npx` in the same edit. The CLI writes that file only under that name directly in its own folder, readable by you alone (mode 0600), and replaces a link there rather than writing through it. - The hook writes marker files in the system temp folder, so its notice is shown once per session and its pause after a failed `npx` holds. ## The GitHub Action diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 6add16af..acb75055 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -23,7 +23,7 @@ Run as is, with no config, on public apps: the counts the [corpus](../corpus) sn ## Performance and footprint -Reading the design system is the slow part: one TypeScript program over the component files. `check` and the server cache what they read and read it again only when those files change, so checking a file after that is syntax work. The hook runs one `check` per edit to a UI file. Without onsystem installed in the project, it runs `check` through npx, which starts npm first: a dev dependency ([pin the version](rollout.md#pin-the-version)) takes that out of every edit. +Reading the design system is the slow part: one TypeScript program over the component files. `check` and the server cache what they read and read it again only when those files change, so checking a file after that is syntax work. The hook runs one `check` per edit to a UI file. Without onsystem installed in the project, it runs `check` through npx on the first edit in a project, which starts npm first; later edits there run the CLI npx installed with Node.js directly ([how](plugin.md#what-it-runs-and-what-it-touches)). A dev dependency ([pin the version](rollout.md#pin-the-version)) takes npm out of the first edit too. Measured with onsystem 0.11.0 on Apple M1 Max (10 cores, 32 GB), macOS 27.0.1, Node.js 24.18.0, on 2026-10-29, with `node scripts/perf.mjs`: each time is the median of 5 runs. The corpus checkouts have no `node_modules`, so the types of the libraries their components wrap are not read: in a project with its dependencies installed, reading the design system takes longer. The demo, where the hook is measured, has its dependencies installed. diff --git a/docs/plugin.md b/docs/plugin.md index b6a2044a..fcfab89d 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -38,9 +38,9 @@ The plugin is the fastest way to keep Claude on your design system. It bundles t ## What it runs, and what it touches -The hook and the server run the `onsystem` package at the plugin's exact version: the project's own install when there is one (`npm install --save-dev onsystem`), else `npx`. Both run through Node.js directly, never a shell. They read the project's files and write only the cache (`node_modules/.cache/onsystem`, or the system temp folder) and marker files in the system temp folder, so the hook's notice is shown once and its pause holds. onsystem makes no network requests and no model calls. The only network access is npm's, when it runs through npx: +The hook and the server run the `onsystem` package at the plugin's exact version: the project's own install when there is one (`npm install --save-dev onsystem`), else `npx`. Both run through Node.js directly, never a shell. They read the project's files and write only the cache (`node_modules/.cache/onsystem`, or the system temp folder, with the CLI's compile cache) and, in the system temp folder, marker files, so the hook's notice is shown once and its pause holds, and the path of the CLI npx installed. onsystem makes no network requests and no model calls. The only network access is npm's, when it runs through npx: -- **The hook** runs `npx --prefer-offline`, which uses the package in npm's cache without asking the registry: after the first download it checks offline too. If npm itself fails there, as when its cached list of versions predates a new release of the plugin, it runs once more with `--prefer-online`, which asks the registry. When the CLI ran and failed, as with a broken config, the hook says so at once, without a second run. +- **The hook** runs `npx --prefer-offline` on the first edit in a project, which uses the package in npm's cache without asking the registry: after the first download it checks offline too. If npm itself fails there, as when its cached list of versions predates a new release of the plugin, it runs once more with `--prefer-online`, which asks the registry. When the CLI ran and failed, as with a broken config, the hook says so at once, without a second run. The CLI npx started says where it is, and the next edits in that project run it with Node.js directly, without starting npm, while it is still the plugin's release. When npm's cache no longer has it, or it gives no results, as when part of npm's copy was removed, the hook forgets it and runs npx in that same edit. That path is kept in a folder of the system temp folder that only you can use ([SECURITY.md](../SECURITY.md), under "The Claude Code plugin", says what the hook trusts). - **The server** runs `npx -y onsystem@`, which asks the registry about the package each time it starts. Offline, it starts from npm's cache only after npm gives up on the registry, over a minute later. - **A project install** needs no network and is the fastest: the hook runs it without npm, and the server's npx uses it when it is the plugin's version. diff --git a/plugins/onsystem/hooks/check-ui.mjs b/plugins/onsystem/hooks/check-ui.mjs index d567dd7b..2f649411 100644 --- a/plugins/onsystem/hooks/check-ui.mjs +++ b/plugins/onsystem/hooks/check-ui.mjs @@ -36,9 +36,16 @@ // with the cache, as when its cached list of versions is older than this release, // it runs once more with --prefer-online, which asks the registry. A failure of // the CLI it started, such as a broken config, is not retried. +// The CLI npx starts writes where it is (ONSYSTEM_REMEMBER_CLI), and later edits +// in the same project run that CLI with Node directly, without starting npm first, +// while it is still this release. When it gives no results (it crashed, as when part +// of its folder in npm's cache was removed), it is forgotten, and npx runs in that +// same edit. Its path is kept per project, in the user's own +// folder in the system temp folder, and read only while that folder and the file +// are the user's alone (ownFolder), since the hook runs what it names. // It reads the project's files and writes nothing but markers in the system temp -// folder, for the one-time notice and the pause; the only network access is npm's, -// when npx needs the registry. +// folder, for the one-time notice, the pause and the CLI's path; the only network +// access is npm's, when npx needs the registry. // The check runs from the file's project folder, the nearest one with a config, a // components.json, a package.json or a project.json. In a monorepo whose root has a config or // a baseline (`init` at the root writes both), it runs from the root, as `check .` there does, @@ -116,7 +123,7 @@ for (const target of targets) { // Where `check` runs: the project's folder, or the monorepo root whose config or baseline covers it. const root = (project && monorepoRoot(project)) ?? project ?? sessionDir; // The project's own install, which may be in the project's node_modules rather than the root's. - const cli = resolveCli(project ?? root); + const cli = resolveCli(project ?? root, root); const key = `${root}\0${cli.argv?.join('\0')}`; const job = jobs.get(key) ?? { root, cli, files: [] }; jobs.set(key, job); @@ -163,7 +170,8 @@ process.exit(0); /** Runs `check` on a job's files, and adds what it found to `blocks`, `notes` and `notices`. */ function checkFiles(job) { - const { root, cli, files } = job; + const { root, files } = job; + let { cli } = job; if (!cli.argv) return cannotRun(job, 'npx was not found next to this Node.js.'); // npm could not reach the registry, or npx ran out of time, less than OFFLINE_PAUSE ago: the // notice has been shown, and npx would wait as long again. @@ -172,6 +180,21 @@ function checkFiles(job) { let run = check(job, cli.npx ? ['--prefer-offline'] : []); let results = resultsOf(run); + // The CLI npx started before gave no results (it crashed, printed no JSON or is gone, as when + // part of its folder in npm's cache was removed): it is forgotten, and npx runs in this edit, as + // on the first one, and can install it again. A broken config fails there too, and is reported + // below. Not after the time limit, which npx would not get. + if (results === undefined && cli.remembered && !timedOut(run)) { + try { + fs.rmSync(cli.remembered.remember, { force: true }); + } catch { + // The hook reads it again on the next edit, and comes back here if it still fails. + } + job.cli = cli = cli.remembered; + if (paused(pause)) return; + run = check(job, ['--prefer-offline']); + results = resultsOf(run); + } // A run from the cache can fail where the registry would not: npm's cached list of versions can // predate this release, after the plugin moves to it. When npm itself failed, then, once more with // the registry, unless npm could not reach it, which a second run would not change. When the CLI @@ -404,7 +427,11 @@ function check({ root, cli, files }, npm) { { cwd: root, encoding: 'utf8', - env: { ...process.env, NO_COLOR: '1' }, + env: { + ...process.env, + NO_COLOR: '1', + ...(cli.remember ? { ONSYSTEM_REMEMBER_CLI: cli.remember } : {}), + }, maxBuffer: 64 * 1024 * 1024, timeout: Math.max(deadline - Date.now(), 1), windowsHide: true, @@ -690,9 +717,11 @@ function readJson(file) { /** * How to start the CLI: `argv` ([node, script] or ['npx']) runs an override, the project's - * install, or npx (`npx: true`, without npm's flags and the package). No `argv`: no npx found. + * install, the CLI npx started before from `root`, the folder it runs in (with `remembered`, how + * to run npx instead), or npx (`npx: true`, without npm's flags and the package). No `argv`: no + * npx found. */ -function resolveCli(dir) { +function resolveCli(dir, root) { if (BIN) return { argv: [process.execPath, BIN] }; const installed = installedCli(dir); if (installed) return { argv: [process.execPath, installed] }; @@ -704,8 +733,86 @@ function resolveCli(dir) { path.join(nodeDir, 'node_modules', 'npm', 'bin', 'npx-cli.js'), path.join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npx-cli.js'), ].find((f) => fs.existsSync(f)); - if (npx) return { argv: [process.execPath, npx], npx: true }; - return { argv: process.platform === 'win32' ? undefined : ['npx'], npx: true }; + if (npx) return throughNpx([process.execPath, npx], root); + return process.platform === 'win32' ? { argv: undefined, npx: true } : throughNpx(['npx'], root); +} + +/** + * npx run as `argv` from `root`, or the CLI it started there for this release before, while that + * is still there: run with Node directly, with `remembered`, npx, for when it gives no results. + * `remember` is the file the CLI npx starts writes its path in, one per release, npx and folder, + * in the user's own folder in the system temp folder: npx runs a project's own install of the + * release when it has one, which is for that project alone. + */ +function throughNpx(argv, root) { + const folder = ownFolder(); + if (!folder) return { argv, npx: true }; + const key = createHash('sha256') + .update(`${VERSION}\0${argv.join('\0')}\0${root}`) + .digest('hex') + .slice(0, 16); + const remember = path.join(folder, `npx-cli-${key}`); + const npx = { argv, npx: true, remember }; + const script = rememberedCli(remember); + return script ? { argv: [process.execPath, script], remembered: npx } : npx; +} + +/** + * The CLI script whose path `file` holds, when it is still there and still this release's: the + * `bin` of the onsystem package it is in, at VERSION. `file` must be a file, not a link, that only + * the user can write to, as the CLI writes it. + */ +function rememberedCli(file) { + let script; + try { + const stat = fs.lstatSync(file); + if (!stat.isFile()) return undefined; + if (process.getuid && (stat.uid !== process.getuid() || stat.mode & 0o022)) return undefined; + script = fs.readFileSync(file, 'utf8').trim(); + } catch { + return undefined; + } + if (!path.isAbsolute(script)) return undefined; + // The bin is dist/cli.js in the package. + const dir = path.dirname(path.dirname(script)); + const manifest = readJson(path.join(dir, 'package.json')); + const bin = typeof manifest?.bin === 'string' ? manifest.bin : manifest?.bin?.[PACKAGE]; + const current = + manifest?.name === PACKAGE && + manifest.version === VERSION && + typeof bin === 'string' && + path.resolve(dir, bin) === script; + return current && fs.existsSync(script) ? script : undefined; +} + +/** + * The user's own folder in the system temp folder, `onsystem-` (`onsystem` on Windows), + * where the CLI caches too, created for the user alone. Undefined unless it is theirs alone, since + * the hook runs the CLI a file in it names: the rule of `untrustedFolder` in + * src/util/own-folder.ts, which this hook, run without the package, keeps a copy of. A folder, not + * a link; on Linux and macOS the user's, and no one else may write to it; on Windows, which has + * no such owner to check, inside the user's home folder, where Windows keeps their temp folder. + */ +function ownFolder() { + const user = process.getuid ? `-${process.getuid()}` : ''; + const dir = path.join(os.tmpdir(), `onsystem${user}`); + try { + if (fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) !== undefined) { + fs.chmodSync(dir, 0o700); + } + const stat = fs.lstatSync(dir); + if (!stat.isDirectory()) return undefined; + if (process.platform === 'win32' || !process.getuid) { + const home = fs.realpathSync.native(os.homedir()); + const relative = path.relative(home, fs.realpathSync.native(dir)); + return !relative.startsWith('..') && !path.isAbsolute(relative) ? dir : undefined; + } + if (stat.uid !== process.getuid() || stat.mode & 0o022) return undefined; + if (stat.mode & 0o077) fs.chmodSync(dir, 0o700); + return dir; + } catch { + return undefined; + } } /** The CLI script of the project's own install, if it has one. */ diff --git a/src/bin.ts b/src/bin.ts index 7ef59fca..cc9acee4 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,5 +1,54 @@ #!/usr/bin/env node -import { main } from './cli.js'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import module from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ownFolder, untrustedFolder } from './util/own-folder.js'; + +// Both below are kept in the user's own folder in the system temp folder, and only while it is +// theirs alone: what is in it is run. +const folder = ownFolder(); +const own = !untrustedFolder(folder, true); + +// Node.js keeps the code it compiled for the CLI and TypeScript, so a run after the first starts +// sooner: the Claude Code hook starts one per edit. The cache is turned on before the CLI loads, +// which is why the CLI is imported below rather than at the top. Node.js 22.1 and later; skipped +// on Node.js 20, and when NODE_DISABLE_COMPILE_CACHE is set. Its folder is created here for the +// user alone, since Node.js would create it with the umask, and held to the same rule. +const compileCache = path.join(folder, 'compile-cache'); +const node = module as Partial>; +if (own && node.enableCompileCache && !untrustedFolder(compileCache, true)) { + node.enableCompileCache(compileCache); +} + +// The Claude Code hook names a file when npx starts the CLI: the CLI writes its own path in it, +// and the hook's next edits in that project run it with Node.js directly, without npm. Only an +// `npx-cli-` file directly in that folder, for the user alone; a link there is replaced, not +// followed. +const remember = process.env.ONSYSTEM_REMEMBER_CLI; +if ( + own && + remember && + path.dirname(path.resolve(remember)) === folder && + /^npx-cli-[0-9a-f]{16}$/.test(path.basename(remember)) +) { + const partial = path.join( + folder, + `.${path.basename(remember)}.${String(process.pid)}-${randomUUID()}`, + ); + try { + fs.writeFileSync(partial, fileURLToPath(import.meta.url), { mode: 0o600, flag: 'wx' }); + fs.renameSync(partial, path.join(folder, path.basename(remember))); + } catch { + // The hook goes on running npx. + } finally { + fs.rmSync(partial, { force: true }); + } +} + +const { main } = await import('./cli.js'); main(process.argv.slice(2)).then( (code) => { diff --git a/src/cli.ts b/src/cli.ts index f472d99f..9e9e0c5b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -275,6 +275,7 @@ export async function main(argv: string[], io: Io = defaultIo): Promise tokens: values.tokens, docs: values.docs, includeDesignSystem: values['include-design-system'], + cache: values.cache, }; try { diff --git a/src/config.ts b/src/config.ts index da4f5fb4..8bd04284 100644 --- a/src/config.ts +++ b/src/config.ts @@ -406,6 +406,11 @@ export interface LoadConfigOptions { tokens?: string[] | undefined; docs?: string[] | undefined; includeDesignSystem?: boolean | undefined; + /** + * `false` (`--no-cache`): find the design system again rather than read what was kept on disk + * the last time ({@link detectProject}). + */ + cache?: boolean | undefined; /** * Read a JSON config only: a JavaScript or TypeScript config is an error rather than run. * For a project the MCP server found from the path a tool call names, which a prompt can @@ -605,7 +610,7 @@ export async function loadConfigWith( const componentsSet = Boolean(options.components?.length) || configComponents !== undefined; const detection = componentsSet ? undefined - : detectProject(root, tsconfig, undefined, declared, finder(root)); + : detectProject(root, tsconfig, undefined, declared, finder(root), options.cache !== false); // A token file named outright (not a glob) that does not exist is a typo, not "no tokens". // Not in a design-system package's config, whose own checks catch it: a package installed diff --git a/src/design-system.ts b/src/design-system.ts index 2c009098..98a17557 100644 --- a/src/design-system.ts +++ b/src/design-system.ts @@ -1,7 +1,6 @@ import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; import fsp from 'node:fs/promises'; -import os from 'node:os'; import path from 'node:path'; import { glob } from 'tinyglobby'; @@ -59,6 +58,7 @@ import type { TokenCategory, } from './types.js'; import { silentLogger, type Logger } from './util/log.js'; +import { ownFolder, ownFolderAdvice, untrustedFolder } from './util/own-folder.js'; import { globBase, isHomeOrAbove, isInside, matchesGlob, relativePath } from './util/paths.js'; import { closest, plural, unique } from './util/strings.js'; import { COLOR_UTILITY, parseUtility } from './lint/tailwind.js'; @@ -650,7 +650,9 @@ async function cacheKey( /** * Where the model is cached: the project's `node_modules/.cache`, else (`temp`) a folder of the - * user's own in the system temp folder, `onsystem-`. + * user's own in the system temp folder, `onsystem-`, used only while it is theirs alone + * ({@link untrustedFolder}): on a multi-user host the temp folder is shared, and a model planted in + * it would reach the agent. */ function cacheFile(root: string): { file: string; temp: boolean } { const nodeModules = path.join(root, 'node_modules'); @@ -658,25 +660,7 @@ function cacheFile(root: string): { file: string; temp: boolean } { return { file: path.join(nodeModules, '.cache', 'onsystem', 'model.json'), temp: false }; } const id = createHash('sha256').update(root).digest('hex').slice(0, 16); - const user = process.getuid ? `-${process.getuid()}` : ''; - return { file: path.join(os.tmpdir(), `onsystem${user}`, `${id}.json`), temp: true }; -} - -/** - * Why the cache folder in the system temp folder cannot be trusted, or undefined when it can: it - * must be a folder, not a link, that the user owns and no one else can write to. On a multi-user - * host the temp folder is shared, and a model planted in it would reach the agent. With `create`, - * it is created (or closed) for the user first. Windows gives each user a temp folder of their own. - */ -async function untrustedCacheFolder(folder: string, create: boolean): Promise { - if (!process.getuid) return undefined; - if (create) await fsp.mkdir(folder, { recursive: true, mode: 0o700 }); - const stat = await fsp.lstat(folder); - if (!stat.isDirectory()) return `${folder} is not a folder`; - if (stat.uid !== process.getuid()) return `${folder} belongs to another user`; - if (!create && stat.mode & 0o022) return `other users can write to ${folder}`; - if (create && stat.mode & 0o077) await fsp.chmod(folder, 0o700); - return undefined; + return { file: path.join(ownFolder(), `${id}.json`), temp: true }; } /** @@ -727,7 +711,7 @@ export async function buildModel( let cached: { key?: string; dependencies?: string[]; model?: DesignSystemModel } | undefined; if (useCache && !options.oldProgram) { try { - const untrusted = temp && (await untrustedCacheFolder(path.dirname(cachePath), false)); + const untrusted = temp && untrustedFolder(path.dirname(cachePath)); if (!untrusted) cached = JSON.parse(await fsp.readFile(cachePath, 'utf8')) as typeof cached; } catch { // No cache yet, or unreadable: extract. @@ -827,11 +811,11 @@ export async function buildModel( try { const folder = path.dirname(cachePath); let untrusted: string | undefined; - if (temp) untrusted = await untrustedCacheFolder(folder, true); + if (temp) untrusted = untrustedFolder(folder, true); else await fsp.mkdir(folder, { recursive: true }); if (untrusted) { logger.warn( - `not caching the model: ${untrusted}. Install the project's dependencies, so the cache goes in node_modules, or set TMPDIR to a folder of your own.`, + `not caching the model: ${untrusted}. Install the project's dependencies, so the cache goes in node_modules, or ${ownFolderAdvice()}.`, ); } else { await writeWhole(cachePath, JSON.stringify({ key, dependencies, model }), temp); @@ -1060,6 +1044,7 @@ export class DesignSystemHost { loadConfig({ root, config: configFile === designSystemPackage?.config ? undefined : configFile, + cache: this.options.cache, })); try { this.config = await load(); diff --git a/src/detect.ts b/src/detect.ts index 4b1de60f..806ce670 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -1,13 +1,17 @@ +import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { escapePath, globSync } from 'tinyglobby'; import { readProjectConfig, type ProjectConfig } from './extract/program.js'; import { buildEntries, exportedFiles, moduleFile, sourceTarget } from './package-source.js'; import { importedStylesheets } from './tokens/imports.js'; +import { ownFolder, untrustedFolder } from './util/own-folder.js'; import { globBase, isHomeOrAbove, isInside, pathOf, relativePath, toPosix } from './util/paths.js'; import { unique } from './util/strings.js'; +import { VERSION } from './version.js'; /** * How apps import a design-system module, from a package's `exports`: @@ -177,6 +181,11 @@ export const APP_STYLESHEETS = [ * `declared` is the dependency whose config the root takes on ({@link findDesignSystemPackage}): * it is read first among the dependencies in step 3, whatever its name, and also when it is * installed from a registry rather than a workspace package. + * + * A design system found in one dependency's `exports` (step 3) is kept on disk beside the + * cached model, and read from there while nothing it came from changes ({@link keptDetection}): + * following a package's barrels parses every file they lead to, and `check` runs once per edit. + * Without `cache` (`--no-cache`), it is neither read nor kept. */ export function detectProject( root: string, @@ -184,6 +193,35 @@ export function detectProject( trace?: DetectionStep[], declared?: Pick, packages: PackageFinder = new PackageFinder(root), + cache = true, +): Detection | undefined { + // A trace (`inspect --explain`) is of every candidate, looked at again. + if (trace || !cache) return detectAll(root, tsconfig, trace, declared, packages); + const code = codeStamp(); + if (!code) return detectAll(root, tsconfig, undefined, declared, packages); + const key = JSON.stringify([ + VERSION, + code, + path.resolve(root), + tsconfig ?? null, + declared ? [declared.name, declared.dir, declared.installed] : null, + ]); + const kept = keptDetection(root, key, declared, packages); + if (kept && 'detection' in kept) return kept.detection; + const inputs: Inputs = { located: [], dirs: [], keep: false }; + const detection = detectAll(root, tsconfig, undefined, declared, packages, inputs); + if (detection && inputs.keep) keepDetection(root, key, inputs, detection, kept?.crowded); + return detection; +} + +/** {@link detectProject} without the copy kept on disk, noting in `inputs` what it read. */ +function detectAll( + root: string, + tsconfig: string | undefined, + trace: DetectionStep[] | undefined, + declared: Pick | undefined, + packages: PackageFinder, + inputs?: Inputs, ): Detection | undefined { const step = (candidate: string, accepted: boolean, reason: string) => trace?.push({ candidate, accepted, reason }); @@ -257,6 +295,8 @@ export function detectProject( ); } let imported: Map> | undefined; + // A workspace package read other than through its exports: its tsconfig or the root's code. + let unwatched = false; // Packages published without their sources whose type declarations are read instead. let declarations: Set | undefined; /** What the trace adds for an installed package whose sources are not read. */ @@ -275,6 +315,7 @@ export function detectProject( // Else a workspace package, or one installed from a registry. const workspaceDir = own ? undefined : packages.find(name); const installedDir = own || workspaceDir ? undefined : packages.installed(name); + inputs?.located.push([name, workspaceDir ?? null, installedDir ?? null]); const kind = (own?.installed ?? installedDir !== undefined) ? 'installed' : 'workspace'; const candidate = `${kind} package ${name}`; // What components.json already points into. @@ -290,13 +331,24 @@ export function detectProject( } const reasons: string[] = []; const note: Note = (r) => reasons.push(r); + inputs?.dirs.push(dir); // An installed package that declares no config is read through its exports alone, when // they point at the component sources it publishes: its compiled files are not read. - const result = installedDir - ? fromPackage(root, dir, pkg, note, true) - : (fromPackage(root, dir, pkg, note) ?? - fromPackageComponentsJson(root, dir, pkg, packages, note) ?? - fromImports(root, dir, name, (imported ??= importedSubpaths(root, names)).get(name), note)); + const exported = fromPackage(root, dir, pkg, note, installedDir !== undefined); + if (!exported && !installedDir) unwatched = true; + const result = + exported ?? + (installedDir + ? undefined + : (fromPackageComponentsJson(root, dir, pkg, packages, note) ?? + fromImports( + root, + dir, + name, + (imported ??= importedSubpaths(root, names)).get(name), + note, + ))); + if (result) inputs?.dirs.push(...result.dirs); if (result && matchesAny(root, result)) { found.push({ ...result, @@ -338,7 +390,15 @@ export function detectProject( if (found.length === 1) { const [only] = found; const source = only?.source ?? ''; - if (!only || !read.length) return detection(root, found, withDefaults, source); + if (!only || !read.length) { + // Found in one dependency's exports, with nothing read from the root but its + // package.json and that it has no components.json: kept on disk. + if (inputs) { + const ownExports = own?.exports !== undefined || typeof own?.main === 'string'; + inputs.keep = !shadcn && !ownExports && !unwatched; + } + return detection(root, found, withDefaults, source); + } return { ...detection(root, [...found, ...registries], withDefaults, named(source)), designSystems: [{ source, components: only.components, imports: 0 }, ...read], @@ -1738,6 +1798,238 @@ function fileStamp(file: string): string { return stats ? `${String(stats.mtimeMs)}:${String(stats.size)}` : '-'; } +/** What a detection read, so that it can be kept on disk ({@link keepDetection}). */ +interface Inputs { + /** Each dependency looked at: its workspace folder and the folder it is installed in, if any. */ + located: [name: string, workspace: string | null, installed: string | null][]; + /** The folders whose files it read: each dependency's, and its components'. */ + dirs: string[]; + /** Whether it read nothing else from the root but its package.json and components.json. */ + keep: boolean; +} + +/** + * Folders that held more than MAX_WATCHED_FILES files, so that a detection reading them is not + * kept, and they are not walked again on each run while `stamp` ({@link folderStamp}) holds. + */ +interface Crowded { + dirs: string[]; + stamp: string; +} + +/** A detection kept on disk, with what tells whether it still holds, or folders too big to watch. */ +type KeptDetection = { key: string } & ( + | { + /** {@link inputsStamp} of `dirs`. */ + stamp: string; + located: Inputs['located']; + dirs: string[]; + detection: Detection; + } + | { crowded: Crowded } +); + +/** More files than this in the folders a detection read, and it is not kept. */ +const MAX_WATCHED_FILES = 5000; +/** + * A detection is kept only when the files it read are older than this. A file written again + * within the same tick of its file system's clock (a few milliseconds on Linux, a second on + * HFS+) keeps its mtime, and with its size unchanged, the change would go unseen. + */ +const SETTLED_MS = 2000; +/** More files than this in this module's folder, and only this module's file stamps the code. */ +const MAX_CODE_FILES = 500; + +const byName = (a: fs.Dirent, b: fs.Dirent) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + +let code: string | null | undefined; +/** + * A stamp of the code that is running ({@link codeStampOf} this module's folder: `dist`, or `src` + * run from source), so that a detection kept by another build of onsystem is not read, whatever + * its version says. Undefined when it cannot be read: nothing is kept then. + */ +function codeStamp(): string | undefined { + if (code === undefined) { + try { + const self = fileURLToPath(import.meta.url); + code = codeStampOf(path.dirname(self), self); + } catch { + code = null; + } + } + return code ?? undefined; +} + +/** + * A hash of the size and mtime of every file in `dir` (outside `node_modules`), or of `self` + * alone when it holds more than MAX_CODE_FILES: a module bundled into a folder of other files. + */ +export function codeStampOf(dir: string, self?: string): string { + const files: string[] = []; + const walk = (folder: string): boolean => { + for (const entry of fs.readdirSync(folder, { withFileTypes: true }).sort(byName)) { + if (entry.name === 'node_modules') continue; + const file = path.join(folder, entry.name); + if (entry.isDirectory()) { + if (!walk(file)) return false; + } else if (files.push(file) > MAX_CODE_FILES) { + return false; + } + } + return true; + }; + const stamped = walk(dir) || !self ? files : [self]; + const hash = createHash('sha256'); + for (const file of stamped) hash.update(`${file}\0${fileStamp(file)}\n`); + return hash.digest('hex').slice(0, 16); +} + +/** + * Where a detection is kept: beside the cached model, in the project's `node_modules/.cache`, + * else (`temp`) in the user's own folder in the system temp folder ({@link ownFolder}). + */ +function detectionFile(root: string): { file: string; temp: boolean } { + const nodeModules = path.join(root, 'node_modules'); + if (fs.existsSync(nodeModules)) { + return { file: path.join(nodeModules, '.cache', 'onsystem', 'detection.json'), temp: false }; + } + const id = createHash('sha256').update(path.resolve(root)).digest('hex').slice(0, 16); + return { file: path.join(ownFolder(), `${id}.detection.json`), temp: true }; +} + +/** + * A hash of the size and mtime of the root's package.json and components.json and of every file + * and folder in `dirs` (outside `node_modules` and `.git`), and the newest mtime among them. A + * folder's mtime changes when a file in it is added, removed or renamed. Undefined when there are + * more than MAX_WATCHED_FILES files. + */ +function inputsStamp( + root: string, + dirs: readonly string[], +): { stamp: string; newest: number } | undefined { + const hash = createHash('sha256'); + let newest = 0; + let count = 0; + const add = (file: string) => { + const stats = fs.statSync(file, { throwIfNoEntry: false }); + hash.update(`${file}\0${stats ? `${String(stats.mtimeMs)}:${String(stats.size)}` : '-'}\n`); + if (stats) newest = Math.max(newest, stats.mtimeMs); + }; + add(path.join(root, 'package.json')); + add(path.join(root, 'components.json')); + const walk = (dir: string): boolean => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + // Missing: a folder created later changes the stamp. + add(dir); + return true; + } + add(dir); + for (const entry of entries.sort(byName)) { + if (entry.name === 'node_modules' || entry.name === '.git') continue; + const file = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!walk(file)) return false; + } else if (++count > MAX_WATCHED_FILES) { + return false; + } else { + add(file); + } + } + return true; + }; + for (const dir of dirs) if (!walk(dir)) return undefined; + return { stamp: hash.digest('hex'), newest }; +} + +/** A hash of the size and mtime of the folders `dirs` themselves, not of what is in them. */ +function folderStamp(dirs: readonly string[]): string { + const hash = createHash('sha256'); + for (const dir of dirs) hash.update(`${dir}\0${fileStamp(dir)}\n`); + return hash.digest('hex'); +} + +/** + * The detection kept on disk for this `key` (the version and build, root, tsconfig and declared + * package), while it still holds: every dependency it looked at is found where it was, and no + * file in the folders it read, nor the root's package.json or components.json, has changed size + * or mtime. Or the folders a detection for this key found too big to watch. + */ +function keptDetection( + root: string, + key: string, + declared: Pick | undefined, + packages: PackageFinder, +): { detection: Detection } | { crowded: Crowded } | undefined { + try { + const { file, temp } = detectionFile(root); + if (temp && untrustedFolder(path.dirname(file))) return undefined; + const kept = JSON.parse(fs.readFileSync(file, 'utf8')) as Partial< + Extract & + Extract + >; + if (kept.key !== key) return undefined; + if (kept.crowded) return { crowded: kept.crowded }; + if (!kept.detection || !kept.located || !kept.dirs) return undefined; + if (inputsStamp(root, kept.dirs)?.stamp !== kept.stamp) return undefined; + for (const [name, workspace, installed] of kept.located) { + if (name === declared?.name) continue; + const found = packages.find(name) ?? null; + if (found !== workspace) return undefined; + if (!found && (packages.installed(name) ?? null) !== installed) return undefined; + } + return { detection: kept.detection }; + } catch { + // None kept, or unreadable: detect. + return undefined; + } +} + +/** + * Keeps a detection on disk for {@link keptDetection}, when the files it read have settled. When + * its folders hold too many files to watch, keeps that instead, so that they are not walked on + * each run: `crowded`, what was kept for them before. + */ +function keepDetection( + root: string, + key: string, + inputs: Inputs, + detection: Detection, + crowded: Crowded | undefined, +): void { + const dirs = unique(inputs.dirs.map((d) => path.resolve(d))); + const outermost = dirs.filter((d) => !dirs.some((other) => other !== d && isInside(other, d))); + if (outermost.some(isHomeOrAbove)) return; + try { + const sameDirs = + crowded?.dirs.length === outermost.length && crowded.dirs.every((d, i) => d === outermost[i]); + if (sameDirs && crowded.stamp === folderStamp(outermost)) return; + const stamped = inputsStamp(root, outermost); + if (stamped && Date.now() - stamped.newest < SETTLED_MS) return; + const { file, temp } = detectionFile(root); + if (temp) { + if (untrustedFolder(path.dirname(file), true)) return; + } else { + fs.mkdirSync(path.dirname(file), { recursive: true }); + } + const kept: KeptDetection = stamped + ? { key, stamp: stamped.stamp, located: inputs.located, dirs: outermost, detection } + : { key, crowded: { dirs: outermost, stamp: folderStamp(outermost) } }; + // Written beside it, then renamed into place: a run reading it never sees half. + const partial = `${file}.${String(process.pid)}-${randomUUID()}.tmp`; + try { + fs.writeFileSync(partial, JSON.stringify(kept), temp ? { mode: 0o600 } : {}); + fs.renameSync(partial, file); + } finally { + fs.rmSync(partial, { force: true }); + } + } catch { + // Not kept: the next run detects again. + } +} + /** `@acme/ui`, `@acme/ui-kit`, `@acme/design-system`, `acme-ui`; not `@acme/email-components`. */ export function isDesignSystemName(name: string): boolean { const base = name.slice(name.lastIndexOf('/') + 1); diff --git a/src/util/own-folder.ts b/src/util/own-folder.ts new file mode 100644 index 00000000..475cb37a --- /dev/null +++ b/src/util/own-folder.ts @@ -0,0 +1,63 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Node.js built-ins only: src/bin.ts imports this before it turns on the compile cache, and the +// rest of the CLI loads after that. + +/** + * The user's own folder in the system temp folder, `onsystem-` (`onsystem` on Windows): + * the cached model and detection of a project without `node_modules`, Node.js's compile cache + * for the CLI, and the path of the CLI npx installed, which the Claude Code hook runs. + */ +export function ownFolder(): string { + const user = process.getuid ? `-${String(process.getuid())}` : ''; + return path.join(os.tmpdir(), `onsystem${user}`); +} + +/** + * What the user can do when the {@link ownFolder} cannot be trusted: os.tmpdir() reads TMPDIR on + * Linux and macOS and TEMP on Windows, where the folder must also be inside the home folder. + */ +export function ownFolderAdvice(): string { + const windows = process.platform === 'win32' || !process.getuid; + return `set TMPDIR (TEMP on Windows) to a folder of your own${windows ? ' inside your home folder' : ''}`; +} + +/** + * Why `dir` (the {@link ownFolder}) cannot be trusted, or undefined when it can. What is kept in + * it decides what code runs and what is read as the design system, and the system temp folder + * can be shared. With `create`, it is created first, for the user alone. + * + * It must be a folder, not a link. On Linux and macOS it must belong to the user, and no one else + * may write to it: one that others can write to is refused rather than closed, since a file put + * in it before would stay. Windows has no such owner to check here: there it must be inside the + * user's home folder (links resolved), where Windows keeps each user's own temp folder. + * + * The Claude Code hook keeps a copy of this rule (plugins/onsystem/hooks/check-ui.mjs), since it + * runs without the package: keep them the same. + */ +export function untrustedFolder(dir: string, create = false): string | undefined { + try { + // mkdir applies the umask, which may leave the user without access to it: the folder it + // made is set to theirs alone. + if (create && fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) !== undefined) { + fs.chmodSync(dir, 0o700); + } + const stat = fs.lstatSync(dir); + if (!stat.isDirectory()) return `${dir} is not a folder`; + if (process.platform === 'win32' || !process.getuid) { + const home = fs.realpathSync.native(os.homedir()); + const relative = path.relative(home, fs.realpathSync.native(dir)); + const inside = !relative.startsWith('..') && !path.isAbsolute(relative); + return inside ? undefined : `${dir} is outside your home folder`; + } + if (stat.uid !== process.getuid()) return `${dir} belongs to another user`; + if (stat.mode & 0o022) return `other users can write to ${dir}`; + // Others can read it but not write to it: closed, since what is kept in it names projects. + if (create && stat.mode & 0o077) fs.chmodSync(dir, 0o700); + return undefined; + } catch (error) { + return (error as Error).message; + } +} diff --git a/src/workspace.ts b/src/workspace.ts index f785edf6..8c251a8e 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -215,6 +215,7 @@ function loadProjectConfig( ): Promise { const shared = { cwd: options.cwd, + cache: options.cache, includeDesignSystem: options.includeDesignSystem, jsonConfigOnly: options.jsonConfigOnly, jsonConfigWithin: options.jsonConfigWithin, diff --git a/test/cache-folder.test.ts b/test/cache-folder.test.ts index 7eb42204..42152ca0 100644 --- a/test/cache-folder.test.ts +++ b/test/cache-folder.test.ts @@ -81,19 +81,30 @@ describe('the cache outside node_modules', () => { expect((await build(root)).model.stats.fromCache).toBe(true); }); - it.runIf(posix)('is not read from a folder other users can write to', async () => { - const root = fixture(PROJECT); - await build(root); - const [file = ''] = cached(); - plant(file); - fs.chmodSync(path.dirname(file), 0o777); - const second = await build(root); - expect(second.model.stats.fromCache).toBe(false); - expect(description(second.model)).toBe('Runs an action.'); - // The folder is the user's again, with the model just built in it. - expect(fs.statSync(path.dirname(file)).mode & 0o777).toBe(0o700); - expect((await build(root)).model.stats.fromCache).toBe(true); - }); + it.runIf(posix)( + 'is neither read from nor written to a folder other users can write to', + async () => { + const root = fixture(PROJECT); + await build(root); + const [file = ''] = cached(); + plant(file); + const folder = path.dirname(file); + fs.chmodSync(folder, 0o777); + const warnings: string[] = []; + const second = await build(root, warnings); + expect(second.model.stats.fromCache).toBe(false); + expect(description(second.model)).toBe('Runs an action.'); + expect(warnings).toEqual([ + `not caching the model: other users can write to ${folder}. Install the project's dependencies, so the cache goes in node_modules, or set TMPDIR (TEMP on Windows) to a folder of your own.`, + ]); + // Refused, not closed and trusted: a file put in it before would stay. + expect(fs.statSync(folder).mode & 0o777).toBe(0o777); + expect((await build(root)).model.stats.fromCache).toBe(false); + const left = JSON.parse(fs.readFileSync(file, 'utf8')) as Pick; + expect(description(left.model)).toBe('PLANTED'); + fs.chmodSync(folder, 0o700); + }, + ); it.runIf(posix)('is neither read nor written through a link in place of its folder', async () => { const root = fixture(PROJECT); @@ -114,7 +125,7 @@ describe('the cache outside node_modules', () => { expect(fs.lstatSync(folder).isSymbolicLink()).toBe(true); // In a shared temp folder the user may not be able to remove it: the warning says what to do. expect(warnings).toEqual([ - `not caching the model: ${folder} is not a folder. Install the project's dependencies, so the cache goes in node_modules, or set TMPDIR to a folder of your own.`, + `not caching the model: ${folder} is not a folder. Install the project's dependencies, so the cache goes in node_modules, or set TMPDIR (TEMP on Windows) to a folder of your own.`, ]); }); }); diff --git a/test/hook-speed.test.ts b/test/hook-speed.test.ts new file mode 100644 index 00000000..a9928f29 --- /dev/null +++ b/test/hook-speed.test.ts @@ -0,0 +1,745 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import module from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; + +import ts from 'typescript'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { main, type Io } from '../src/cli.js'; +import { loadConfig } from '../src/config.js'; +import { codeStampOf, detectProject, type DetectionStep } from '../src/detect.js'; +import { ownFolderAdvice, untrustedFolder } from '../src/util/own-folder.js'; +import { fixture } from './helpers.js'; + +// Each edit an agent makes runs `check` through the Claude Code hook in a new process. Three +// things it used to do again on every edit: start npm (npx), compile the CLI and TypeScript, and +// follow the design-system package's barrels to its components, parsing each file. + +const REPO = path.resolve(import.meta.dirname, '..'); +const HOOK = path.join(REPO, 'plugins/onsystem/hooks/check-ui.mjs'); +const { version: VERSION } = JSON.parse( + fs.readFileSync(path.join(REPO, 'package.json'), 'utf8'), +) as { version: string }; +const posix = process.platform !== 'win32'; +const user = process.getuid ? `-${String(process.getuid())}` : ''; + +const json = (value: unknown) => JSON.stringify(value, null, 2); +const component = (name: string) => + `export function ${name}(props: { tone?: "calm" | "loud" }) {\n return
;\n}\n`; + +/** An npm monorepo whose app uses a design-system package that exports its components from a barrel. */ +const MONOREPO = { + 'package.json': json({ name: 'mono', private: true, workspaces: ['apps/*', 'packages/*'] }), + 'apps/web/package.json': json({ name: 'web', dependencies: { '@acme/ui': '*' } }), + 'apps/web/app/page.tsx': 'export default function Page() {\n return null;\n}\n', + 'packages/ui/package.json': json({ name: '@acme/ui', exports: { '.': './src/index.ts' } }), + 'packages/ui/src/index.ts': + "export * from './button';\nexport * from './card';\nexport * from './badge';\n", + 'packages/ui/src/button.tsx': component('Button'), + 'packages/ui/src/card.tsx': component('Card'), + 'packages/ui/src/badge.tsx': component('Badge'), +}; + +/** Dates every file and folder under `dir` a minute back: files that have not changed for a while. */ +function settle(dir: string): void { + const past = new Date(Date.now() - 60_000); + for (const entry of fs.readdirSync(dir, { recursive: true, withFileTypes: true })) { + fs.utimesSync(path.join(entry.parentPath, entry.name), past, past); + } +} + +/** Runs the CLI in this process, from `cwd`. */ +async function cli(args: string[], cwd: string) { + const out: string[] = []; + const io: Io = { cwd, color: false, stdout: (t) => out.push(t), stderr: () => {} }; + const code = await main(args, io); + return { code, stdout: out.join('\n') }; +} + +describe('detection kept on disk', () => { + // The system temp folder, as os.tmpdir() reads it: TMPDIR, or TEMP and TMP on Windows. + const TEMP_VARS = ['TMPDIR', 'TEMP', 'TMP']; + let temp: string; + let saved: (string | undefined)[] = []; + beforeEach(() => { + saved = TEMP_VARS.map((name) => process.env[name]); + temp = fs.mkdtempSync(path.join(os.tmpdir(), 'onsystem-detect-test-')); + for (const name of TEMP_VARS) process.env[name] = temp; + }); + afterEach(() => { + TEMP_VARS.forEach((name, i) => { + const value = saved[i]; + if (value === undefined) Reflect.deleteProperty(process.env, name); + else process.env[name] = value; + }); + fs.rmSync(temp, { recursive: true, force: true }); + }); + + const folder = () => path.join(temp, `onsystem${user}`); + /** The detections kept in the user's temp folder (the fixtures are in it too). */ + function kept(): string[] { + if (!fs.existsSync(folder())) return []; + return fs + .readdirSync(folder()) + .filter((name) => name.endsWith('.detection.json')) + .map((name) => path.join(folder(), name)); + } + /** Marks the one kept detection, so that a detection read from it shows. */ + function mark(): void { + const [file, ...more] = kept(); + expect(more).toEqual([]); + const data = JSON.parse(fs.readFileSync(file ?? '', 'utf8')) as { detection: object }; + data.detection = { ...data.detection, source: 'KEPT' }; + fs.writeFileSync(file ?? '', JSON.stringify(data)); + } + const files = (web: string) => + detectProject(web) + ?.components.map((c) => path.basename(c)) + .sort(); + + it("reads a design system found in a package's exports from disk while nothing it read changes", () => { + const root = fixture(MONOREPO); + const web = path.join(root, 'apps/web'); + settle(root); + const first = detectProject(web); + expect(first?.source).toBe('workspace package @acme/ui'); + expect(first?.components.map((c) => path.basename(c)).sort()).toEqual([ + 'badge.tsx', + 'button.tsx', + 'card.tsx', + ]); + mark(); + expect(detectProject(web)?.source).toBe('KEPT'); + // The agent's edits to the app do not change what was read. + fs.writeFileSync(path.join(web, 'app/page.tsx'), 'export default function Page() {}\n'); + fs.writeFileSync(path.join(web, 'app/new.tsx'), 'export default function New() {}\n'); + expect(detectProject(web)?.source).toBe('KEPT'); + // inspect --explain traces every candidate again. + const steps: DetectionStep[] = []; + expect(detectProject(web, undefined, steps)?.source).toBe('workspace package @acme/ui'); + expect(steps.some((s) => s.candidate === 'workspace package @acme/ui' && s.accepted)).toBe( + true, + ); + }); + + it('detects again when a file in the package changes, and keeps that once it settles', () => { + const root = fixture(MONOREPO); + const web = path.join(root, 'apps/web'); + settle(root); + detectProject(web); + mark(); + // A new component exported from the barrel. + fs.writeFileSync(path.join(root, 'packages/ui/src/dialog.tsx'), component('Dialog')); + fs.appendFileSync(path.join(root, 'packages/ui/src/index.ts'), "export * from './dialog';\n"); + expect(files(web)).toEqual(['badge.tsx', 'button.tsx', 'card.tsx', 'dialog.tsx']); + expect(detectProject(web)?.source).toBe('workspace package @acme/ui'); + settle(root); + expect(files(web)).toEqual(['badge.tsx', 'button.tsx', 'card.tsx', 'dialog.tsx']); + mark(); + expect(detectProject(web)?.source).toBe('KEPT'); + // A file rewritten at the same size. + const card = path.join(root, 'packages/ui/src/card.tsx'); + fs.writeFileSync(card, fs.readFileSync(card, 'utf8').replace('calm', 'cool')); + expect(detectProject(web)?.source).toBe('workspace package @acme/ui'); + // A barrel removed. + settle(root); + detectProject(web); + mark(); + fs.writeFileSync(path.join(root, 'packages/ui/src/index.ts'), "export * from './button';\n"); + expect(detectProject(web)).toBeUndefined(); + }); + + it("detects again when the app's package.json or components.json changes, or a dependency appears", () => { + const root = fixture({ + ...MONOREPO, + 'apps/web/package.json': json({ + name: 'web', + dependencies: { '@acme/ui': '*', '@acme/design-system': '*' }, + }), + }); + const web = path.join(root, 'apps/web'); + settle(root); + expect(detectProject(web)?.source).toBe('workspace package @acme/ui'); + mark(); + // @acme/design-system, not in the workspace before, now is. + fs.mkdirSync(path.join(root, 'packages/design-system/src'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'packages/design-system/package.json'), + json({ name: '@acme/design-system', exports: { './*': './src/*.tsx' } }), + ); + for (const name of ['Alert', 'Tabs', 'Toast']) { + fs.writeFileSync( + path.join(root, `packages/design-system/src/${name.toLowerCase()}.tsx`), + component(name), + ); + } + expect(detectProject(web)?.designSystems?.map((d) => d.source)).toEqual([ + 'workspace package @acme/ui', + 'workspace package @acme/design-system', + ]); + + for (const file of kept()) fs.rmSync(file); + const app = fixture(MONOREPO); + const other = path.join(app, 'apps/web'); + settle(app); + detectProject(other); + mark(); + fs.writeFileSync(path.join(other, 'components.json'), json({ aliases: {} })); + expect(detectProject(other)?.source).toBe('workspace package @acme/ui'); + fs.rmSync(path.join(other, 'components.json')); + fs.writeFileSync(path.join(other, 'package.json'), json({ name: 'web' })); + expect(detectProject(other)).toBeUndefined(); + }); + + it("keeps nothing whose files just changed, or that read the app's own code", () => { + // Written within the same tick of the file system's clock, a change can keep its mtime. + const fresh = fixture(MONOREPO); + expect(detectProject(path.join(fresh, 'apps/web'))?.source).toBe('workspace package @acme/ui'); + expect(kept()).toEqual([]); + // A package without exports is read from the paths the app imports. + const byPath = fixture({ + ...MONOREPO, + 'packages/ui/package.json': json({ name: '@acme/ui' }), + 'apps/web/app/page.tsx': + 'import { Button } from "@acme/ui/src/button";\n\nexport default function Page() {\n return