Skip to content

Commit 5470c60

Browse files
authored
fix(config): tighten Zod-backed surfaces after v2.12.0 review (#354)
Cleanup pass on the surfaces the post-v2.12.0 code review flagged but left as follow-up: - Convert `ValidationResult` from an interface with optional fields to a discriminated union. Callers now narrow with `if (result.success)` instead of optional chaining or `as` casts. - Isolate Zod private-internals reads (`_def.type`, `_def.innerType`, `_def.shape`) behind a new `scripts/lib/zod-internals.ts` adapter module. The codegen's required-array post-process no longer reaches into Zod's private surface inline — one place to update on a future zod major bump instead of three. - Drop the unused `isConfigSchemaError` export. No production consumers remained after v2.12.0 landed. - Add semver validation to the docs generator's `--version` flag, mirroring the sibling `generate-config-schema.ts` pattern. Empty or malformed values now exit 1 instead of being interpolated into the generated `$schema` URL. While in `generate-config-schema.ts`, the required-array walker was split into two helpers (`unwrapZodWrappers`, `pruneRequiredArray`) so the main function stays under the lint cognitive-complexity threshold after the adapter indirection lands. Test count: 710 → 723. Schema output byte-identical (drift check passes with no regeneration needed). Lint warnings: 7 → 6 across the repo.
1 parent adbd54f commit 5470c60

10 files changed

Lines changed: 239 additions & 106 deletions

AGENTS.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,6 @@ systematic/
109109
| `loadConfig` | fn | src/lib/config.ts:47 | 5 | JSONC config loading + 3-source merge |
110110
| `SystematicConfigSchema` | const | src/lib/config-schema.ts | 6 | Canonical Zod schema for user config |
111111
| `validateConfig` | fn | src/lib/config-schema.ts | 4 | Safe parse wrapper returning ValidationResult |
112-
| `isConfigSchemaError` | fn | src/lib/config.ts | 2 | Type guard for config validation errors |
113112
| `SECURITY_OVERLAY_FIELDS` | const | src/lib/config-schema.ts | 3 | Trust-protected overlay field names |
114113
| `isValidAgentColor` | fn | src/lib/agent-colors.ts | 3 | Color validator (hex or token) |
115114
| `OPENCODE_AGENT_COLOR_TOKENS` | const | src/lib/agent-colors.ts | 4 | Accepted OpenCode color token enum |

docs/scripts/generate-config-reference.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,14 @@ function renderTopLevelSection(
282282
* using the same fallback chain as the JSON Schema generator.
283283
* @returns The full .mdx content as a string.
284284
*/
285+
const SEMVER_REGEX = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/
286+
285287
export function generateConfigReference(version?: string): string {
288+
if (version !== undefined && !SEMVER_REGEX.test(version)) {
289+
throw new Error(
290+
`Error: Invalid version format "${version}". Must be valid semver (e.g., 3.0.0)`,
291+
)
292+
}
286293
const resolvedVersion = version ?? resolveVersion(null, PROJECT_ROOT)
287294
const major = getMajorVersion(resolvedVersion)
288295
const schemaUrl = SCHEMA_ID_TEMPLATE.replace('%MAJOR%', major)

scripts/generate-config-schema.ts

Lines changed: 93 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ import path from 'node:path'
2121
import { fileURLToPath } from 'node:url'
2222
import { z } from 'zod'
2323
import { SystematicConfigSchema } from '../src/lib/config-schema.js'
24+
import {
25+
getZodDefaultInnerType,
26+
getZodObjectShape,
27+
getZodTypeName,
28+
} from './lib/zod-internals.js'
2429

2530
/**
2631
* Run Biome's formatter over JSON content via stdin. Used to keep the
@@ -59,6 +64,53 @@ const SEMVER_REGEX = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/
5964
export const SCHEMA_ID_TEMPLATE =
6065
'https://fro.bot/systematic/schemas/v%MAJOR%/systematic-config.schema.json'
6166

67+
function resolveVersionFromGit(rootDir: string): string | null {
68+
// Prefer `describe` (locates a tag in the commit's ancestry), then fall
69+
// back to listing all semver-shaped tags. The fallback covers two real
70+
// CI cases that `git describe` cannot:
71+
// - shallow checkout where the tagged commit isn't in local history
72+
// - PR synthetic merge commits that aren't ancestors of any tag
73+
for (const cmd of [
74+
'git describe --tags --abbrev=0',
75+
'git tag -l "v*.*.*" --sort=-v:refname',
76+
]) {
77+
let tag = ''
78+
try {
79+
const out = execSync(cmd, { encoding: 'utf-8', cwd: rootDir }).trim()
80+
tag = out.split('\n')[0]?.trim() ?? ''
81+
} catch {
82+
continue
83+
}
84+
if (tag.length === 0) continue
85+
const normalized = tag.startsWith('v') ? tag.slice(1) : tag
86+
if (SEMVER_REGEX.test(normalized)) return normalized
87+
throw new Error(
88+
`Error: Invalid git tag format "${tag}". Expected semver or v-prefixed semver (e.g., v1.2.3)`,
89+
)
90+
}
91+
return null
92+
}
93+
94+
function resolveVersionFromPackageJson(rootDir: string): string | null {
95+
const pkgPath = path.join(rootDir, 'package.json')
96+
try {
97+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as {
98+
version?: string
99+
}
100+
const v = pkg.version
101+
if (
102+
typeof v === 'string' &&
103+
v.length > 0 &&
104+
!v.includes('semantic-release')
105+
) {
106+
return v
107+
}
108+
} catch {
109+
// fall through
110+
}
111+
return null
112+
}
113+
62114
/**
63115
* Resolve the version to use for schema generation.
64116
*
@@ -86,48 +138,11 @@ export function resolveVersion(
86138
return explicit
87139
}
88140

89-
// Try git: prefer describe (locates a tag in the commit's ancestry),
90-
// fall back to listing all semver-shaped tags sorted by version. The
91-
// fallback covers two real CI cases that `git describe` cannot:
92-
// - shallow checkout where the tagged commit isn't in local history
93-
// - PR synthetic merge commits that aren't ancestors of any tag
94-
for (const cmd of [
95-
'git describe --tags --abbrev=0',
96-
'git tag -l "v*.*.*" --sort=-v:refname',
97-
]) {
98-
try {
99-
const out = execSync(cmd, { encoding: 'utf-8', cwd: rootDir }).trim()
100-
const tag = out.split('\n')[0]?.trim() ?? ''
101-
if (tag.length === 0) continue
102-
const normalized = tag.startsWith('v') ? tag.slice(1) : tag
103-
if (SEMVER_REGEX.test(normalized)) {
104-
return normalized
105-
}
106-
throw new Error(
107-
`Error: Invalid git tag format "${tag}". Expected semver or v-prefixed semver (e.g., v1.2.3)`,
108-
)
109-
} catch {
110-
// try next strategy
111-
}
112-
}
141+
const fromGit = resolveVersionFromGit(rootDir)
142+
if (fromGit != null) return fromGit
113143

114-
// Try package.json
115-
const pkgPath = path.join(rootDir, 'package.json')
116-
try {
117-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as {
118-
version?: string
119-
}
120-
const v = pkg.version
121-
if (
122-
typeof v === 'string' &&
123-
v.length > 0 &&
124-
!v.includes('semantic-release')
125-
) {
126-
return v
127-
}
128-
} catch {
129-
// package.json read failed, fall through to error
130-
}
144+
const fromPkg = resolveVersionFromPackageJson(rootDir)
145+
if (fromPkg != null) return fromPkg
131146

132147
throw new Error(
133148
'Error: No resolvable version. Specify --version <semver>, ensure a git tag exists, or set a valid semver in package.json.',
@@ -144,53 +159,51 @@ export function getMajorVersion(version: string): string {
144159
}
145160

146161
/**
147-
* Walk a JSON Schema object tree and remove fields from `required` arrays
148-
* when the corresponding Zod shape field has a `.default()` wrapper.
149-
*
150-
* A field with a runtime default is optional from the user's perspective:
151-
* Zod fills it in if absent. Leaving it in `required` causes IDEs to
152-
* redline valid minimal configs that the runtime accepts fine.
153-
*
154-
* Handles both the top-level object and nested objects (e.g., `bootstrap`).
155-
*
156-
* @param jsonNode The JSON Schema object node to process (mutated in-place)
157-
* @param zodNode The corresponding Zod schema node (used to detect defaults)
162+
* Unwrap `ZodDefault` / `ZodOptional` wrappers to reach the underlying schema
163+
* (typically a `ZodObject`). Stops as soon as the inner type is neither.
158164
*/
159-
function removeDefaultFieldsFromRequired(
165+
function unwrapZodWrappers(schema: z.ZodType): z.ZodType {
166+
let inner: z.ZodType = schema
167+
for (;;) {
168+
const typeName = getZodTypeName(inner)
169+
if (typeName !== 'default' && typeName !== 'optional') return inner
170+
const next = getZodDefaultInnerType(inner)
171+
if (next == null) return inner
172+
inner = next
173+
}
174+
}
175+
176+
/**
177+
* Drop keys from `jsonNode.required` whose Zod field has a runtime default.
178+
* Removes the `required` key entirely when nothing remains.
179+
*/
180+
function pruneRequiredArray(
160181
jsonNode: Record<string, unknown>,
161-
zodNode: z.ZodType,
182+
shape: Record<string, z.ZodType>,
162183
): void {
163-
// Unwrap ZodDefault / ZodOptional wrappers to reach the inner ZodObject.
164-
let inner: z.ZodType = zodNode
165-
for (;;) {
166-
const def = inner._def as { type?: string; innerType?: z.ZodType }
167-
if (def.type === 'default' || def.type === 'optional') {
168-
inner = def.innerType as z.ZodType
169-
} else {
170-
break
171-
}
184+
if (!Array.isArray(jsonNode.required)) return
185+
const filtered = (jsonNode.required as string[]).filter((key) => {
186+
const field = shape[key]
187+
if (field == null) return true
188+
return getZodTypeName(field) !== 'default'
189+
})
190+
if (filtered.length === 0) {
191+
delete jsonNode.required
192+
} else {
193+
jsonNode.required = filtered
172194
}
195+
}
173196

174-
// Only ZodObject has ._def.shape; skip everything else (ZodRecord, ZodArray, …)
197+
function removeDefaultFieldsFromRequired(
198+
jsonNode: Record<string, unknown>,
199+
zodNode: z.ZodType,
200+
): void {
201+
// Only ZodObject has a shape; skip everything else (ZodRecord, ZodArray, …)
175202
// In this Zod 4 build, shape is a plain object (not a lazy function).
176-
const innerDef = inner._def as { shape?: Record<string, z.ZodType> }
177-
const shape = innerDef.shape
203+
const shape = getZodObjectShape(unwrapZodWrappers(zodNode))
178204
if (shape == null) return
179205

180-
// Remove keys from `required` when the Zod shape field is ZodDefault
181-
if (Array.isArray(jsonNode.required)) {
182-
const filtered = (jsonNode.required as string[]).filter((key) => {
183-
const field = shape[key]
184-
if (field == null) return true
185-
const fieldDef = field._def as { type?: string }
186-
return fieldDef.type !== 'default'
187-
})
188-
if (filtered.length === 0) {
189-
delete jsonNode.required
190-
} else {
191-
jsonNode.required = filtered
192-
}
193-
}
206+
pruneRequiredArray(jsonNode, shape)
194207

195208
// Recurse into `properties` to catch nested object schemas (e.g., `bootstrap`)
196209
const props = jsonNode.properties as

scripts/lib/zod-internals.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Thin adapters over zod private internals (`_def`). Isolated here so a future
3+
* zod major upgrade only needs to change this file. All other code should use
4+
* these functions instead of reading `_def` directly.
5+
*
6+
* Pinned to `zod@4.4.3` exact in package.json; the dependency-version comment
7+
* here is the canary — bump zod, re-verify these adapters, then bump the pin.
8+
*/
9+
import type { z } from 'zod'
10+
11+
export function getZodTypeName(schema: z.ZodType): string | undefined {
12+
return (schema as { _def?: { type?: string } })._def?.type
13+
}
14+
15+
export function getZodDefaultInnerType(
16+
schema: z.ZodType,
17+
): z.ZodType | undefined {
18+
return (schema as { _def?: { innerType?: z.ZodType } })._def?.innerType
19+
}
20+
21+
export function getZodObjectShape(
22+
schema: z.ZodType,
23+
): Record<string, z.ZodType> | undefined {
24+
return (schema as { _def?: { shape?: Record<string, z.ZodType> } })._def
25+
?.shape
26+
}

src/lib/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ All discovery follows same pattern: `dir → walkDir() → find files → parseF
4141

4242
| Module | Key Exports | Role |
4343
|--------|-------------|------|
44-
| `config.ts` | `loadConfig`, `getConfigPaths`, `SystematicConfig`, `DEFAULT_CONFIG`, `isConfigSchemaError` | JSONC config loading + merging |
44+
| `config.ts` | `loadConfig`, `getConfigPaths`, `SystematicConfig`, `DEFAULT_CONFIG` | JSONC config loading + merging |
4545
| `config-schema.ts` | `SystematicConfigSchema`, `validateConfig`, `SECURITY_OVERLAY_FIELDS`, `AgentOverlaySchema`, `CategoryOverlaySchema`, `BootstrapSchema` | Canonical Zod schema for user config; security field list |
4646
| `agent-colors.ts` | `isValidAgentColor`, `OPENCODE_AGENT_COLOR_TOKENS` | Color validator (hex or named token) + accepted token enum |
4747
| `config-handler.ts` | `createConfigHandler`, `ConfigHandlerDeps`, `formatAgentDescription`, `toTitleCase` | OpenCode config hook (collects + converts all assets) |

src/lib/config-schema.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -281,11 +281,9 @@ export const SystematicConfigSchema = z
281281
examples: [{ disabled_skills: ['ce:plan'], bootstrap: { enabled: false } }],
282282
})
283283

284-
export interface ValidationResult {
285-
success: boolean
286-
data?: z.infer<typeof SystematicConfigSchema>
287-
errors?: readonly z.ZodIssue[]
288-
}
284+
export type ValidationResult =
285+
| { success: true; data: z.infer<typeof SystematicConfigSchema> }
286+
| { success: false; errors: readonly z.ZodIssue[] }
289287

290288
export function validateConfig(input: unknown): ValidationResult {
291289
const result = SystematicConfigSchema.safeParse(input)

src/lib/config.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -157,23 +157,6 @@ function throwTopLevelConfigSchemaError(
157157
})
158158
}
159159

160-
/**
161-
* Type guard for errors thrown by the top-level config schema validator.
162-
* Use this to distinguish schema validation failures from JSONC parse errors
163-
* or file read errors.
164-
*/
165-
export function isConfigSchemaError(err: unknown): err is Error & {
166-
_tag: 'ConfigSchemaError'
167-
filePath: string
168-
trust: ConfigSource['trust']
169-
issues: readonly z.core.$ZodIssue[]
170-
} {
171-
return (
172-
err instanceof Error &&
173-
(err as unknown as { _tag: unknown })._tag === 'ConfigSchemaError'
174-
)
175-
}
176-
177160
function loadConfigSource(
178161
filePath: string,
179162
trust: ConfigSource['trust'],

tests/unit/config-schema.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ describe('validateConfig wrapper', () => {
313313
expect(result.success).toBe(true)
314314
if (result.success) {
315315
expect(result.data).toBeDefined()
316-
expect(result.data?.bootstrap.enabled).toBe(true)
316+
expect(result.data.bootstrap.enabled).toBe(true)
317317
}
318318
})
319319

@@ -322,7 +322,30 @@ describe('validateConfig wrapper', () => {
322322
expect(result.success).toBe(false)
323323
if (!result.success) {
324324
expect(result.errors).toBeDefined()
325-
expect(result.errors?.length ?? 0).toBeGreaterThan(0)
325+
expect(result.errors.length).toBeGreaterThan(0)
326+
}
327+
})
328+
329+
test('discriminated union: success branch narrows data without optional chaining', () => {
330+
const result = validateConfig({})
331+
if (result.success) {
332+
// After narrowing via result.success, result.data is non-optional.
333+
// This test compiles only if ValidationResult is a discriminated union.
334+
const enabled: boolean = result.data.bootstrap.enabled
335+
expect(enabled).toBe(true)
336+
} else {
337+
throw new Error('Expected success')
338+
}
339+
})
340+
341+
test('discriminated union: failure branch narrows errors without optional chaining', () => {
342+
const result = validateConfig({ foo: 'bar' })
343+
if (!result.success) {
344+
// After narrowing via !result.success, result.errors is non-optional.
345+
const count: number = result.errors.length
346+
expect(count).toBeGreaterThan(0)
347+
} else {
348+
throw new Error('Expected failure')
326349
}
327350
})
328351
})

tests/unit/generate-config-reference.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,3 +481,32 @@ describe('schema-described field parity', () => {
481481
}
482482
})
483483
})
484+
485+
describe('--version flag semver validation', () => {
486+
let generateFn: (version?: string) => string
487+
488+
beforeAll(async () => {
489+
const mod = await import('../../docs/scripts/generate-config-reference.js')
490+
generateFn = mod.generateConfigReference
491+
})
492+
493+
test('valid semver is accepted and produces the expected schema URL', () => {
494+
const mdx = generateFn('3.0.0')
495+
expect(mdx).toContain('v3')
496+
})
497+
498+
test('pre-release semver is accepted', () => {
499+
const mdx = generateFn('3.0.0-alpha.1')
500+
expect(mdx).toContain('v3')
501+
})
502+
503+
test('empty version string is rejected with semver error', () => {
504+
expect(() => generateFn('')).toThrow('Invalid version format ""')
505+
})
506+
507+
test('garbage version string is rejected with semver error', () => {
508+
expect(() => generateFn('garbage')).toThrow(
509+
'Invalid version format "garbage"',
510+
)
511+
})
512+
})

0 commit comments

Comments
 (0)