refactor: dynamically generate roomodes JSON schema from Zod types

Replace the hand-crafted schemas/roomodes.json with one generated from
the Zod schemas in packages/types/src/mode.ts using zod-to-json-schema.
This ensures the schema stays in sync when TypeScript types change.

- Add zod-to-json-schema dev dependency to packages/types
- Create packages/types/scripts/generate-roomodes-schema.ts
- Add generate:schema npm script to packages/types
- Add drift-detection test in packages/types to catch schema/type mismatches
- Update existing AJV validation tests with documentation comment
This commit is contained in:
Roo Code 2026-03-02 15:31:19 +00:00
parent 70db387a69
commit 3fd2ca47c8
6 changed files with 248 additions and 115 deletions

View file

@ -20,7 +20,8 @@
"build": "tsup",
"build:watch": "tsup --watch --outDir npm/dist --onSuccess 'echo ✅ Types rebuilt to npm/dist'",
"npm:publish": "node scripts/publish-npm.cjs",
"clean": "rimraf dist .turbo"
"clean": "rimraf dist .turbo",
"generate:schema": "tsx scripts/generate-roomodes-schema.ts"
},
"dependencies": {
"zod": "3.25.76"
@ -31,6 +32,7 @@
"@types/node": "^24.1.0",
"globals": "^16.3.0",
"tsup": "^8.4.0",
"vitest": "^3.2.3"
"vitest": "^3.2.3",
"zod-to-json-schema": "^3.25.1"
}
}

View file

@ -0,0 +1,83 @@
/**
* Generates the JSON Schema for .roomodes configuration files from the Zod
* schemas defined in packages/types/src/mode.ts.
*
* This ensures the schema stays in sync with the TypeScript types. Run via:
* pnpm --filter @roo-code/types generate:schema
*
* The output is written to schemas/roomodes.json at the repository root.
*/
import * as fs from "fs"
import * as path from "path"
import { fileURLToPath } from "url"
import { zodToJsonSchema } from "zod-to-json-schema"
import { z } from "zod"
import { toolGroups, deprecatedToolGroups } from "../src/tool.js"
import { groupOptionsSchema, modeConfigSchema } from "../src/mode.js"
// ---------------------------------------------------------------------------
// 1. Build a ToolGroup enum that includes deprecated groups so existing
// configs still validate.
// ---------------------------------------------------------------------------
const allToolGroups = [...toolGroups, ...deprecatedToolGroups] as [string, ...string[]]
const allToolGroupsSchema = z.enum(allToolGroups)
// ---------------------------------------------------------------------------
// 2. Build a GroupEntry schema that uses the extended tool group list.
// ---------------------------------------------------------------------------
const groupEntrySchema = z.union([allToolGroupsSchema, z.tuple([allToolGroupsSchema, groupOptionsSchema])])
// ---------------------------------------------------------------------------
// 3. Build the RuleFile schema (used during import/export but not part of
// the core Zod types).
// ---------------------------------------------------------------------------
const ruleFileSchema = z.object({
relativePath: z.string(),
content: z.string().optional(),
})
// ---------------------------------------------------------------------------
// 4. Build an extended ModeConfig schema that includes rulesFiles and uses
// the extended groups (with deprecated entries).
// ---------------------------------------------------------------------------
const exportedModeConfigSchema = modeConfigSchema.omit({ groups: true }).extend({
groups: z.array(groupEntrySchema),
rulesFiles: z.array(ruleFileSchema).optional(),
})
// ---------------------------------------------------------------------------
// 5. Build the top-level .roomodes schema.
// ---------------------------------------------------------------------------
const roomodesSchema = z
.object({
customModes: z.array(exportedModeConfigSchema),
})
.strict()
// ---------------------------------------------------------------------------
// 6. Convert to JSON Schema (draft-07).
// ---------------------------------------------------------------------------
const jsonSchema = zodToJsonSchema(roomodesSchema, {
$refStrategy: "none",
target: "jsonSchema7",
}) as Record<string, unknown>
// ---------------------------------------------------------------------------
// 7. Add metadata.
// ---------------------------------------------------------------------------
jsonSchema["$id"] = "https://github.com/RooCodeInc/Roo-Code/blob/main/schemas/roomodes.json"
jsonSchema["title"] = "Roo Code Custom Modes"
jsonSchema["description"] = "Schema for .roomodes configuration files used by Roo Code to define custom modes."
// ---------------------------------------------------------------------------
// 8. Write to disk.
// ---------------------------------------------------------------------------
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, "../../..")
const outPath = path.join(repoRoot, "schemas", "roomodes.json")
fs.mkdirSync(path.dirname(outPath), { recursive: true })
fs.writeFileSync(outPath, JSON.stringify(jsonSchema, null, "\t") + "\n", "utf-8")
console.log(`Generated ${path.relative(repoRoot, outPath)}`)

