From ef15442968632a82d848c64605e1e94f07871114 Mon Sep 17 00:00:00 2001 From: Victor M Varela Date: Fri, 9 Oct 2026 08:53:03 +0200 Subject: [PATCH 1/2] feat: publish config JSON Schema Closes #27 --- README.md | 29 ++++ package.json | 2 + pnpm-lock.yaml | 34 ++++ schema.json | 90 ++++++++++ scripts/check-release.mjs | 11 +- src/config-file.ts | 29 +++- tests/config-file.test.ts | 74 ++++++++ tests/readme-examples.test.ts | 116 +++++++++++++ tests/schema.test.ts | 318 ++++++++++++++++++++++++++++++++++ 9 files changed, 695 insertions(+), 8 deletions(-) create mode 100644 schema.json create mode 100644 tests/readme-examples.test.ts create mode 100644 tests/schema.test.ts diff --git a/README.md b/README.md index 8371f82..f677a03 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,35 @@ replaces a file alias with the same key, and inline `strict`/`debug` values win. inline options apply when OpenCode reloads its configuration. The `.jsonc` file is read when the plugin starts, so restart OpenCode after editing it. +## Editor support + +`schema.json` ships with the package: a draft-07 JSON Schema for the plugin options. Point +the config file at it and editors autocomplete and validate the shape (JSONC comments and +trailing commas are fine): + +```jsonc +// .opencode/opencode-model-aliases.jsonc — complete example +{ + "$schema": "https://raw.githubusercontent.com/vmvarela/opencode-model-aliases/v0.4.0/schema.json", + "strict": true, + "debug": false, + "aliases": { + "openai/gpt-sol": { "match": "openai/gpt-*-sol" }, + "openai/latest": { + "match": ["openai/gpt-*", "openai/o*"], + "exclude": ["openai/*-preview"], + "filter": { "status": ["active", "beta"], "minContext": 128000 } + } + } +} +``` + +`$schema` is file-only metadata: editors may require it, the plugin validates it as a +non-empty string and strips it before merging. Inline plugin options never accept it. +The schema checks shape only — glob compilation, the literal-provider rule and provider +equality between patterns and the alias key are still enforced by the plugin at runtime, +which remains authoritative. + ## When things fail - **Invalid configuration fails at startup**, including unknown keys and invalid globs. diff --git a/package.json b/package.json index a4dc173..0f5f996 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "index.js", "tui.js", "dist", + "schema.json", "README.md", "LICENSE" ], @@ -48,6 +49,7 @@ "@opencode/plugin": "2.0.21", "@types/node": "^26.6.3", "@types/picomatch": "4.0.3", + "ajv": "8.20.0", "conventional-changelog-conventionalcommits": "9.3.1", "semantic-release": "25.0.9", "typescript": "7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b79f885..938346b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@types/picomatch': specifier: 4.0.3 version: 4.0.3 + ajv: + specifier: 8.20.0 + version: 8.20.0 conventional-changelog-conventionalcommits: specifier: 9.3.1 version: 9.3.1 @@ -1202,6 +1205,9 @@ packages: resolution: {integrity: sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==} engines: {node: '>=18'} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -1556,6 +1562,12 @@ packages: resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1870,6 +1882,9 @@ packages: resolution: {integrity: sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==} engines: {node: ^20.17.0 || >=22.9.0} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -2427,6 +2442,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4123,6 +4142,13 @@ snapshots: clean-stack: 5.3.0 indent-string: 5.0.0 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -4480,6 +4506,10 @@ snapshots: dependencies: pure-rand: 8.4.2 + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.8: {} + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -4789,6 +4819,8 @@ snapshots: json-parse-even-better-errors@5.0.0: {} + json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} json-stringify-nice@1.1.4: {} @@ -5324,6 +5356,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} diff --git a/schema.json b/schema.json new file mode 100644 index 0000000..6550396 --- /dev/null +++ b/schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/vmvarela/opencode-model-aliases/v0.4.0/schema.json", + "title": "opencode-model-aliases plugin options", + "description": "Schema for the plugin configuration file `.opencode/opencode-model-aliases.jsonc` and for the inline plugin `options` in `opencode.json`; file and inline options are merged before validation, so `aliases` may be omitted in the file when it is provided inline. The schema describes the JSON shape only: glob compilation, provider equality between match patterns and the alias key, and model-catalog collisions are enforced by the plugin at runtime. `$schema` is file-only metadata: it is accepted here and stripped before merging; inline options never accept it.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { "type": "string", "minLength": 1, "description": "File-only metadata; removed before merging and never accepted in inline options." }, + "aliases": { + "type": "object", + "propertyNames": { "$ref": "#/definitions/selector" }, + "additionalProperties": { "$ref": "#/definitions/alias" } + }, + "strict": { "type": "boolean" }, + "debug": { "type": "boolean" } + }, + "definitions": { + "selector": { + "type": "string", + "minLength": 1, + "pattern": "^[^#/*?\\[\\]{}()!+@|\\\\]+/[^#]+$" + }, + "alias": { + "type": "object", + "additionalProperties": false, + "required": ["match"], + "properties": { + "match": { + "oneOf": [ + { "$ref": "#/definitions/selector" }, + { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/selector" } + } + ] + }, + "exclude": { + "oneOf": [ + { "$ref": "#/definitions/selector" }, + { + "type": "array", + "items": { "$ref": "#/definitions/selector" } + } + ] + }, + "filter": { "$ref": "#/definitions/filter" }, + "select": { "$ref": "#/definitions/select" }, + "name": { "type": "string", "minLength": 1 } + } + }, + "filter": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "enum": ["active", "alpha", "beta"] } + }, + "capabilities": { "$ref": "#/definitions/capabilities" }, + "minContext": { "type": "integer", "minimum": 1 } + } + }, + "capabilities": { + "type": "object", + "additionalProperties": false, + "properties": { + "tools": { "type": "boolean" }, + "input": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "output": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + } + }, + "select": { + "type": "object", + "additionalProperties": false, + "required": ["strategy"], + "properties": { "strategy": { "type": "string", "enum": ["latest"] } } + } + } +} diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index 1359b19..40d3953 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -279,7 +279,15 @@ assert.ok(Array.isArray(packOutput) && packOutput.length === 1, "pack inesperado assert.equal(packOutput[0].name, "opencode-model-aliases"); const packedFiles = packOutput[0].files.map((entry) => entry.path); // package.json always goes into the npm tarball in addition to the files list. -const allowedRoots = ["index.js", "tui.js", "dist", "README.md", "LICENSE", "package.json"]; +const allowedRoots = [ + "index.js", + "tui.js", + "dist", + "schema.json", + "README.md", + "LICENSE", + "package.json", +]; const leaked = packedFiles.filter((file) => { return !allowedRoots.some((root) => file === root || file.startsWith(`${root}/`)); }); @@ -290,6 +298,7 @@ for (const required of [ "tui.js", "dist/index.js", "dist/tui.js", + "schema.json", "README.md", "LICENSE", ]) { diff --git a/src/config-file.ts b/src/config-file.ts index ae95996..dabab5f 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -9,7 +9,9 @@ const FILE_NAME = "opencode-model-aliases.jsonc"; /** Raw options read from the file; values are not validated yet. * Unrecognized root keys are preserved as-is: normalizeOptions - * must reject them as parse-error (no silent whitelisting). */ + * must reject them as parse-error (no silent whitelisting). + * The single exception is `$schema`: file-only metadata, validated and + * stripped before it can reach the merge (see loadConfigFile). */ export interface RawFileOptions { readonly aliases?: Record; readonly strict?: unknown; @@ -81,12 +83,25 @@ export async function loadConfigFile(startDirectory: string): Promise { removeTempRoot(tempRoot); }); +describe("schema metadata ($schema)", () => { + const SCHEMA_URL = + "https://raw.githubusercontent.com/vmvarela/opencode-model-aliases/master/schema.json"; + + it("archivo con $schema válido se acepta, se elimina del merge y el setup funciona", async () => { + const project = path.join(tempRoot, "proj"); + mkdirSync(project, { recursive: true }); + writeConfigFile( + project, + [ + "{", + ` "$schema": "${SCHEMA_URL}",`, + ' "aliases": { "anthropic/float": { "match": "anthropic/**" } },', + "}", + ].join("\n"), + ); + const loaded = await loadConfigFile(project); + expect(loaded.ok).toBe(true); + if (loaded.ok && loaded.file) { + // Stripped before the merge: normalizeOptions must never see it. + expect("$schema" in loaded.file.options).toBe(false); + expect(loaded.file.options.strict).toBeUndefined(); + } + const harness = createHarness({ sources: SOURCES(), directory: project }); + await floatingModels.setup(harness.ctx); + expect(harness.view().get("anthropic/float")?.modelID).toBe("claude-b"); + }); + + it("archivo con $schema inválido (no string, vacío, null) falla sin registrar", async () => { + const badFiles = [ + '{ "$schema": 3, "aliases": {} }', + '{ "$schema": "", "aliases": {} }', + '{ "$schema": null, "aliases": {} }', + ]; + for (const [index, contents] of badFiles.entries()) { + const project = path.join(tempRoot, `bad-${index}`); + mkdirSync(project, { recursive: true }); + writeConfigFile(project, contents); + const loaded = await loadConfigFile(project); + expect(loaded.ok).toBe(false); + if (!loaded.ok) expect(loaded.reason).toMatch(/\$schema must be a non-empty string/); + } + }); + + it("archivo con $schema válido pero errata en la raíz (strcit) sigue fallando", async () => { + const project = path.join(tempRoot, "proj"); + mkdirSync(project, { recursive: true }); + writeConfigFile( + project, + `{ "$schema": "${SCHEMA_URL}", "aliases": { "anthropic/float": { "match": "anthropic/**" } }, "strcit": true }`, + ); + const harness = createHarness({ sources: SOURCES(), directory: project }); + await expect(floatingModels.setup(harness.ctx)).rejects.toThrow(/strcit/); + expect(harness.callbacks).toHaveLength(0); + }); + + it("$schema inline sigue rechazado como clave raíz desconocida", async () => { + const project = path.join(tempRoot, "proj"); + mkdirSync(project, { recursive: true }); + const harness = createHarness({ + sources: SOURCES(), + options: { + aliases: { "anthropic/float": { match: "anthropic/**" } }, + $schema: SCHEMA_URL, + }, + directory: project, + }); + await expect(floatingModels.setup(harness.ctx)).rejects.toThrow( + /unsupported key\(s\): \$schema/, + ); + expect(harness.callbacks).toHaveLength(0); + }); +}); + describe("separate JSONC config file", () => { it("file-only options con comentarios y trailing commas: alias, strict y debug aplican", async () => { const project = path.join(tempRoot, "proj"); diff --git a/tests/readme-examples.test.ts b/tests/readme-examples.test.ts new file mode 100644 index 0000000..db19705 --- /dev/null +++ b/tests/readme-examples.test.ts @@ -0,0 +1,116 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { Ajv } from "ajv"; +import { type ParseError, parse } from "jsonc-parser"; +import { describe, expect, it } from "vitest"; + +const SCHEMA_PATH = path.join(import.meta.dirname, "..", "schema.json"); +const schema = JSON.parse(readFileSync(SCHEMA_PATH, "utf8")) as Record; + +// Same policy as the schema contract tests: validation only. +const ajv = new Ajv(); +if (!ajv.validateSchema(schema)) throw new Error(ajv.errorsText()); +const validate = ajv.compile(schema); + +const README_PATH = path.join(import.meta.dirname, "..", "README.md"); +const readme = readFileSync(README_PATH, "utf8"); + +const fences = [...readme.matchAll(/```(\w+)\n([\s\S]*?)```/g)].map((match) => ({ + lang: match[1] ?? "", + body: match[2] ?? "", +})); + +/** Parses a complete JSONC document and fails on any parse error. */ +function parseJsoncDoc(body: string, source: string): Record { + const errors: ParseError[] = []; + const doc = parse(body, errors, { allowTrailingComma: true }); + const first = errors[0]; + expect(first, source).toBeUndefined(); + expect(typeof doc === "object" && doc !== null && !Array.isArray(doc), source).toBe(true); + return doc as Record; +} + +/** Wraps an alias-fragment fence (bare `"": { ... }` entries) into a + * complete options document; trailing commas are tolerated. */ +function wrapAliasFragment(body: string, source: string): Record { + return parseJsoncDoc(`{"aliases": {\n${body}\n}}`, source); +} + +function optionsFromJsonExample(body: string, source: string): unknown[] { + if (/^\s*"[^"]+"\s*:/u.test(body)) { + return [wrapAliasFragment(body, source)]; + } + const doc = parseJsoncDoc(body, source); + if ("plugins" in doc) { + expect(Array.isArray(doc.plugins), source).toBe(true); + const plugins = doc.plugins as unknown[]; + const configured = plugins.filter( + (plugin): plugin is Record => + typeof plugin === "object" && + plugin !== null && + typeof (plugin as Record).package === "string" && + ((plugin as Record).package ?? "").startsWith("opencode-model-aliases"), + ); + expect(configured.length, `${source}: configured plugin`).toBeGreaterThan(0); + return configured.map((plugin) => { + expect(Object.hasOwn(plugin, "options"), `${source}: plugin options`).toBe(true); + return plugin.options; + }); + } + if ("aliases" in doc) return [doc]; + throw new Error(`${source}: unrecognized configuration example`); +} + +describe("README examples validate against schema.json", () => { + it("the schema URL shown in the README matches the schema $id", () => { + expect(readme).toContain(String(schema.$id)); + expect(schema.$id).toBe( + "https://raw.githubusercontent.com/vmvarela/opencode-model-aliases/v0.4.0/schema.json", + ); + }); + + it("```jsonc fences are complete file configs: parsed, $schema = $id, schema-valid", () => { + const jsoncFences = fences.filter((fence) => fence.lang === "jsonc"); + expect(jsoncFences.length).toBeGreaterThan(0); + for (const fence of jsoncFences) { + const doc = parseJsoncDoc(fence.body, "jsonc fence"); + const schemaUrl = doc.$schema; + expect(schemaUrl, "file example must point at the published schema").toBe(schema.$id); + expect(errorsOf(doc), fence.body).toBeUndefined(); + } + }); + + it("```json fences are complete options docs, opencode configs or alias fragments — all covered and schema-valid", () => { + let covered = 0; + for (const fence of fences.filter((entry) => entry.lang === "json")) { + const source = fence.body.slice(0, 60); + for (const options of optionsFromJsonExample(fence.body, source)) { + expect(errorsOf(options), source).toBeUndefined(); + } + covered += 1; + } + // No ```json fence is silently skipped. + expect(covered).toBe(fences.filter((entry) => entry.lang === "json").length); + }); + + it("rejects malformed complete configs instead of validating recovered parser data", () => { + expect(() => + optionsFromJsonExample( + '{"plugins":[{"package":"opencode-model-aliases@latest","options":{"aliases":{}}}', + "truncated config", + ), + ).toThrow(); + }); + + it("requires intended OpenCode plugin options to be found and validated", () => { + const renamed = JSON.stringify({ + plugins: [{ package: "opencode-model-aliases@latest", optoins: { aliases: {} } }], + }); + expect(() => optionsFromJsonExample(renamed, "missing options")).toThrow(); + }); +}); + +function errorsOf(doc: unknown): string | undefined { + validate(doc); + return validate.errors ? ajv.errorsText(validate.errors) : undefined; +} diff --git a/tests/schema.test.ts b/tests/schema.test.ts new file mode 100644 index 0000000..a27320c --- /dev/null +++ b/tests/schema.test.ts @@ -0,0 +1,318 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { Ajv } from "ajv"; +import { describe, expect, it } from "vitest"; +import { normalizeOptions } from "../src/normalize.js"; + +const SCHEMA_PATH = path.join(import.meta.dirname, "..", "schema.json"); +const schema = JSON.parse(readFileSync(SCHEMA_PATH, "utf8")) as Record; + +const SCHEMA_URL = + "https://raw.githubusercontent.com/vmvarela/opencode-model-aliases/v0.4.0/schema.json"; + +// Validation only: no coerceTypes/useDefaults/removeAdditional, so the +// compiled schema describes the same shape normalizeOptions consumes. +const ajv = new Ajv(); +const metaValid = ajv.validateSchema(schema); +if (!metaValid) throw new Error(`schema.json is not a valid draft-07 schema: ${ajv.errorsText()}`); +const validate = ajv.compile(schema as object); +const errorsOf = (doc: unknown): string | undefined => { + validate(doc); + return validate.errors ? ajv.errorsText(validate.errors) : undefined; +}; + +/** Schema-valid documents: no Ajv errors. */ +function expectValid(doc: unknown) { + expect(errorsOf(doc), JSON.stringify(doc)).toBeUndefined(); +} + +/** Schema-invalid documents: at least one error mentioning the fragment. */ +function expectInvalid(doc: unknown, fragment: string) { + const errors = errorsOf(doc); + expect(errors, JSON.stringify(doc)).toBeDefined(); + expect(errors).toContain(fragment); +} + +const alias = (key: string, config: Record) => ({ + aliases: { [key]: config }, +}); + +describe("schema.json contract (draft-07, Ajv)", () => { + it("is a structurally valid draft-07 schema and compiles", () => { + expect(schema.$schema).toBe("http://json-schema.org/draft-07/schema#"); + expect(schema.$id).toBe(SCHEMA_URL); + // validateSchema + compile already ran at module load; reaching here + // proves both succeeded. + }); + + it("never enforces uniqueItems (duplicate items are preserved)", () => { + expect(JSON.stringify(schema)).not.toContain("uniqueItems"); + expectValid(alias("anthropic/float", { match: ["anthropic/a", "anthropic/a"] })); + }); + + it("accepts a full configuration with every option level", () => { + expectValid({ + $schema: SCHEMA_URL, + aliases: { + "anthropic/float": { + match: "anthropic/claude-*", + exclude: ["anthropic/claude-a"], + filter: { + status: ["active", "beta"], + capabilities: { tools: true, input: ["text"], output: ["text"] }, + minContext: 1, + }, + select: { strategy: "latest" }, + name: "Float", + }, + "anthropic/edge": { match: ["anthropic/claude-*"], exclude: [] }, + }, + strict: false, + debug: true, + }); + }); + + it("allows aliases omitted at file level and an empty capabilities object", () => { + expectValid({}); + expectValid({ strict: true, debug: false }); + expectValid(alias("anthropic/float", { match: "anthropic/**", filter: { capabilities: {} } })); + }); +}); + +describe("unknown keys are rejected at every level", () => { + it("root typo", () => { + expectInvalid({ strcit: true }, "must NOT have additional properties"); + expectInvalid({ aliases: { "a/m": { match: "a/**" } }, $schema: 3 }, "must be string"); + }); + + it("alias level", () => { + expectInvalid( + alias("a/m", { match: "a/**", macth: "a/**" }), + "must NOT have additional properties", + ); + }); + + it("filter level", () => { + expectInvalid( + alias("a/m", { match: "a/**", filter: { stauts: ["active"] } }), + "must NOT have additional properties", + ); + }); + + it("capabilities level", () => { + expectInvalid( + alias("a/m", { match: "a/**", filter: { capabilities: { toolz: true } } }), + "must NOT have additional properties", + ); + }); + + it("select level", () => { + expectInvalid( + alias("a/m", { match: "a/**", select: { strategy: "latest", tieBreak: "x" } }), + "must NOT have additional properties", + ); + }); +}); + +describe("field constraints", () => { + it("aliases container must be an object", () => { + expectInvalid({ aliases: [] }, "must be object"); + expectInvalid({ aliases: null }, "must be object"); + }); + + it("match is required and non-empty (string or non-empty array)", () => { + expectInvalid(alias("a/m", {}), "must have required property 'match'"); + expectInvalid(alias("a/m", { match: "" }), "must match exactly one schema in oneOf"); + expectInvalid(alias("a/m", { match: [] }), "must match exactly one schema in oneOf"); + expectInvalid(alias("a/m", { match: ["a/**", ""] }), "must match exactly one schema in oneOf"); + expectInvalid(alias("a/m", { match: 1 }), "must match exactly one schema in oneOf"); + }); + + it("exclude is an optional non-empty string or array (empty array valid)", () => { + expectInvalid( + alias("a/m", { match: "a/**", exclude: "" }), + "must match exactly one schema in oneOf", + ); + expectInvalid( + alias("a/m", { match: "a/**", exclude: 1 }), + "must match exactly one schema in oneOf", + ); + }); + + it("filter.status is a non-empty array of allowed statuses", () => { + expectInvalid( + alias("a/m", { match: "a/**", filter: { status: [] } }), + "must NOT have fewer than 1 items", + ); + expectInvalid( + alias("a/m", { match: "a/**", filter: { status: ["deprecated"] } }), + "must be equal to one of the allowed values", + ); + expectInvalid(alias("a/m", { match: "a/**", filter: { status: "active" } }), "must be array"); + }); + + it("filter.capabilities: tools boolean, non-empty modality arrays", () => { + expectInvalid( + alias("a/m", { match: "a/**", filter: { capabilities: { tools: "true" } } }), + "must be boolean", + ); + expectInvalid( + alias("a/m", { match: "a/**", filter: { capabilities: { input: [] } } }), + "must NOT have fewer than 1 items", + ); + expectInvalid( + alias("a/m", { match: "a/**", filter: { capabilities: { output: [""] } } }), + "must NOT have fewer than 1 characters", + ); + }); + + it("filter.minContext is a positive integer", () => { + expectInvalid(alias("a/m", { match: "a/**", filter: { minContext: 0 } }), "must be >= 1"); + expectInvalid(alias("a/m", { match: "a/**", filter: { minContext: 1.5 } }), "must be integer"); + expectInvalid(alias("a/m", { match: "a/**", filter: { minContext: "1" } }), "must be integer"); + }); + + it("select, when present, is exactly { strategy: 'latest' }", () => { + expectInvalid( + alias("a/m", { match: "a/**", select: {} }), + "must have required property 'strategy'", + ); + expectInvalid( + alias("a/m", { match: "a/**", select: { strategy: "oldest" } }), + "must be equal to one of the allowed values", + ); + }); + + it("name is a non-empty string", () => { + expectInvalid( + alias("a/m", { match: "a/**", name: "" }), + "must NOT have fewer than 1 characters", + ); + }); +}); + +describe("alias keys are `/` without #", () => { + const withKey = (key: string) => alias(key, { match: "anthropic/**" }); + + it("accepts literal providers, models with globs and nested slashes", () => { + expectValid(withKey("anthropic/claude-*")); + expectValid(withKey("anthropic/claude/a")); + }); + + it("rejects keys without a slash, empty provider or empty model", () => { + expectInvalid(withKey("no-slash"), "must match pattern"); + expectInvalid(withKey("/model"), "must match pattern"); + expectInvalid(withKey("provider/"), "must match pattern"); + }); + + it("rejects non-literal providers and keys containing #", () => { + expectInvalid(withKey("prov*ider/m"), "must match pattern"); + expectInvalid(withKey("p?/m"), "must match pattern"); + expectInvalid(withKey("anthropic/m#1"), "must match pattern"); + expectInvalid(withKey("anthropic/a/b#c"), "must match pattern"); + }); + + it("rejects match patterns without `/` shape or with #", () => { + expectInvalid(alias("a/m", { match: "claude-*" }), "must match pattern"); + expectInvalid(alias("a/m", { match: "prov*ider/m" }), "must match pattern"); + expectInvalid(alias("a/m", { match: "a/b#c" }), "must match pattern"); + }); +}); + +/** + * Runtime parity: representative documents are compared against BOTH the + * compiled schema and normalizeOptions. Semantic-only constraints are + * intentionally NOT part of the schema (glob compilation, provider + * equality between match patterns and the alias key, catalog collisions): + * a doc that is schema-valid can still fail at runtime for those reasons. + */ +const validWithoutMetadata: unknown[] = [ + alias("anthropic/float", { match: "anthropic/claude-*" }), + alias("anthropic/float", { + match: ["anthropic/claude-*", "anthropic/haiku-**"], + exclude: ["anthropic/claude-a"], + filter: { + status: ["beta"], + capabilities: { tools: true, input: ["text"] }, + minContext: 32_000, + }, + select: { strategy: "latest" }, + name: "Float", + }), + { aliases: {}, strict: true, debug: false }, + { aliases: { "anthropic/float": { match: "anthropic/**", filter: { capabilities: {} } } } }, +]; + +const invalidDocs: unknown[] = [ + { strcit: true, aliases: {} }, + alias("a/m", { macth: "a/**" }), + alias("a/m", { match: "a/**", filter: { stauts: ["active"] } }), + alias("a/m", { match: "a/**", filter: { capabilities: { toolz: true } } }), + alias("a/m", { match: "a/**", select: { strategy: "latest", tieBreak: "x" } }), + alias("a/m", {}), + alias("a/m", { match: [] }), + alias("a/m", { match: "" }), + alias("a/m", { match: "a/**", filter: { status: ["deprecated"] } }), + alias("a/m", { match: "a/**", filter: { capabilities: { tools: "true" } } }), + alias("a/m", { match: "a/**", filter: { capabilities: { input: [] } } }), + alias("a/m", { match: "a/**", filter: { minContext: 0 } }), + alias("a/m", { match: "a/**", filter: { minContext: 1.5 } }), + alias("a/m", { match: "a/**", select: {} }), + alias("a/m", { match: "a/**", select: { strategy: "oldest" } }), + alias("a/m", { match: "a/**", name: "" }), + alias("no-slash", { match: "a/**" }), + alias("prov*ider/m", { match: "a/**" }), + alias("a/m#x", { match: "a/**" }), + { aliases: [] }, + { aliases: null }, +]; + +describe("runtime parity with normalizeOptions", () => { + it("schema-valid docs (without $schema metadata) are accepted by normalizeOptions", () => { + for (const doc of validWithoutMetadata) { + expect(errorsOf(doc), JSON.stringify(doc)).toBeUndefined(); + expect(normalizeOptions(doc as never).ok, JSON.stringify(doc)).toBe(true); + } + }); + + it("schema-invalid docs are also rejected by normalizeOptions", () => { + for (const doc of invalidDocs) { + expect(errorsOf(doc), JSON.stringify(doc)).toBeDefined(); + expect(normalizeOptions(doc as never).ok, JSON.stringify(doc)).toBe(false); + } + }); + + it("prototype-named own keys (via JSON.parse) are rejected by both", () => { + const hostileRoot = JSON.parse( + '{"aliases": {"anthropic/float": {"match": "anthropic/**"}}, "constructor": 1}', + ); + expect(errorsOf(hostileRoot)).toContain("must NOT have additional properties"); + expect(normalizeOptions(hostileRoot as never).ok).toBe(false); + + const hostileAliasKey = JSON.parse('{"aliases": {"__proto__": {"match": "anthropic/**"}}}'); + expect(errorsOf(hostileAliasKey)).toBeDefined(); + expect(normalizeOptions(hostileAliasKey as never).ok).toBe(false); + }); + + it("intentional divergence: aliases may be omitted at file level, but the merged runtime config requires it", () => { + // File-level schema allows the omission (inline options supply it); + // normalizeOptions over the merged config requires the container. + expect(errorsOf({})).toBeUndefined(); + expect(normalizeOptions({} as never).ok).toBe(false); + }); + + it("intentional divergence: $schema is valid file metadata, never accepted inline", () => { + // Schema-valid as file metadata... + expectValid({ $schema: SCHEMA_URL, aliases: {} }); + // ...the file layer strips it (see config-file tests) so the merged + // config validates; inline options are rejected by normalizeOptions. + expect(normalizeOptions({ $schema: SCHEMA_URL, aliases: {} } as never).ok).toBe(false); + }); + + it("semantic-only: provider equality between match and alias key is left to the runtime", () => { + // Schema-valid shape; normalizeOptions rejects the provider mismatch. + const doc = alias("anthropic/float", { match: "openai/gpt-*" }); + expect(errorsOf(doc)).toBeUndefined(); + expect(normalizeOptions(doc as never).ok).toBe(false); + }); +}); From 2377f32c8aac26219edc0b4de66a9750f0535326 Mon Sep 17 00:00:00 2001 From: Victor M Varela Date: Fri, 9 Oct 2026 09:14:15 +0200 Subject: [PATCH 2/2] fix: reject proto keys in JSONC config --- src/config-file.ts | 10 +++++++--- tests/config-file.test.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/config-file.ts b/src/config-file.ts index dabab5f..e41cc5c 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; -import { type ParseError, parse, printParseErrorCode } from "jsonc-parser"; +import { getNodeValue, type ParseError, parseTree, printParseErrorCode } from "jsonc-parser"; import { isPlainObject } from "./config.js"; /** Directory + file name of the plugin configuration file. */ @@ -59,9 +59,12 @@ export async function loadConfigFile(startDirectory: string): Promise 0) { const first = errors[0]; if (!first) return { ok: false, reason: `config file "${candidate}" has malformed JSONC` }; @@ -70,6 +73,7 @@ export async function loadConfigFile(startDirectory: string): Promise { expect(harness.callbacks).toHaveLength(0); }); + it("clave raíz hostil __proto__ se preserva como propia y normalizeOptions la rechaza", async () => { + const project = path.join(tempRoot, "proj"); + mkdirSync(project, { recursive: true }); + writeConfigFile(project, '{ "$schema": "schema.json", "aliases": {}, "__proto__": {} }'); + const result = await loadConfigFile(project); + expect(result.ok).toBe(true); + if (result.ok && result.file) { + // La clave debe ser propia y enumerable, no una mutación del prototipo. + expect(Object.hasOwn(result.file.options, "__proto__")).toBe(true); + expect(Object.keys(result.file.options)).toContain("__proto__"); + } + // normalizeOptions debe rechazarla como clave desconocida: el setup falla + // y no registra transformaciones. + const harness = createHarness({ sources: SOURCES(), directory: project }); + await expect(floatingModels.setup(harness.ctx)).rejects.toThrow(/__proto__/); + expect(harness.callbacks).toHaveLength(0); + }); + it("el RPC inspect expone el alias definido solo en el archivo", async () => { const project = path.join(tempRoot, "proj"); mkdirSync(project, { recursive: true });