From b4e33d1321530fcbf24e272f6811b8bf307cb854 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 14:16:58 -0700 Subject: [PATCH 1/5] fix(api): preserve schema-derived config and artifact contracts Keep authoring config types schema-input-derived and resolved config schema-output-derived. Materialize their declarations with the TypeScript checker so consumers do not inherit Zod internals or ambient Node types. Warn about stripped config keys, mark exposed source state unstable, and give artifact files and managed paths one code-unit ordering. Pin runtime exports and compile all public types in isolated consumers, including hoisted Zod 3 definitions. The Uint8Array file-value change needs the matching explicit UTF-8 conversions in the companion partials change. That owned file is deliberately left for its implementer. --- CLAUDE.md | 4 +- README.md | 5 + REVIEW.md | 2 +- package.json | 2 +- scripts/materialize-config-types.mjs | 104 ++++++++++++ src/config.ts | 39 +++-- src/index.ts | 1 + src/schema.ts | 29 ++-- src/targets/engine.ts | 8 +- src/types.ts | 23 +-- src/update-check.ts | 2 +- tests/config-contract.test.ts | 194 ++++++++++++++++++++++ tests/fixtures/public-api/consumer.txt | 38 +++++ tests/public-api.test.ts | 214 +++++++++++++++++++++++++ tests/update-check-file-value.test.ts | 26 +++ 15 files changed, 649 insertions(+), 42 deletions(-) create mode 100644 scripts/materialize-config-types.mjs create mode 100644 tests/config-contract.test.ts create mode 100644 tests/fixtures/public-api/consumer.txt create mode 100644 tests/public-api.test.ts create mode 100644 tests/update-check-file-value.test.ts 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 626eded..e2eaa79 100644 --- a/README.md +++ b/README.md @@ -567,6 +567,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. 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 | @@ -681,8 +683,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` accepts strings and `Uint8Array` values, including Node buffers. + 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 683e624..fb4f3e7 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..f40af08 --- /dev/null +++ b/scripts/materialize-config-types.mjs @@ -0,0 +1,104 @@ +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)) { + 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); + } 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 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)) { + return [ + ts.factory.updateTypeAliasDeclaration( + node, + node.modifiers, + node.name, + node.typeParameters, + aliases.get(node.name.text), + ), + ]; + } + 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 = ts + .createPrinter() + .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.`, + ); +} +await writeFile(declarationPath, result); diff --git a/src/config.ts b/src/config.ts index 29322b0..f65e38e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,7 @@ import { createFilesystemSourceProvider } from "./source.js"; import type { ResolvedProject, PluginpackConfig, + ResolvedPluginpackConfig, ResolvedProjectConfig, SourcePlugin, SourcePluginManifest, @@ -48,7 +49,7 @@ export async function loadConfig( */ async function loadPartialsIfConfigured( rootDir: string, - config: PluginpackConfig, + config: ResolvedPluginpackConfig, ): Promise> { if (!config.source?.partials) { return new Map(); @@ -72,11 +73,7 @@ export async function loadProjectConfig( interopDefault: true, }); 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, @@ -87,7 +84,7 @@ export async function loadProjectConfig( async function addRootSkillsPlugin( rootDir: string, - config: PluginpackConfig, + config: ResolvedPluginpackConfig, plugins: Map, ): Promise { if (!config.source?.skills) { @@ -218,13 +215,18 @@ async function readSourceManifest( } } -function parseWithContext( - schema: z.ZodType, +function parseWithContext( + schema: S, value: unknown, context: string, -): unknown { +): z.output { const parsed = schema.safeParse(value); if (parsed.success) { + for (const key of strippedKeys(value, parsed.data)) { + console.warn( + `Unknown config key "${key}" in ${context} was ignored. Remove it or use a supported config field.`, + ); + } return parsed.data; } const details = parsed.error.issues @@ -232,3 +234,20 @@ function parseWithContext( .join("; "); throw new Error(`Invalid pluginpack config in ${context}: ${details}`); } + +/** 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/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/schema.ts b/src/schema.ts index f6acb7d..b01a239 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -191,14 +191,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({ @@ -351,12 +346,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/targets/engine.ts b/src/targets/engine.ts index 93c2d5c..f7e7bcb 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -352,12 +352,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..77f0903 --- /dev/null +++ b/tests/config-contract.test.ts @@ -0,0 +1,194 @@ +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 } 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("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/public-api.test.ts b/tests/public-api.test.ts new file mode 100644 index 0000000..5aed466 --- /dev/null +++ b/tests/public-api.test.ts @@ -0,0 +1,214 @@ +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("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)] }, + }); + }); +}); From ec31fc91194a403e6182c42f5e31abe4bef170d1 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 14:41:04 -0700 Subject: [PATCH 2/5] fix(partials): decode Uint8Array file values as UTF-8 FileValue is now string | Uint8Array, and Uint8Array.toString() joins byte values with commas instead of decoding them. Decode through Buffer.from so a Uint8Array value is read as UTF-8 text. --- src/partials.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/partials.ts b/src/partials.ts index 8eb2f6e..bd317bd 100644 --- a/src/partials.ts +++ b/src/partials.ts @@ -131,7 +131,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; } @@ -316,6 +317,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]; } From 76768e61cef3be1d86280417c80b455029a5ae5d Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:26:49 -0700 Subject: [PATCH 3/5] fix(types): validate and compact materialized config declarations Match schema-derived alias expressions before replacing declarations, so a public type with the same name cannot be silently overwritten. Reuse existing structural aliases and emit multiline literals for readable output. Typecheck the publishable declaration with no ambient types and full library checking. This catches unqualified Zod helpers and missing dependencies during the build. --- scripts/materialize-config-types.mjs | 101 +++++++++++++++++++-- tests/materialize-config-types.test.ts | 117 +++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 tests/materialize-config-types.test.ts diff --git a/scripts/materialize-config-types.mjs b/scripts/materialize-config-types.mjs index f40af08..23367ea 100644 --- a/scripts/materialize-config-types.mjs +++ b/scripts/materialize-config-types.mjs @@ -26,7 +26,7 @@ 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)) { + if (ts.isTypeAliasDeclaration(node) && schemaDerivation(node.type)) { const type = checker.getTypeAtLocation(node); const structuralType = checker.typeToTypeNode( type, @@ -38,7 +38,10 @@ for (const node of schema.statements) { `Cannot materialize schema-derived type ${node.name.text}`, ); } - aliases.set(node.name.text, structuralType); + 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)) @@ -54,6 +57,8 @@ const declaration = ts.createSourceFile( 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 []; @@ -68,13 +73,48 @@ const statements = declaration.statements.flatMap((node) => { 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, - aliases.get(node.name.text), + materialized, ), ]; } @@ -93,12 +133,61 @@ const statements = declaration.statements.flatMap((node) => { } return [node]; }); -const result = ts - .createPrinter() - .printFile(ts.factory.updateSourceFile(declaration, statements)); +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/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"); + }); +}); From 74d0ac6e9ddb36ab44345b23aa69c76564aa2e1f Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:27:06 -0700 Subject: [PATCH 4/5] fix(config): deduplicate and label stripped-key warnings Track diagnostics by file and field instead of loader identity, so prune and diff do not warn again when they reload a project. Keep new keys visible without relying on the config module cache. Use manifest wording for legacy and canonical source manifests and MCP shipping manifests. Keep intentional record passthroughs silent. --- src/config-diagnostics.ts | 36 ++++++++++++++++ src/config.ts | 28 +++---------- src/source.ts | 2 + tests/config-contract.test.ts | 78 ++++++++++++++++++++++++++++++++++- 4 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 src/config-diagnostics.ts 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 f65e38e..41eb844 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 } from "./fs.js"; import { loadPartials } from "./partials.js"; import { configSchema, sourcePluginManifestSchema } from "./schema.js"; @@ -204,6 +205,7 @@ async function readSourceManifest( sourcePluginManifestSchema, JSON.parse(raw), filePath, + "manifest", ) as SourcePluginManifest; } catch (error) { if (error instanceof SyntaxError) { @@ -219,35 +221,15 @@ function parseWithContext( schema: S, value: unknown, context: string, + kind: "config" | "manifest" = "config", ): z.output { const parsed = schema.safeParse(value); if (parsed.success) { - for (const key of strippedKeys(value, parsed.data)) { - console.warn( - `Unknown config key "${key}" in ${context} was ignored. Remove it or use a supported config field.`, - ); - } + 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}`); -} - -/** 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; + throw new Error(`Invalid pluginpack ${kind} in ${context}: ${details}`); } 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/tests/config-contract.test.ts b/tests/config-contract.test.ts index 77f0903..261a892 100644 --- a/tests/config-contract.test.ts +++ b/tests/config-contract.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import ts from "typescript"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { build } from "../src/index.js"; +import { build, diffTarget, loadConfig, prune } from "../src/index.js"; import { loadProjectConfig } from "../src/config.js"; const temporaryDirectories: string[] = []; @@ -170,6 +170,82 @@ void defaultIsPresent; void invalidResolved; ]); }); + 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) => [ From 2c54235a8e0e0cb33689833361fb5dbf52f6a07d Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 15:27:17 -0700 Subject: [PATCH 5/5] docs(api): explain byte decoding and diagnostic scope Document string or Uint8Array artifact values and explicit UTF-8 decoding, including Node buffers. State which authored manifests receive stripped-key warnings and that repeated loads do not duplicate them. --- README.md | 4 ++-- tests/public-api.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e2eaa79..af36f9d 100644 --- a/README.md +++ b/README.md @@ -567,7 +567,7 @@ 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. 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. +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** @@ -683,7 +683,7 @@ 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` accepts strings and `Uint8Array` values, including Node buffers. +`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: diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts index 5aed466..2ef39e7 100644 --- a/tests/public-api.test.ts +++ b/tests/public-api.test.ts @@ -89,6 +89,14 @@ describe("public API contract", () => { 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");