View file

@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest"
import * as fs from "fs"
import * as path from "path"
import { fileURLToPath } from "url"
import { zodToJsonSchema } from "zod-to-json-schema"
import { z } from "zod"
import { toolGroups, deprecatedToolGroups } from "../tool.js"
import { groupOptionsSchema, modeConfigSchema } from "../mode.js"
/**
* This test verifies that the checked-in schemas/roomodes.json matches what
* would be generated from the current Zod schemas. If this test fails, run:
*
* pnpm --filter @roo-code/types generate:schema
*
* to regenerate the schema file.
*/
describe("roomodes schema sync", () => {
it("should match the dynamically generated schema from Zod types", () => {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const schemaPath = path.resolve(__dirname, "../../../../schemas/roomodes.json")
const checkedIn = JSON.parse(fs.readFileSync(schemaPath, "utf-8"))
// Reproduce the same generation logic as scripts/generate-roomodes-schema.ts
const allToolGroups = [...toolGroups, ...deprecatedToolGroups] as [string, ...string[]]
const allToolGroupsSchema = z.enum(allToolGroups)
const groupEntrySchema = z.union([allToolGroupsSchema, z.tuple([allToolGroupsSchema, groupOptionsSchema])])
const ruleFileSchema = z.object({
relativePath: z.string(),
content: z.string().optional(),
})
const exportedModeConfigSchema = modeConfigSchema.omit({ groups: true }).extend({
groups: z.array(groupEntrySchema),
rulesFiles: z.array(ruleFileSchema).optional(),
})
const roomodesSchema = z
.object({
customModes: z.array(exportedModeConfigSchema),
})
.strict()
const generated = zodToJsonSchema(roomodesSchema, {
$refStrategy: "none",
target: "jsonSchema7",
}) as Record<string, unknown>
generated["$id"] = "https://github.com/RooCodeInc/Roo-Code/blob/main/schemas/roomodes.json"
generated["title"] = "Roo Code Custom Modes"
generated["description"] = "Schema for .roomodes configuration files used by Roo Code to define custom modes."
expect(checkedIn).toEqual(generated)
})
})

14
pnpm-lock.yaml generated
View file

@ -731,6 +731,9 @@ importers:
vitest:
specifier: ^3.2.3
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
zod-to-json-schema:
specifier: ^3.25.1
version: 3.25.1(zod@3.25.76)
packages/vscode-shim:
devDependencies:
@ -11020,6 +11023,11 @@ packages:
peerDependencies:
zod: 3.25.76
zod-to-json-schema@3.25.1:
resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
peerDependencies:
zod: 3.25.76
zod-to-ts@1.2.0:
resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==}
peerDependencies:
@ -14992,7 +15000,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
'@vitest/utils@3.2.4':
dependencies:
@ -22298,6 +22306,10 @@ snapshots:
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.1(zod@3.25.76):
dependencies:
zod: 3.25.76
zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.25.76):
dependencies:
typescript: 5.8.3

View file

