diff --git a/CLAUDE.md b/CLAUDE.md index 3afa8ae..694537f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,9 @@ diff, prune, and validate all derive from it. - `src/cli.ts` — commander CLI: `init`, `build`, `validate`, `diff`, `prune`, `clean`, `docs`. - `src/schema.ts` — **zod schemas are the source of truth for config types**; - the public types are derived with `z.infer`. Edit schemas here, not `types.ts`. + authoring types use `z.input`, and parsed config uses `z.infer`. The build + materializes these aliases as structural declarations without a Zod type + dependency. Edit schemas here, not `types.ts`. - `src/types.ts` — non-config types; re-exports the config types from `schema.ts`. - `src/components.ts` — `componentDirs` + `staticFiles` (shared by render/config). - `src/config.ts` — `loadConfig` (jiti loads `pluginpack.config.ts`) and legacy diff --git a/README.md b/README.md index f804541..18ea525 100644 --- a/README.md +++ b/README.md @@ -613,6 +613,8 @@ rest of the artifact. `pluginpack.config.ts` exports a config object (wrap it in `defineConfig` for types). `src/schema.ts` is the source of truth; this table enumerates every field. Paths marked _safe relative_ reject absolute paths and `..` escapes. +Keys stripped by config parsing produce a warning that names the config file and the dotted field path, once per file and key per process. Unknown keys in `plugin.pluginpack.json` (both canonical and legacy sources) and `mcp/pluginpack.json` produce manifest warnings. Fix the spelling or remove the key. The `manifest` and `entry` passthrough objects preserve arbitrary keys without warnings. Unknown package client names still fail validation. + **Top level** | Field | Type | Required | Meaning | @@ -727,8 +729,11 @@ Option objects: The result and config types (`Artifact`, `DiffResult`/`DiffEntry`, `ValidationResult`/`ValidationIssue`, `CleanupResult`/`CleanupEntry`, `ResolvedProject`, `PluginpackConfig`, `TargetConfig`, `TargetName`, `Citation`, `InstallSnippet`, …) are all exported for use in TypeScript. +`PluginpackConfig` describes authoring input. `defineConfig` is an identity helper; it does not parse or apply defaults. `loadConfig` returns `ResolvedProject.config` as `ResolvedPluginpackConfig`, the validated schema output. The declaration build derives structural types from those same schemas, so consumers do not need Zod types or ambient Node types. `Artifact.files` values are `string | Uint8Array`, including Node buffers. Decode byte values with `Buffer.from(value).toString("utf8")` or `new TextDecoder().decode(value)`. + A few things worth knowing about this surface before depending on it: +- **Source-discovery state is unstable.** `ResolvedProject.sourceRoot`, `plugins`, `partials`, and `source` expose internal build state. They are not supported extension points, and their shapes may change. - **The target set is closed.** `TargetName` is `"claude" | "cursor" | "antigravity" | "copilot" | "codex" | "agent-plugins"` today, with no public API for registering another target — adding one means a PR to this repo (see `PluginTargetDefinition` in `src/targets/types.ts`, not exported). There is no supported third-party target-extension mechanism. - **The package is ESM-only.** `package.json`'s `exports` map has no `require` condition; a CommonJS consumer needs dynamic `import()`. This is a deliberate choice, not a tsup default left unexamined. - **`Artifact.files` and `ResolvedProject.plugins` are `Map`s, not plain objects.** `JSON.stringify()` on either silently produces `{}` — iterate with `for...of`/`Object.fromEntries()` instead of serializing directly if you need to log or transport a result. diff --git a/REVIEW.md b/REVIEW.md index de37544..7b191b9 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -4,7 +4,7 @@ Rules for reviewing changes in this repository. People and review agents both fo ## Always check -- **Schemas are the source of truth.** Config types come from the zod schemas in `src/schema.ts` through `z.infer`. A config change edits the schema, not `src/types.ts`. +- **Schemas are the source of truth.** Authoring config types come from the zod schemas in `src/schema.ts` through `z.input`. Parsed config types use `z.infer` (`ResolvedPluginpackConfig`). A config change edits the schema, not `src/types.ts`. The declaration build materializes these aliases as structural types; do not maintain a separate handwritten config shape. - **Targets come from evidence.** A new target, or a change to a target's layout or manifest, cites the host's official docs or a real plugin it was checked against. Guessed formats are a blocker. - **The artifact is the seam.** Build, dry run, diff, prune and validate all derive from the in-memory `Artifact`. A change that writes to disk outside it, or makes one of those commands disagree with another, is a blocker. - **Destructive paths stay guarded.** `prune` and `clean` remove only managed files. A change near them needs a test that proves unmanaged and source files survive. diff --git a/package.json b/package.json index f003fac..3316b40 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ ], "scripts": { "audit": "npm audit --omit=dev", - "build": "tsup", + "build": "tsup && node scripts/materialize-config-types.mjs", "check": "npm run test:all", "dev": "tsx src/cli.ts", "docs": "npm run docs:check", diff --git a/scripts/materialize-config-types.mjs b/scripts/materialize-config-types.mjs new file mode 100644 index 0000000..23367ea --- /dev/null +++ b/scripts/materialize-config-types.mjs @@ -0,0 +1,193 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import ts from "typescript"; + +// Keep the schemas as the only config model. Ask the TypeScript checker for +// their evaluated input/output types instead of shipping Zod's implementation +// types (or maintaining a second, handwritten set of config interfaces). +const configPath = path.resolve("tsconfig.json"); +const config = ts.readConfigFile(configPath, ts.sys.readFile); +if (config.error) { + throw new Error( + ts.flattenDiagnosticMessageText(config.error.messageText, "\n"), + ); +} +const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + process.cwd(), +); +const schemaPath = path.resolve("src/schema.ts"); +const program = ts.createProgram([schemaPath], parsed.options); +const checker = program.getTypeChecker(); +const schema = program.getSourceFile(schemaPath); +if (!schema) throw new Error(`Cannot read schema types from ${schemaPath}`); + +const aliases = new Map(); +const schemaValues = new Set(); +for (const node of schema.statements) { + if (ts.isTypeAliasDeclaration(node) && schemaDerivation(node.type)) { + const type = checker.getTypeAtLocation(node); + const structuralType = checker.typeToTypeNode( + type, + undefined, + ts.NodeBuilderFlags.NoTruncation | ts.NodeBuilderFlags.InTypeAlias, + ); + if (!structuralType) { + throw new Error( + `Cannot materialize schema-derived type ${node.name.text}`, + ); + } + aliases.set(node.name.text, { + structuralType, + derivation: schemaDerivation(node.type), + }); + } else if (ts.isVariableStatement(node)) { + for (const declaration of node.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) + schemaValues.add(declaration.name.text); + } + } +} + +const declarationPath = path.resolve("dist/index.d.ts"); +const declaration = ts.createSourceFile( + declarationPath, + await readFile(declarationPath, "utf8"), + ts.ScriptTarget.Latest, + true, +); +const printer = ts.createPrinter(); +const namedStructures = new Map(); +const statements = declaration.statements.flatMap((node) => { + if (ts.isImportDeclaration(node) && node.moduleSpecifier.text === "zod") { + return []; + } + if ( + ts.isVariableStatement(node) && + node.declarationList.declarations.every( + (item) => ts.isIdentifier(item.name) && schemaValues.has(item.name.text), + ) + ) { + // These schema values were reachable only through the aliases below. + return []; + } + if (ts.isTypeAliasDeclaration(node) && aliases.has(node.name.text)) { + const alias = aliases.get(node.name.text); + const derivation = schemaDerivation(node.type); + if ( + !derivation || + derivation.mode !== alias.derivation.mode || + derivation.schema !== alias.derivation.schema + ) { + throw new Error( + `${declarationPath}: ${node.name.text} collides with a schema-derived alias. Rename the conflicting type or update declaration generation before publishing.`, + ); + } + const transformed = ts.transform(alias.structuralType, [ + (context) => { + const visit = (child) => { + if (ts.isTypeLiteralNode(child)) { + const name = namedStructures.get(structureKey(child)); + if (name) return ts.factory.createTypeReferenceNode(name); + const visited = ts.visitEachChild(child, visit, context); + // Checker-created literals carry single-line emit flags. Fresh + // literals use the printer's readable, multiline form instead. + return ts.factory.createTypeLiteralNode(visited.members); + } + return ts.visitEachChild(child, visit, context); + }; + return (root) => ts.visitNode(root, visit); + }, + ]); + const materialized = transformed.transformed[0]; + transformed.dispose(); + // Only reference aliases already emitted, so generated references cannot + // create cycles or name a schema alias absent from the public bundle. + if (ts.isTypeLiteralNode(alias.structuralType)) { + const key = structureKey(alias.structuralType); + if (!namedStructures.has(key)) namedStructures.set(key, node.name.text); + } + return [ + ts.factory.updateTypeAliasDeclaration( + node, + node.modifiers, + node.name, + node.typeParameters, + materialized, + ), + ]; + } + if ( + ts.isExportDeclaration(node) && + node.exportClause && + ts.isNamedExports(node.exportClause) + ) { + for (const exported of node.exportClause.elements) { + if (schemaValues.has((exported.propertyName ?? exported.name).text)) { + throw new Error( + "Schema values are not public API. Update declaration generation before exporting one.", + ); + } + } + } + return [node]; +}); +const result = printer.printFile( + ts.factory.updateSourceFile(declaration, statements), +); +if (/\bzod\b|\bz\.(?:core|input|infer|output)\b/.test(result)) { + throw new Error( + `${declarationPath} still references Zod; update schema type materialization before publishing.`, + ); +} +// Validate the exact publishable file, not the source program with ambient +// Node types and skipLibCheck enabled. A textual Zod guard cannot catch an +// unqualified implementation helper such as $brand. +const declarationOptions = { + ...parsed.options, + types: [], + skipLibCheck: false, + noEmit: true, +}; +const host = ts.createCompilerHost(declarationOptions); +const read = host.readFile.bind(host); +host.readFile = (file) => (file === declarationPath ? result : read(file)); +const declarationProgram = ts.createProgram( + [declarationPath], + declarationOptions, + host, +); +const diagnostics = ts.getPreEmitDiagnostics(declarationProgram); +if (diagnostics.length > 0) { + throw new Error( + `Invalid declarations in ${declarationPath}; update schema type materialization before publishing.\n` + + ts.formatDiagnostics(diagnostics, { + getCanonicalFileName: (file) => file, + getCurrentDirectory: () => process.cwd(), + getNewLine: () => "\n", + }), + ); +} +await writeFile(declarationPath, result); + +function structureKey(node) { + return printer.printNode(ts.EmitHint.Unspecified, node, schema); +} + +/** Only aliases directly derived from a schema are eligible for replacement. */ +function schemaDerivation(node) { + if ( + !ts.isTypeReferenceNode(node) || + !ts.isQualifiedName(node.typeName) || + !ts.isIdentifier(node.typeName.left) || + node.typeName.left.text !== "z" || + !["input", "infer", "output"].includes(node.typeName.right.text) || + node.typeArguments?.length !== 1 + ) + return undefined; + const argument = node.typeArguments[0]; + if (!ts.isTypeQueryNode(argument) || !ts.isIdentifier(argument.exprName)) + return undefined; + return { mode: node.typeName.right.text, schema: argument.exprName.text }; +} diff --git a/src/config-diagnostics.ts b/src/config-diagnostics.ts new file mode 100644 index 0000000..a97a7b9 --- /dev/null +++ b/src/config-diagnostics.ts @@ -0,0 +1,36 @@ +// Repeated project loads in prune/diff must not repeat warnings. Track the +// file and field, not module identity: config loaders may disable their cache. +const warnedKeys = new Set(); + +export function warnOnStrippedKeys( + input: unknown, + output: unknown, + filePath: string, + kind: "config" | "manifest" = "config", +): void { + for (const key of strippedKeys(input, output)) { + const diagnostic = JSON.stringify([filePath, kind, key]); + if (warnedKeys.has(diagnostic)) continue; + warnedKeys.add(diagnostic); + console.warn( + `Unknown ${kind} key "${key}" in ${filePath} was ignored. Remove it or use a supported ${kind} field.`, + ); + } +} + +/** Compare parsed data, not a second schema: record passthroughs retain keys. */ +function strippedKeys(input: unknown, output: unknown, prefix = ""): string[] { + if (input === output || !isObject(input) || !isObject(output)) { + return []; + } + return Object.keys(input).flatMap((key) => { + const dotted = prefix ? `${prefix}.${key}` : key; + return Object.hasOwn(output, key) + ? strippedKeys(input[key], output[key], dotted) + : [dotted]; + }); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/config.ts b/src/config.ts index 07006f8..37a9048 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url"; import { createJiti } from "jiti"; import { z } from "zod"; import { componentDirs } from "./components.js"; +import { warnOnStrippedKeys } from "./config-diagnostics.js"; import { exists, isInside, physicalKey } from "./fs.js"; import { loadPartials } from "./partials.js"; import { configSchema, sourcePluginManifestSchema } from "./schema.js"; @@ -11,6 +12,7 @@ import { createFilesystemSourceProvider } from "./source.js"; import type { ResolvedProject, PluginpackConfig, + ResolvedPluginpackConfig, ResolvedProjectConfig, SourcePlugin, SourcePluginManifest, @@ -49,7 +51,7 @@ export async function loadConfig( */ async function loadPartialsIfConfigured( rootDir: string, - config: PluginpackConfig, + config: ResolvedPluginpackConfig, ): Promise> { if (!config.source?.partials) { return new Map(); @@ -77,11 +79,7 @@ export async function loadProjectConfig( moduleCache: false, }); const loaded = await jiti.import(resolvedConfigPath, { default: true }); - const config = parseWithContext( - configSchema, - loaded, - resolvedConfigPath, - ) as PluginpackConfig; + const config = parseWithContext(configSchema, loaded, resolvedConfigPath); const rootDir = path.dirname(resolvedConfigPath); return { rootDir, @@ -92,7 +90,7 @@ export async function loadProjectConfig( async function addRootSkillsPlugin( rootDir: string, - config: PluginpackConfig, + config: ResolvedPluginpackConfig, plugins: Map, ): Promise { if (!config.source?.skills) { @@ -400,6 +398,7 @@ async function readSourceManifest( sourcePluginManifestSchema, JSON.parse(raw), filePath, + "manifest", ) as SourcePluginManifest; } catch (error) { if (error instanceof SyntaxError) { @@ -411,17 +410,19 @@ async function readSourceManifest( } } -function parseWithContext( - schema: z.ZodType, +function parseWithContext( + schema: S, value: unknown, context: string, -): unknown { + kind: "config" | "manifest" = "config", +): z.output { const parsed = schema.safeParse(value); if (parsed.success) { + warnOnStrippedKeys(value, parsed.data, context, kind); return parsed.data; } const details = parsed.error.issues .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) .join("; "); - throw new Error(`Invalid pluginpack config in ${context}: ${details}`); + throw new Error(`Invalid pluginpack ${kind} in ${context}: ${details}`); } diff --git a/src/index.ts b/src/index.ts index 9c51b51..ed247a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ export type { FileValue, Metadata, PluginpackConfig, + ResolvedPluginpackConfig, ResolvedProject, SourcePlugin, TargetConfig, diff --git a/src/partials.ts b/src/partials.ts index 538238d..fdb45e4 100644 --- a/src/partials.ts +++ b/src/partials.ts @@ -169,7 +169,8 @@ export function resolvePartials( if (!substitutionRunsOn(relativePath)) { return value; } - const text = typeof value === "string" ? value : value.toString("utf8"); + const text = + typeof value === "string" ? value : Buffer.from(value).toString("utf8"); if (!text.includes(PARTIAL_OPENER)) { return value; } @@ -385,6 +386,7 @@ export function findPartialTag(value: FileValue): string | undefined { if (typeof value !== "string" && value.includes(0)) { return undefined; } - const text = typeof value === "string" ? value : value.toString("utf8"); + const text = + typeof value === "string" ? value : Buffer.from(value).toString("utf8"); return PARTIAL_TAG.exec(text)?.[0]; } diff --git a/src/schema.ts b/src/schema.ts index e6625aa..35dd606 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -199,14 +199,9 @@ const targetSchema = z.object({ /** * The root `pluginpack.config.ts` schema. * - * Deliberately not `.strict()`: zod's default `z.object()` silently drops - * unknown keys rather than erroring, which is the right tradeoff pre-1.0 — it - * lets a future minor version add an optional config field without that - * being a breaking change for existing configs. The cost is a typo'd key - * failing silently instead of loudly; that's an accepted tradeoff, not an - * oversight. Adding `.strict()` later would itself be a breaking change for - * anyone currently typo-ing successfully, so this needs to be a deliberate - * decision made once, not toggled casually. + * Keep non-strict parsing: unknown keys are stripped, not rejected. The + * config loader warns with their paths so typos do not fail silently. + * Manifest and entry records intentionally preserve arbitrary keys. */ const configSchema = z .object({ @@ -359,12 +354,16 @@ const mcpManifestSchema = z.object({ export { configSchema, mcpManifestSchema, sourcePluginManifestSchema }; -export type Author = z.infer; -export type Metadata = z.infer; -export type SourceConfig = z.infer; -export type EmittedPluginConfig = z.infer; -export type UpdateCheckConfig = z.infer; -export type TargetConfig = z.infer; -export type PluginpackConfig = z.infer; +// Authoring types describe schema input, so adding a default does not make +// that field required in existing configs. Parsed project state uses output. +export type Author = z.input; +export type Metadata = z.input; +export type SourceConfig = z.input; +export type EmittedPluginConfig = z.input; +export type UpdateCheckConfig = z.input; +export type TargetConfig = z.input; +export type PluginpackConfig = z.input; +/** The validated config, with schema defaults and transforms applied. */ +export type ResolvedPluginpackConfig = z.infer; export type SourcePluginManifest = z.infer; export type McpManifest = z.infer; diff --git a/src/source.ts b/src/source.ts index b2f139f..82e023b 100644 --- a/src/source.ts +++ b/src/source.ts @@ -1,6 +1,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { componentDirs, staticFiles } from "./components.js"; +import { warnOnStrippedKeys } from "./config-diagnostics.js"; import { exists, isSafeRelativePath, toPosix, walkFiles } from "./fs.js"; import { mcpManifestSchema, sourcePluginManifestSchema } from "./schema.js"; import type { @@ -236,6 +237,7 @@ async function readRequiredJson( .join("; "); throw new Error(`Invalid pluginpack JSON in ${file}: ${details}`); } + warnOnStrippedKeys(parsed, result.data, file, "manifest"); return result.data as T; } diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 64e0c1d..6871486 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -377,12 +377,14 @@ function artifact( outDir: string, files: Map, ): Artifact { - const managedPaths = [...files.keys()].sort(); + const orderedFiles = new Map( + [...files.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ); return { target, outDir, - files: new Map([...files.entries()].sort(([a], [b]) => a.localeCompare(b))), - managedPaths, + files: orderedFiles, + managedPaths: [...orderedFiles.keys()], }; } diff --git a/src/types.ts b/src/types.ts index 384339f..37c16af 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ import type { EmittedPluginConfig, TargetConfig, PluginpackConfig, + ResolvedPluginpackConfig, SourcePluginManifest, McpManifest, UpdateCheckConfig, @@ -17,6 +18,7 @@ export type { EmittedPluginConfig, TargetConfig, PluginpackConfig, + ResolvedPluginpackConfig, SourcePluginManifest, McpManifest, UpdateCheckConfig, @@ -35,13 +37,10 @@ export type SourcePlugin = { }; /** - * The one surface pluginpack uses to acquire source. A filesystem provider - * backs it today; an API-backed provider (e.g. a remote skills API) can - * implement the same two methods without touching the emit/validate/diff - * pipeline. Deliberately not exported from `src/index.ts`: nothing in - * `build()`/`loadConfig()` accepts an override yet, so exporting the type - * would commit to public surface no one can actually use. Wire a `source` - * override into `BuildOptions` before re-exporting this. + * Internal source access used by the build pipeline. Its structural shape + * is reachable through `ResolvedProject.source`, but the public API does + * not accept a provider override. + * @unstable Not a supported extension point; this interface may change. */ export interface SourceProvider { readPluginFiles( @@ -64,10 +63,14 @@ export type AuthoredPlugin = { export type ResolvedProject = { rootDir: string; configPath: string; - config: PluginpackConfig; + config: ResolvedPluginpackConfig; + /** @unstable Internal source-discovery state, not an extension point. */ sourceRoot: string; + /** @unstable Internal source-discovery state, not an extension point. */ plugins: Map; + /** @unstable Internal rendering state, not an extension point. */ partials: Map; + /** @unstable Internal source access; provider overrides are not supported. */ source: SourceProvider; }; @@ -75,10 +78,10 @@ export type ResolvedProject = { export type ResolvedProjectConfig = { rootDir: string; configPath: string; - config: PluginpackConfig; + config: ResolvedPluginpackConfig; }; -export type FileValue = string | Buffer; +export type FileValue = string | Uint8Array; /** One target's build output: its files, and which of them are managed by pluginpack. */ export type Artifact = { diff --git a/src/update-check.ts b/src/update-check.ts index bc39b64..dac7aab 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -241,7 +241,7 @@ export function applyUpdateCheck( ? undefined : typeof existingValue === "string" ? existingValue - : existingValue.toString("utf8"); + : Buffer.from(existingValue).toString("utf8"); let merged: string; try { merged = FORMATS[options.format].mergeHooks(existing); diff --git a/tests/config-contract.test.ts b/tests/config-contract.test.ts new file mode 100644 index 0000000..261a892 --- /dev/null +++ b/tests/config-contract.test.ts @@ -0,0 +1,270 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import ts from "typescript"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { build, diffTarget, loadConfig, prune } from "../src/index.js"; +import { loadProjectConfig } from "../src/config.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + temporaryDirectories + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); +}); + +async function configProject(config: unknown): Promise { + const root = await mkdtemp( + path.join(os.tmpdir(), "pluginpack-config-contract-"), + ); + temporaryDirectories.push(root); + await writeFile( + path.join(root, "pluginpack.config.mjs"), + `export default ${JSON.stringify(config)};\n`, + ); + return root; +} + +describe("config contract", () => { + it("keeps authoring input optional when the schema gains a default", async () => { + const schemaPath = path.resolve("src/schema.ts"); + const original = await readFile(schemaPath, "utf8"); + // Mutate only the compiler's virtual source, not the real schema. This + // models an additive minor release and exercises the real defineConfig. + const changed = original.replace( + "version: z.string().min(1),", + 'version: z.string().min(1), futureDefault: z.string().default("value"),', + ); + expect(changed).not.toBe(original); + const consumer = path.resolve("tests/virtual-default-consumer.mts"); + const code = ` +import { defineConfig } from "../src/config.js"; +import { configSchema } from "../src/schema.js"; +import type { PluginpackConfig, ResolvedPluginpackConfig } from "../src/index.js"; +const beforeDefault = { name: "demo", version: "1.0.0", targets: {} }; +const authored: PluginpackConfig = beforeDefault; +defineConfig(authored); +defineConfig(beforeDefault); +const resolved: ResolvedPluginpackConfig = configSchema.parse(beforeDefault); +const defaultIsPresent: string = resolved.futureDefault; +// @ts-expect-error A parsed config must contain the defaulted output field. +const invalidResolved: ResolvedPluginpackConfig = beforeDefault; +void defaultIsPresent; void invalidResolved; +`; + const config = ts.readConfigFile( + path.resolve("tsconfig.json"), + ts.sys.readFile, + ); + const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + process.cwd(), + ); + const host = ts.createCompilerHost(parsed.options); + const read = host.readFile.bind(host); + host.readFile = (file) => + file === schemaPath ? changed : file === consumer ? code : read(file); + const program = ts.createProgram( + [consumer], + { ...parsed.options, noEmit: true }, + host, + ); + expect( + ts + .getPreEmitDiagnostics(program) + // This probe checks authoring and schema types. The full typecheck + // separately checks runtime implementations reached by src/index.ts. + .filter( + (diagnostic) => + !diagnostic.file || + [ + consumer, + schemaPath, + path.resolve("src/config.ts"), + path.resolve("src/types.ts"), + ].includes(diagnostic.file.fileName), + ) + .map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + ), + ).toEqual([]); + }); + + it("warns with the file and dotted paths for stripped keys, including nested records", async () => { + const root = await configProject({ + name: "demo", + version: "1.0.0", + targetss: {}, + targets: { + codxe: { outDir: "unused", plugins: {} }, + cursor: { + outDir: "out", + plugins: { + demo: { source: "shared/demo", component: ["skills"] }, + }, + }, + }, + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const loaded = await loadProjectConfig(root); + expect(loaded.config.targets.cursor?.plugins.demo).not.toHaveProperty( + "component", + ); + expect(warn.mock.calls.map(([message]) => message)).toEqual([ + expect.stringContaining("targetss"), + expect.stringContaining("targets.codxe"), + expect.stringContaining("targets.cursor.plugins.demo.component"), + ]); + for (const [message] of warn.mock.calls) { + expect(message).toContain(path.join(root, "pluginpack.config.mjs")); + expect(message).toMatch(/ignored/i); + expect(message).toMatch(/remove|supported/i); + } + }); + + it("warns on client setting typos but not manifest or entry passthrough data", async () => { + const root = await configProject({ + name: "demo", + version: "1.0.0", + targets: { + cursor: { + outDir: "cursor", + manifest: { custom: { anything: true } }, + plugins: { + demo: { + source: "shared/demo", + manifest: { custom: true }, + entry: { policy: { future: true } }, + }, + }, + }, + "agent-plugins": { + outDir: "packages", + clients: { + copilot: { manfiest: {}, manifest: { custom: { anything: true } } }, + }, + plugins: { + demo: { + source: "shared/demo", + clients: { copilot: { entry: { custom: { anything: true } } } }, + }, + }, + }, + }, + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const loaded = await loadProjectConfig(root); + expect(loaded.config.targets.cursor?.manifest).toEqual({ + custom: { anything: true }, + }); + expect(warn.mock.calls).toEqual([ + [ + expect.stringContaining( + "targets.agent-plugins.clients.copilot.manfiest", + ), + ], + ]); + }); + + it.each(["prune", "diff"] as const)( + "%s warns once per config path and key even when the config loads twice", + async (command) => { + const root = await configProject({ + name: "demo", + version: "1.0.0", + versoin: "typo", + targets: { + cursor: { + outDir: "out", + plugins: { demo: { source: "shared/demo", include: [] } }, + }, + }, + }); + await mkdir(path.join(root, "shared/demo"), { recursive: true }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + if (command === "prune") { + await prune({ cwd: root, dryRun: true }); + } else { + await diffTarget({ cwd: root, target: "cursor", against: "out" }); + } + // A fresh loader must not repeat the diagnostic, even without jiti caching. + await loadProjectConfig(root); + expect(warn.mock.calls).toEqual([ + [expect.stringContaining('Unknown config key "versoin"')], + ]); + expect(warn.mock.calls[0][0]).toContain( + path.join(root, "pluginpack.config.mjs"), + ); + }, + ); + + it.each(["legacy", "canonical"] as const)( + "labels unknown keys in %s source manifests as manifest warnings", + async (layout) => { + const sourceDir = layout === "legacy" ? "plugins/demo" : "shared/demo"; + const root = await configProject({ + name: "demo", + version: "1.0.0", + targets: + layout === "legacy" + ? {} + : { + cursor: { + outDir: "out", + plugins: { demo: { source: sourceDir, include: [] } }, + }, + }, + }); + await mkdir(path.join(root, sourceDir), { recursive: true }); + const manifestPath = path.join(root, sourceDir, "plugin.pluginpack.json"); + await writeFile( + manifestPath, + JSON.stringify({ + name: "demo", + descripton: "typo", + author: { name: "Demo", emali: "typo" }, + mcpServers: { future: { custom: true } }, + }), + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + for (let i = 0; i < 2; i++) { + if (layout === "legacy") await loadConfig(root); + else await build({ cwd: root, dryRun: true }); + } + expect(warn.mock.calls).toEqual([ + [expect.stringContaining('Unknown manifest key "descripton"')], + [expect.stringContaining('Unknown manifest key "author.emali"')], + ]); + for (const [message] of warn.mock.calls) { + expect(message).toContain(manifestPath); + expect(message).toContain("supported manifest field"); + } + }, + ); + + it("uses the same code-unit ordering for artifact files and managed paths", async () => { + const plugins = Object.fromEntries( + ["a-b", "A-tool", "_shared"].map((name) => [ + name, + { source: "shared/demo", include: [] }, + ]), + ); + const root = await configProject({ + name: "demo", + version: "1.0.0", + targets: { cursor: { outDir: "out", plugins } }, + }); + await mkdir(path.join(root, "shared/demo"), { recursive: true }); + await writeFile( + path.join(root, "shared/demo/plugin.pluginpack.json"), + "{}", + ); + const [artifact] = await build({ cwd: root, dryRun: true }); + expect([...artifact.files.keys()]).toEqual(artifact.managedPaths); + expect(artifact.managedPaths).toEqual([...artifact.managedPaths].sort()); + }); +}); diff --git a/tests/fixtures/public-api/consumer.txt b/tests/fixtures/public-api/consumer.txt new file mode 100644 index 0000000..5e14c98 --- /dev/null +++ b/tests/fixtures/public-api/consumer.txt @@ -0,0 +1,38 @@ +import { defineConfig } from "@gleanwork/pluginpack"; +import type { + Artifact, + Author, + BuildOptions, + Citation, + CleanupEntry, + CleanupResult, + DiffEntry, + DiffResult, + EmittedPluginConfig, + FileValue, + InstallParams, + InstallSnippet, + Metadata, + PluginpackConfig, + ResolvedPluginpackConfig, + ResolvedProject, + SourcePlugin, + TargetConfig, + TargetName, + ValidationIssue, + ValidationResult, +} from "@gleanwork/pluginpack"; + +// A tuple keeps every imported type in the checked consumer surface. +export type PublicTypes = [ + Artifact, Author, BuildOptions, Citation, CleanupEntry, CleanupResult, + DiffEntry, DiffResult, EmittedPluginConfig, FileValue, InstallParams, + InstallSnippet, Metadata, PluginpackConfig, ResolvedPluginpackConfig, + ResolvedProject, SourcePlugin, TargetConfig, TargetName, ValidationIssue, + ValidationResult, +]; + +const config: PluginpackConfig = { name: "consumer", version: "1.0.0", targets: {} }; +defineConfig(config); +export const binaryFile: FileValue = new Uint8Array([65, 66]); +export const files: Artifact["files"] = new Map([["asset.bin", binaryFile]]); diff --git a/tests/materialize-config-types.test.ts b/tests/materialize-config-types.test.ts new file mode 100644 index 0000000..d6a0bc7 --- /dev/null +++ b/tests/materialize-config-types.test.ts @@ -0,0 +1,117 @@ +import { spawnSync } from "node:child_process"; +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +async function materialize(schema: string, declaration: string) { + const root = await mkdtemp(path.join(os.tmpdir(), "pluginpack-materialize-")); + roots.push(root); + await mkdir(path.join(root, "src")); + await mkdir(path.join(root, "dist")); + await symlink( + path.resolve("node_modules"), + path.join(root, "node_modules"), + "dir", + ); + await writeFile(path.join(root, "package.json"), '{"type":"module"}'); + await writeFile( + path.join(root, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + strict: true, + skipLibCheck: true, + types: [], + }, + }), + ); + await writeFile(path.join(root, "src/schema.ts"), schema); + const declarationPath = path.join(root, "dist/index.d.ts"); + await writeFile(declarationPath, declaration); + const result = spawnSync( + process.execPath, + [path.resolve("scripts/materialize-config-types.mjs")], + { cwd: root, encoding: "utf8" }, + ); + return { ...result, declaration: await readFile(declarationPath, "utf8") }; +} + +describe("config declaration build", () => { + it("prints multiline config types and reuses matching materialized aliases", async () => { + const result = await materialize( + `import { z } from "zod"; +const authorSchema = z.object({ name: z.string(), email: z.string().optional() }); +const targetSchema = z.object({ author: authorSchema, outDir: z.string() }); +const configSchema = z.object({ targets: z.object({ claude: targetSchema.optional(), cursor: targetSchema.optional() }) }); +export type Author = z.input; +export type TargetConfig = z.input; +export type PluginpackConfig = z.input; +export type ResolvedPluginpackConfig = z.infer;`, + `import { z } from "zod"; +declare const authorSchema: z.ZodType; +declare const targetSchema: z.ZodType; +declare const configSchema: z.ZodType; +type Author = z.input; +type TargetConfig = z.input; +type PluginpackConfig = z.input; +type ResolvedPluginpackConfig = z.infer; +export type { Author, TargetConfig, PluginpackConfig, ResolvedPluginpackConfig };`, + ); + expect(result.status, result.stderr).toBe(0); + expect(result.declaration).toMatch(/type Author = \{\r?\n/); + expect(result.declaration).toContain("author: Author;"); + expect(result.declaration).toMatch(/claude\?: TargetConfig \| undefined/); + expect(result.declaration).toMatch(/cursor\?: TargetConfig \| undefined/); + expect(result.declaration).toContain( + "type ResolvedPluginpackConfig = PluginpackConfig;", + ); + }); + + it("typechecks generated declarations and rejects unqualified Zod brand types", async () => { + const result = await materialize( + 'import { z } from "zod"; const handleSchema = z.string().brand<"Handle">(); export type Handle = z.infer;', + 'import { z } from "zod"; declare const handleSchema: z.ZodString; type Handle = z.infer; export type { Handle };', + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("$brand"); + expect(result.stderr).toContain("Cannot find name"); + expect(result.stderr).toContain("dist/index.d.ts"); + }); + + it("rejects ambient Node types without relying on consumer tests", async () => { + const result = await materialize( + "export {};", + "export type Bytes = Buffer;", + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Buffer"); + }); + + it("rejects a schema alias name colliding with an unrelated public type", async () => { + const result = await materialize( + 'import { z } from "zod"; const authorSchema = z.object({ name: z.string() }); export type InstallParams = z.input;', + "export type InstallParams = { target: string; repository: string };", + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("InstallParams"); + expect(result.stderr).toContain("schema-derived"); + expect(result.declaration).toContain("target: string"); + }); +}); diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts new file mode 100644 index 0000000..2ef39e7 --- /dev/null +++ b/tests/public-api.test.ts @@ -0,0 +1,222 @@ +import { Buffer } from "node:buffer"; +import { + cp, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import ts from "typescript"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { FileValue } from "../src/index.js"; + +const expectedRuntimeExports = [ + "build", + "buildInstallSnippet", + "clean", + "defineConfig", + "diffTarget", + "getInstallSnippetCitation", + "getSupportedInstallTargets", + "getUnsupportedInstallTargets", + "loadConfig", + "prune", + "validateOutput", +]; + +describe("public API contract", () => { + it("pins the source and built runtime export lists", async () => { + expect(Object.keys(await import("../src/index.js")).sort()).toEqual( + expectedRuntimeExports, + ); + const built = await import(path.resolve("dist/index.js")); + expect(Object.keys(built).sort()).toEqual(expectedRuntimeExports); + }); + + it("publishes structural config declarations without a Zod dependency", async () => { + const declaration = await readFile("dist/index.d.ts", "utf8"); + expect(declaration).not.toMatch(/\bzod\b|\bz\.core\b/); + }); + + it("preserves schema-derived public types when materializing declarations", () => { + const parsed = ts.parseJsonConfigFileContent( + ts.readConfigFile(path.resolve("tsconfig.json"), ts.sys.readFile).config, + ts.sys, + process.cwd(), + ); + const sourcePath = path.resolve("src/schema.ts"); + const builtPath = path.resolve("dist/index.d.ts"); + const program = ts.createProgram([sourcePath, builtPath], parsed.options); + const checker = program.getTypeChecker(); + const moduleTypes = (file: string) => { + const source = program.getSourceFile(file); + const module = source && checker.getSymbolAtLocation(source); + if (!module) throw new Error(`Cannot read exported types in ${file}`); + return new Map( + checker.getExportsOfModule(module).map((symbol) => { + const resolved = + symbol.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(symbol) + : symbol; + return [symbol.name, checker.getDeclaredTypeOfSymbol(resolved)]; + }), + ); + }; + const sourceTypes = moduleTypes(sourcePath); + const builtTypes = moduleTypes(builtPath); + for (const [name, sourceType] of sourceTypes) { + const builtType = builtTypes.get(name); + if (!builtType) continue; // Some schemas are internal, not public API. + expect(builtType.flags & ts.TypeFlags.Any, name).toBe(0); + expect(checker.isTypeAssignableTo(sourceType, builtType), name).toBe( + true, + ); + expect(checker.isTypeAssignableTo(builtType, sourceType), name).toBe( + true, + ); + } + }); + + it("publishes byte-array types without an ambient Buffer dependency", async () => { + const declaration = await readFile("dist/index.d.ts", "utf8"); + expect(declaration).not.toContain("Buffer"); + expectTypeOf().toExtend(); + const file: FileValue = Buffer.from([65]); + expect(file[0]).toBe(65); + }); + + it("documents Artifact file values and explicit UTF-8 decoding", async () => { + const readme = await readFile("README.md", "utf8"); + expect(readme).toContain( + "`Artifact.files` values are `string | Uint8Array`", + ); + expect(readme).toContain('`Buffer.from(value).toString("utf8")`'); + }); + + it("marks the reachable source-provider state as unstable", async () => { + const declaration = await readFile("dist/index.d.ts", "utf8"); + expect(declaration).not.toContain("Deliberately not exported"); + const source = ts.createSourceFile( + "index.d.ts", + declaration, + ts.ScriptTarget.Latest, + true, + ); + const project = source.statements.find( + (node): node is ts.TypeAliasDeclaration => + ts.isTypeAliasDeclaration(node) && node.name.text === "ResolvedProject", + ); + expect(project && ts.isTypeLiteralNode(project.type)).toBe(true); + if (!project || !ts.isTypeLiteralNode(project.type)) + throw new Error("ResolvedProject must expose its annotated state"); + const provider = project.type.members.find( + (member) => member.name?.getText(source) === "source", + ); + expect( + provider && ts.getJSDocTags(provider).map((tag) => tag.tagName.text), + ).toContain("unstable"); + }); + + it.each(["no Zod", "hoisted Zod 3"])( + "imports every public type with skipLibCheck false, no ambient types, and %s", + async (dependency) => { + const root = await realpath( + await mkdtemp(path.join(os.tmpdir(), "pluginpack-public-api-")), + ); + try { + const packageDir = path.join( + root, + "node_modules/@gleanwork/pluginpack", + ); + await mkdir(packageDir, { recursive: true }); + await writeFile( + path.join(root, "package.json"), + JSON.stringify({ type: "module" }), + ); + await writeFile( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: "@gleanwork/pluginpack", + type: "module", + exports: { ".": { types: "./index.d.ts" } }, + }), + ); + await writeFile( + path.join(packageDir, "index.d.ts"), + await readFile("dist/index.d.ts"), + ); + const consumer = path.join(root, "consumer.ts"); + let code = await readFile( + "tests/fixtures/public-api/consumer.txt", + "utf8", + ); + if (dependency === "hoisted Zod 3") { + // Zod ships its genuine v3 implementation alongside v4. Copy only v3 + // into the fixture's one hoisted dependency, with no nested v4. + await cp( + path.resolve("node_modules/zod/v3"), + path.join(root, "node_modules/zod"), + { recursive: true }, + ); + code += + '\nimport { z } from "zod";\nexport const legacySchema: z.ZodString = z.string();\n'; + } + await writeFile(consumer, code); + const program = ts.createProgram([consumer], { + target: ts.ScriptTarget.ES2022, + lib: ["lib.es2022.d.ts"], + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + types: [], + strict: true, + skipLibCheck: false, + noEmit: true, + }); + const checker = program.getTypeChecker(); + const declaration = program.getSourceFile( + path.join(packageDir, "index.d.ts"), + ); + const source = program.getSourceFile(consumer); + if (!declaration || !source) + throw new Error("Consumer and package declarations must resolve"); + const module = checker.getSymbolAtLocation(declaration); + if (!module) throw new Error("Package declarations must form a module"); + const exportedTypes = checker + .getExportsOfModule(module) + .filter((symbol) => { + const resolved = + symbol.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(symbol) + : symbol; + return Boolean(resolved.flags & ts.SymbolFlags.Type); + }) + .map((symbol) => symbol.name) + .sort(); + const importedTypes = source.statements + .flatMap((node) => { + if (!ts.isImportDeclaration(node) || !node.importClause?.isTypeOnly) + return []; + const bindings = node.importClause.namedBindings; + return bindings && ts.isNamedImports(bindings) + ? bindings.elements.map((element) => element.name.text) + : []; + }) + .sort(); + expect(importedTypes).toEqual(exportedTypes); + expect( + ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + ), + ).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/tests/update-check-file-value.test.ts b/tests/update-check-file-value.test.ts new file mode 100644 index 0000000..118c1c1 --- /dev/null +++ b/tests/update-check-file-value.test.ts @@ -0,0 +1,26 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, it } from "vitest"; +import type { FileValue } from "../src/index.js"; +import { applyUpdateCheck } from "../src/update-check.js"; + +const existingHooks = Buffer.from( + JSON.stringify({ hooks: { PostToolUse: [] } }), +); + +describe("update-check binary file values", () => { + it.each([ + ["Buffer", existingHooks], + ["Uint8Array", new Uint8Array(existingHooks)], + ] as const)("decodes %s hook content as UTF-8", (_name, value) => { + const files = new Map([["hooks/hooks.json", value]]); + applyUpdateCheck(files, "claude", { + format: "claude", + pluginName: "demo", + version: "1.0.0", + repository: "https://github.com/acme/plugins", + }); + expect(JSON.parse(String(files.get("hooks/hooks.json")))).toMatchObject({ + hooks: { PostToolUse: [], SessionStart: [expect.any(Object)] }, + }); + }); +});