diff --git a/.changeset/culori-types.md b/.changeset/culori-types.md new file mode 100644 index 00000000..b4c3a58a --- /dev/null +++ b/.changeset/culori-types.md @@ -0,0 +1,5 @@ +--- +'onsystem': patch +--- + +The published types no longer fail a TypeScript project that checks library types (`skipLibCheck: false`) with TS7016, "Could not find a declaration file for module 'culori'", as soon as it imports `onsystem`, as an `onsystem.config.ts` with `defineConfig` does. The color types the API uses come from `@types/culori`, which is now a dependency rather than a devDependency; with `skipLibCheck: true`, those types were read as `any`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bc1a8272..d818cd7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Bug reports with a minimal snippet and the `inspect` output are the most useful contribution: most fixes start from one. For a change in behaviour, open an issue first so we can agree on it before you write it. +Bug reports with a minimal snippet and the `inspect` output are the most useful contribution: most fixes start from one. For a change in behaviour, open an issue first so we can agree on it before you write it. Issues that make a good first change are labelled [good first issue](https://github.com/dgesteves/onsystem/labels/good%20first%20issue). ## New integrations @@ -17,6 +17,8 @@ corepack enable pnpm install ``` +Without Corepack, install pnpm 11 with npm (`npm install -g pnpm@11`), then run `pnpm install`. Until the first `pnpm build`, `pnpm install` warns that it failed to create a bin at `site/node_modules/.bin/onsystem`: the website depends on this package, whose `dist/cli.js` the build writes. The warning is harmless. + ## Checks CI runs these on every pull request, in this order: @@ -25,14 +27,17 @@ CI runs these on every pull request, in this order: pnpm lint # ESLint pnpm format:check # Prettier (pnpm format writes) pnpm typecheck # tsc --noEmit +pnpm --filter "./examples/*" typecheck # the examples, against this package's types pnpm build # tsdown, then schema.json from the config schema pnpm smoke # the built server over stdio, every tool, against examples/shadcn-demo -pnpm test # Vitest, on Node 22 and 24 (Linux), and on Windows and macOS +pnpm test # Vitest, on Node 22, 24 and 26 (Linux), and on Windows and macOS pnpm pack-test # npm pack, install into a new project, run it (Linux, Windows, macOS) ``` A pull request that touches `src/` also runs the [real-world corpus](#real-world-corpus), which is not a required check yet. +The Website job builds the site, which renders `docs/*.md` and data generated from the built library: after `pnpm build`, it runs `pnpm --filter site generate`, then the site's `test`, `typecheck`, `lint` and `build`. Run them when you change the docs or `site/`. To see a docs page as the website shows it, run `pnpm build`, then `pnpm --filter site dev`, which serves the site on http://localhost:3300. + `pnpm smoke` spawns `dist/cli.js` the way an MCP client does, so it catches what unit tests cannot: a broken build, a tool that fails over stdio, output that no longer parses. It connects twice, on the 2025 protocol and on 2026-07-28. Run it after `pnpm build`; add `--verbose` to see every response. CI also runs `check` on the demo's clean page (it must pass) and the Claude Code hook on its draft (it must block). `pnpm pack-test` (after `pnpm build`) tests the package as a user gets it: it packs the repository with `npm pack`, so the publish hooks run, installs the tarball into a new project in the system temp folder, and runs the installed `onsystem` bin (`--version`, `inspect`, `check`), an MCP stdio handshake with the installed server in both protocol eras, and the Claude Code hook, which picks up the project's own install. Then it installs the tarball with plain npm into a project already on ESLint 8.57.0, the oldest version the `eslint` peer range admits (npm stops the install when the range leaves the project's ESLint out), checks that the installed `onsystem/eslint` imports no `eslint`, and lints and fixes a page with that ESLint (`scripts/pack-test-eslint8.mjs`). It needs the npm registry for the package's dependencies. diff --git a/package.json b/package.json index d7e8db7b..fc6fb55b 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ }, "dependencies": { "@modelcontextprotocol/server": "^2.3.1", + "@types/culori": "^4.0.1", "culori": "^4.0.2", "postcss": "^8.5.30", "stemmer": "^2.0.1", @@ -110,7 +111,6 @@ "@eslint/js": "^10.0.1", "@modelcontextprotocol/client": "^2.3.1", "@modelcontextprotocol/sdk": "^1.32.1", - "@types/culori": "^4.0.1", "@types/node": "^24.19.2", "@vitest/coverage-istanbul": "^5.0.5", "ajv": "^8.20.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49dc0980..68ab6f1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@modelcontextprotocol/server': specifier: ^2.3.1 version: 2.3.1 + '@types/culori': + specifier: ^4.0.1 + version: 4.0.1 culori: specifier: ^4.0.2 version: 4.0.2 @@ -48,9 +51,6 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.32.1 version: 1.32.2(zod@4.6.6) - '@types/culori': - specifier: ^4.0.1 - version: 4.0.1 '@types/node': specifier: ^24.19.2 version: 24.19.2 diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 8456a83a..a05a8c65 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -246,6 +246,32 @@ describe('packages for other agents', () => { ]); const plugin = readJson('plugins/onsystem-cursor/.cursor-plugin/plugin.json'); expect(plugin).toMatchObject({ name: 'onsystem', version, logo: 'icon.svg' }); + // The properties of Cursor's manifest schema (github.com/cursor/plugins, + // schemas/plugin.schema.json), and no other: the schema sets additionalProperties to false. + const fields = [ + 'name', + 'displayName', + 'description', + 'version', + 'minClientVersions', + 'author', + 'publisher', + 'homepage', + 'repository', + 'license', + 'logo', + 'keywords', + 'category', + 'tags', + 'commands', + 'agents', + 'skills', + 'rules', + 'hooks', + 'variables', + 'mcpServers', + ]; + expect(Object.keys(plugin).filter((key) => !fields.includes(key))).toEqual([]); expect(plugin.name).toMatch(/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/); expect(fs.existsSync(path.join(CURSOR, 'icon.svg'))).toBe(true); const skill = frontMatter(path.join(CURSOR, SKILL)); diff --git a/test/published-types.test.ts b/test/published-types.test.ts new file mode 100644 index 00000000..7d7c2332 --- /dev/null +++ b/test/published-types.test.ts @@ -0,0 +1,78 @@ +import fs from 'node:fs'; +import { builtinModules } from 'node:module'; +import path from 'node:path'; + +import { build } from 'tsdown'; +import { beforeAll, describe, expect, it } from 'vitest'; + +// The type declarations the package publishes import types from other packages: an install has +// those types only when `dependencies` (or a peer) brings them. `Oklch` from culori, which ships +// no types of its own, came from @types/culori, a devDependency, so a project with skipLibCheck +// off failed to typecheck on `import { defineConfig } from 'onsystem'` (TS7016), and with it on +// read those types as `any`. Built as it ships, into a folder of its own. +const REPO = path.resolve(import.meta.dirname, '..'); +const OUT = path.join(REPO, 'node_modules/.cache/onsystem-published-types-test'); +const pkg = JSON.parse(fs.readFileSync(path.join(REPO, 'package.json'), 'utf8')) as { + dependencies: Record; + peerDependencies: Record; +}; + +let declarations: Map; + +beforeAll(async () => { + await build({ + cwd: REPO, + config: false, + entry: { index: 'src/index.ts', eslint: 'src/eslint/index.ts' }, + outDir: OUT, + format: 'esm', + platform: 'node', + target: 'node22.18', + fixedExtension: false, + dts: true, + publint: false, + clean: true, + logLevel: 'silent', + }); + declarations = new Map( + fs + .readdirSync(OUT) + .filter((file) => file.endsWith('.d.ts')) + .map((file) => [file, fs.readFileSync(path.join(OUT, file), 'utf8')]), + ); +}, 120_000); + +/** The package an import specifier names: `culori`, `@modelcontextprotocol/server`. */ +function packageOf(specifier: string): string { + const parts = specifier.split('/'); + return (specifier.startsWith('@') ? parts.slice(0, 2) : parts.slice(0, 1)).join('/'); +} + +/** Whether the installed package ships its own type declarations. */ +function shipsTypes(name: string): boolean { + const json = fs.readFileSync(path.join(REPO, 'node_modules', name, 'package.json'), 'utf8'); + const { types, typings } = JSON.parse(json) as { types?: string; typings?: string }; + return Boolean(types ?? typings) || /"types"\s*:/.test(json); +} + +describe('the published type declarations', () => { + it('import types only from packages an install brings with their types', () => { + const imported = new Set(); + for (const text of declarations.values()) { + for (const match of text.matchAll(/(?:\bfrom\s+|\bimport\(\s*)["']([^"'.][^"']*)["']/g)) { + const specifier = match[1] ?? ''; + if (specifier.startsWith('node:') || builtinModules.includes(specifier)) continue; + imported.add(packageOf(specifier)); + } + } + // The build read the public entry points: the API's types come from these. + expect(imported).toContain('culori'); + const installed = { ...pkg.dependencies, ...pkg.peerDependencies }; + for (const name of imported) { + expect(installed, `${name} is imported by the declarations`).toHaveProperty([name]); + if (shipsTypes(name)) continue; + const types = `@types/${name.replace(/^@/, '').replace('/', '__')}`; + expect(pkg.dependencies, `${name} ships no types: ${types}`).toHaveProperty([types]); + } + }); +});