@ -1,122 +1,96 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/RooCodeInc/Roo-Code/blob/main/schemas/roomodes.json",
"title": "Roo Code Custom Modes",
"description": "Schema for .roomodes configuration files used by Roo Code to define custom modes.",
"type": "object",
"required": ["customModes"],
"additionalProperties": false,
"properties": {
"customModes": {
"type": "array",
"description": "List of custom mode definitions.",
"items": {
"$ref": "#/definitions/CustomMode"
"type": "object",
"properties": {
"slug": {
"type": "string",
"pattern": "^[a-zA-Z0-9-]+$"
},
"name": {
"type": "string",
"minLength": 1
},
"roleDefinition": {
"type": "string",
"minLength": 1
},
"whenToUse": {
"type": "string"
},
"description": {
"type": "string"
},
"customInstructions": {
"type": "string"
},
"source": {
"type": "string",
"enum": ["global", "project"]
},
"groups": {
"type": "array",
"items": {
"anyOf": [
{
"type": "string",
"enum": ["read", "edit", "command", "mcp", "modes", "browser"]
},
{
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": [
{
"type": "string",
"enum": ["read", "edit", "command", "mcp", "modes", "browser"]
},
{
"type": "object",
"properties": {
"fileRegex": {
"type": "string"
},
"description": {
"type": "string"
}
},
"additionalProperties": false
}
]
}
]
}
},
"rulesFiles": {
"type": "array",
"items": {
"type": "object",
"properties": {
"relativePath": {
"type": "string"
},
"content": {
"type": "string"
}
},
"required": ["relativePath"],
"additionalProperties": false
}
}
},
"required": ["slug", "name", "roleDefinition", "groups"],
"additionalProperties": false
}
}
},
"definitions": {
"ToolGroup": {
"type": "string",
"enum": ["read", "edit", "browser", "command", "mcp", "modes"],
"description": "A tool group name that grants the mode access to a set of tools. Note: 'browser' is deprecated but still accepted for backward compatibility."
},
"GroupOptions": {
"type": "object",
"description": "Options that restrict a tool group's file access.",
"properties": {
"fileRegex": {
"type": "string",
"description": "A regular expression pattern to restrict which files the tool group can access."
},
"description": {
"type": "string",
"description": "A human-readable description of the file restriction."
}
},
"additionalProperties": false
},
"GroupEntryTuple": {
"type": "array",
"description": "A tuple of [toolGroupName, options] for tool groups with file restrictions.",
"items": [{ "$ref": "#/definitions/ToolGroup" }, { "$ref": "#/definitions/GroupOptions" }],
"additionalItems": false,
"minItems": 2,
"maxItems": 2
},
"GroupEntry": {
"description": "A tool group permission entry. Either a simple tool group name string, or a [toolGroupName, options] tuple for groups with file restrictions.",
"oneOf": [{ "$ref": "#/definitions/ToolGroup" }, { "$ref": "#/definitions/GroupEntryTuple" }]
},
"RuleFile": {
"type": "object",
"description": "A rules file associated with a mode, used during import/export.",
"required": ["relativePath"],
"additionalProperties": false,
"properties": {
"relativePath": {
"type": "string",
"description": "The relative file path for the rules file."
},
"content": {
"type": "string",
"description": "The text content of the rules file."
}
}
},
"CustomMode": {
"type": "object",
"description": "A custom mode definition.",
"required": ["slug", "name", "roleDefinition", "groups"],
"additionalProperties": false,
"properties": {
"slug": {
"type": "string",
"pattern": "^[a-zA-Z0-9-]+$",
"description": "A unique identifier for the mode, containing only letters, numbers, and dashes."
},
"name": {
"type": "string",
"minLength": 1,
"description": "The display name of the mode."
},
"roleDefinition": {
"type": "string",
"minLength": 1,
"description": "The system prompt that defines the mode's role and behavior."
},
"whenToUse": {
"type": "string",
"description": "A description of when this mode should be used, shown in the mode selection UI."
},
"description": {
"type": "string",
"description": "A short description of the mode."
},
"customInstructions": {
"type": "string",
"description": "Additional instructions appended to the system prompt."
},
"groups": {
"type": "array",
"description": "The tool groups this mode has access to.",
"items": {
"$ref": "#/definitions/GroupEntry"
}
},
"source": {
"type": "string",
"enum": ["global", "project"],
"description": "Where this mode was defined. Automatically set by Roo Code."
},
"rulesFiles": {
"type": "array",
"description": "Rules files associated with this mode, used during import/export.",
"items": {
"$ref": "#/definitions/RuleFile"
}
}
}
}
}
"required": ["customModes"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/RooCodeInc/Roo-Code/blob/main/schemas/roomodes.json",
"title": "Roo Code Custom Modes",
"description": "Schema for .roomodes configuration files used by Roo Code to define custom modes."
}

View file

@ -1,3 +1,11 @@
/**
* Validates the generated schemas/roomodes.json against sample configurations
* using AJV. The schema itself is dynamically generated from the Zod types in
* packages/types/src/mode.ts -- see packages/types/scripts/generate-roomodes-schema.ts.
*
* A separate drift-detection test in packages/types ensures the checked-in
* schema stays in sync with the Zod source of truth.
*/
import { describe, it, expect, beforeAll } from "vitest"
import Ajv from "ajv"
import * as fs from "fs"