From fe63cfe0f339f1f529056d3a398c2be3af0a54f0 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 30 Oct 2026 17:49:07 +0000 Subject: [PATCH 1/6] perf: start check sooner on each hook edit The Claude Code hook starts a new `check` process on every edit. Each run compiled the CLI and TypeScript again, followed the design-system package's barrels again (parsing every file they lead to), and, without a project install, started npm through npx first. - src/bin.ts turns on Node's compile cache (22.1+) in the user's own temp folder, onsystem-, before it loads the CLI. - src/detect.ts keeps a detection found in one dependency's exports beside the cached model, and reads it back while the dependency is found at the same folder and no file in that folder, nor the app's package.json or components.json, has changed size or mtime. Files changed in the last two seconds are not trusted to show a change, so nothing is kept then. - The hook passes ONSYSTEM_REMEMBER_CLI to npx runs; the CLI writes its own path there, and later edits run that CLI with Node directly while it is still the plugin's release. OS2-56 --- .changeset/hook-speed.md | 7 + plugins/onsystem/hooks/check-ui.mjs | 82 +++++- src/bin.ts | 49 +++- src/detect.ts | 233 ++++++++++++++++- test/hook-speed.test.ts | 386 ++++++++++++++++++++++++++++ 5 files changed, 744 insertions(+), 13 deletions(-) create mode 100644 .changeset/hook-speed.md create mode 100644 test/hook-speed.test.ts diff --git a/.changeset/hook-speed.md b/.changeset/hook-speed.md new file mode 100644 index 00000000..f919efbe --- /dev/null +++ b/.changeset/hook-speed.md @@ -0,0 +1,7 @@ +--- +'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-`. 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: following the package's barrels parsed every file they lead to, on every run. On midday's dashboard and dub's web app, `check` on one file went from 0.52 s to 0.32 s and from 0.77 s to 0.42 s. + +In a project without onsystem installed, the hook runs it through npx on the first edit only: the CLI npx started says where it is, and the next edits run it with Node directly, without starting npm, while it is still the plugin's release. diff --git a/plugins/onsystem/hooks/check-ui.mjs b/plugins/onsystem/hooks/check-ui.mjs index 32cacb3c..949e6d7a 100644 --- a/plugins/onsystem/hooks/check-ui.mjs +++ b/plugins/onsystem/hooks/check-ui.mjs @@ -36,9 +36,14 @@ // 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 +// run that CLI with Node directly, without npm, while it is still this release: +// npm's start takes about 0.4 s of each edit. Its path is kept in the user's own +// folder in the system temp folder, which no one else can write to, 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, @@ -404,7 +409,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,7 +699,8 @@ 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, or npx (`npx: true`, without npm's flags and the package). + * No `argv`: no npx found. */ function resolveCli(dir) { if (BIN) return { argv: [process.execPath, BIN] }; @@ -704,8 +714,68 @@ 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]); + return process.platform === 'win32' ? { argv: undefined, npx: true } : throughNpx(['npx']); +} + +/** + * npx run as `argv`, or the CLI it started for this release before, while that is still there: + * run with Node directly. `remember` is the file the CLI npx starts writes its path in, one per + * release and npx, in the user's own folder in the system temp folder. + */ +function throughNpx(argv) { + const folder = ownFolder(); + if (!folder) return { argv, npx: true }; + const key = createHash('sha256') + .update(`${VERSION}\0${argv.join('\0')}`) + .digest('hex') + .slice(0, 16); + const remember = path.join(folder, `npx-cli-${key}`); + const script = rememberedCli(remember); + return script ? { argv: [process.execPath, script] } : { argv, npx: true, remember }; +} + +/** + * 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. + */ +function rememberedCli(file) { + let script; + try { + 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-`, where the CLI caches too: + * created for the user alone, and undefined when someone else could write to it, since the hook + * runs the CLI a file in it names. Windows gives each user a temp folder of their own. + */ +function ownFolder() { + const user = process.getuid ? `-${process.getuid()}` : ''; + const dir = path.join(os.tmpdir(), `onsystem${user}`); + try { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (!process.getuid) return dir; + const stat = fs.lstatSync(dir); + const own = stat.isDirectory() && stat.uid === process.getuid() && !(stat.mode & 0o022); + return own ? dir : undefined; + } 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..a41a216a 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,5 +1,33 @@ #!/usr/bin/env node -import { main } from './cli.js'; +import fs from 'node:fs'; +import module from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// 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. +const folder = ownFolder(); +if (folder) { + (module as Partial>).enableCompileCache?.( + path.join(folder, 'compile-cache'), + ); +} + +// The Claude Code hook names a file here when npx starts the CLI: the CLI writes its own path +// in it, and the hook's next edits run it with Node.js directly, without npm. +const remember = process.env.ONSYSTEM_REMEMBER_CLI; +if (remember) { + try { + fs.writeFileSync(remember, fileURLToPath(import.meta.url)); + } catch { + // The hook goes on running npx. + } +} + +const { main } = await import('./cli.js'); main(process.argv.slice(2)).then( (code) => { @@ -11,3 +39,22 @@ main(process.argv.slice(2)).then( process.exitCode = 1; }, ); + +/** + * The user's own folder in the system temp folder, `onsystem-`, where the model of a project + * without node_modules is cached too: created for the user alone, and undefined when someone else + * could write to it, since the code cached there is run. Windows gives each user a temp folder. + */ +function ownFolder(): string | undefined { + const user = process.getuid ? `-${String(process.getuid())}` : ''; + const dir = path.join(os.tmpdir(), `onsystem${user}`); + try { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (!process.getuid) return dir; + const stat = fs.lstatSync(dir); + const own = stat.isDirectory() && stat.uid === process.getuid() && !(stat.mode & 0o022); + return own ? dir : undefined; + } catch { + return undefined; + } +} diff --git a/src/detect.ts b/src/detect.ts index 4b1de60f..2f7747c0 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -1,4 +1,6 @@ +import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { escapePath, globSync } from 'tinyglobby'; @@ -8,6 +10,7 @@ import { buildEntries, exportedFiles, moduleFile, sourceTarget } from './package import { importedStylesheets } from './tokens/imports.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 +180,10 @@ 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. */ export function detectProject( root: string, @@ -184,6 +191,31 @@ export function detectProject( trace?: DetectionStep[], declared?: Pick, packages: PackageFinder = new PackageFinder(root), +): Detection | undefined { + // A trace (`inspect --explain`) is of every candidate, looked at again. + if (trace) return detectAll(root, tsconfig, trace, declared, packages); + const key = JSON.stringify([ + VERSION, + path.resolve(root), + tsconfig ?? null, + declared ? [declared.name, declared.dir, declared.installed] : null, + ]); + const kept = keptDetection(root, key, declared, packages); + if (kept) return kept; + 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); + 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 +289,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 +309,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 +325,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 +384,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 +1792,173 @@ 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; +} + +/** A detection kept on disk, with what tells whether it still holds. */ +interface KeptDetection { + key: string; + /** {@link inputsStamp} of `dirs`. */ + stamp: string; + located: Inputs['located']; + dirs: string[]; + detection: Detection; +} + +/** 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; + +/** + * 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, `onsystem-`. + */ +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); + const user = process.getuid ? `-${String(process.getuid())}` : ''; + return { file: path.join(os.tmpdir(), `onsystem${user}`, `${id}.detection.json`), temp: true }; +} + +/** + * Whether `folder`, in the system temp folder, is the user's alone: a folder, not a link, that + * they own and no one else can write to. The temp folder of a multi-user host is shared, and a + * detection planted in it would decide what is read as the design system. With `create`, it is + * created (or closed) for the user first. Windows gives each user a temp folder of their own. + */ +function ownFolder(folder: string, create: boolean): boolean { + if (create) fs.mkdirSync(folder, { recursive: true, mode: 0o700 }); + if (!process.getuid) return true; + const stat = fs.lstatSync(folder); + if (!stat.isDirectory() || stat.uid !== process.getuid()) return false; + if (create && stat.mode & 0o077) fs.chmodSync(folder, 0o700); + return create || !(stat.mode & 0o022); +} + +/** + * A hash of the size and mtime of the root's package.json and components.json and of every file + * in `dirs` (outside `node_modules` and `.git`), and the newest mtime among them. 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; + } + for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) { + 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 }; +} + +/** + * The detection kept on disk for this `key` (the version, 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. + */ +function keptDetection( + root: string, + key: string, + declared: Pick | undefined, + packages: PackageFinder, +): Detection | undefined { + try { + const { file, temp } = detectionFile(root); + if (temp && !ownFolder(path.dirname(file), false)) return undefined; + const kept = JSON.parse(fs.readFileSync(file, 'utf8')) as Partial; + if (kept.key !== key || !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 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. */ +function keepDetection(root: string, key: string, inputs: Inputs, detection: Detection): 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 stamped = inputsStamp(root, outermost); + if (!stamped || Date.now() - stamped.newest < SETTLED_MS) return; + const { file, temp } = detectionFile(root); + if (temp) { + if (!ownFolder(path.dirname(file), true)) return; + } else { + fs.mkdirSync(path.dirname(file), { recursive: true }); + } + const kept: KeptDetection = { + key, + stamp: stamped.stamp, + located: inputs.located, + dirs: outermost, + detection, + }; + // 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/test/hook-speed.test.ts b/test/hook-speed.test.ts new file mode 100644 index 00000000..43753770 --- /dev/null +++ b/test/hook-speed.test.ts @@ -0,0 +1,386 @@ +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 { detectProject, type DetectionStep } from '../src/detect.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 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 })) { + if (entry.isFile()) fs.utimesSync(path.join(entry.parentPath, entry.name), past, past); + } +} + +describe('detection kept on disk', () => { + let temp: string; + let saved: string | undefined; + beforeEach(() => { + saved = process.env.TMPDIR; + temp = fs.mkdtempSync(path.join(os.tmpdir(), 'onsystem-detect-test-')); + process.env.TMPDIR = temp; + }); + afterEach(() => { + if (saved === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = saved; + 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