Skip to main content

supercode_harness/
config_schema.rs

1//! BP-9 (D6 row "Published JSON schema for config", cc§6): the published
2//! JSON Schema for the supercode config file — `.supercode.toml`,
3//! `~/.config/supercode/config.toml`, and the JSON mirror
4//! ([`HarnessConfig::from_json_str`]).
5//!
6//! **Why a table and not a derive.** The schema is generated from
7//! [`CONFIG_SCHEMA_FIELDS`], a flat list of `(dotted path, kind,
8//! description)`. That table would rot silently — except that
9//! [`tests::schema_covers_exactly_the_parsed_keys`] compares it against the
10//! keys serde ITSELF emits for a default [`HarnessConfig`], in both
11//! directions, and [`tests::every_schema_key_parses_with_its_declared_type`]
12//! feeds each declared path back through the real parser at its declared
13//! type. Adding a field to `CoreSection` without touching this table fails
14//! the first test; declaring a wrong type fails the second. So the schema is
15//! bound to the serde types by the test suite rather than by a derive macro —
16//! no new dependency, same guarantee, and the descriptions are written for a
17//! human reading a tooltip in their editor.
18//!
19//! The generated document is checked in at
20//! `docs/schema/supercode-config.schema.json` (regenerate with
21//! `supercode config schema --write`); a test asserts the committed file is
22//! byte-identical to what this module generates.
23
24use serde_json::{json, Map, Value};
25
26use crate::configfile::{HarnessConfig, MODULE_NAMES};
27
28/// Where the committed schema lives, workspace-relative.
29pub const CONFIG_SCHEMA_PATH: &str = "docs/schema/supercode-config.schema.json";
30
31/// Canonical URL for the published schema — what a config file's `$schema`
32/// key should point at.
33pub const CONFIG_SCHEMA_URL: &str =
34    "https://raw.githubusercontent.com/volter-ai/supercode/main/docs/schema/supercode-config.schema.json";
35
36/// The JSON type a config key carries.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Kind {
39    /// A string.
40    Str,
41    /// An integer.
42    Int,
43    /// A float.
44    Num,
45    /// A boolean.
46    Bool,
47    /// An array of strings.
48    StrArray,
49    /// An object whose values are strings (a free-form table).
50    StrMap,
51    /// An object whose values may be anything (a free-form table).
52    AnyMap,
53    /// `[capabilities.*]` — module name → capability table.
54    CapabilityMap,
55}
56
57impl Kind {
58    fn schema(self) -> Value {
59        match self {
60            Kind::Str => json!({ "type": "string" }),
61            Kind::Int => json!({ "type": "integer" }),
62            Kind::Num => json!({ "type": "number" }),
63            Kind::Bool => json!({ "type": "boolean" }),
64            Kind::StrArray => json!({ "type": "array", "items": { "type": "string" } }),
65            Kind::StrMap => {
66                json!({ "type": "object", "additionalProperties": { "type": "string" } })
67            }
68            Kind::AnyMap => json!({ "type": "object" }),
69            Kind::CapabilityMap => json!({
70                "type": "object",
71                "propertyNames": { "enum": MODULE_NAMES },
72                "additionalProperties": {
73                    "type": "object",
74                    "properties": {
75                        "enabled": {
76                            "type": "boolean",
77                            "description": "Module master switch (§3.0: every capability table has `enabled`)."
78                        }
79                    },
80                    "description": "A §2 capability module's table: `enabled` plus that module's own settings."
81                }
82            }),
83        }
84    }
85
86    /// A TOML literal of this kind, for the round-trip test.
87    #[cfg(test)]
88    fn sample_toml(self) -> &'static str {
89        match self {
90            Kind::Str => "\"x\"",
91            Kind::Int => "1",
92            Kind::Num => "1.5",
93            Kind::Bool => "true",
94            Kind::StrArray => "[\"x\"]",
95            Kind::StrMap => "{ k = \"v\" }",
96            Kind::AnyMap => "{ k = 1 }",
97            Kind::CapabilityMap => "{ permissions = { enabled = true } }",
98        }
99    }
100}
101
102/// One config key: its dotted path, its JSON type, and the one-line
103/// description an editor shows.
104#[derive(Debug, Clone, Copy)]
105pub struct Field {
106    /// Dotted path from the document root (`core.tools.bash.timeout_secs`).
107    pub path: &'static str,
108    /// The value's JSON type.
109    pub kind: Kind,
110    /// Editor-facing description.
111    pub description: &'static str,
112}
113
114const fn f(path: &'static str, kind: Kind, description: &'static str) -> Field {
115    Field {
116        path,
117        kind,
118        description,
119    }
120}
121
122/// Every key the config parser accepts, in document order. Kept honest by
123/// the tests at the bottom of this file — see the module doc comment.
124pub const CONFIG_SCHEMA_FIELDS: &[Field] = &[
125    f(
126        "$schema",
127        Kind::Str,
128        "Pointer to this JSON Schema, so editors validate the file. Declarative only — supercode never fetches it.",
129    ),
130    f(
131        "schema_version",
132        Kind::Int,
133        "Config schema version. `1` is the only version this build understands; anything else is rejected rather than reinterpreted.",
134    ),
135    f(
136        "extends",
137        Kind::Str,
138        "A built-in preset name (`cc-parity`, `cx-parity`, `supercode-default`, …) or, in the user/global layer only, a path to another config file.",
139    ),
140    f(
141        "core.model",
142        Kind::Str,
143        "Model id or alias for the main loop.",
144    ),
145    f(
146        "core.base_url",
147        Kind::Str,
148        "OpenAI-compatible endpoint. Supports `${VAR}` / `${VAR:-default}` / `{file:…}` substitution. [project-forbidden]",
149    ),
150    f(
151        "core.api_key_env",
152        Kind::Str,
153        "Environment variable name the API key is read from. [project-forbidden]",
154    ),
155    f(
156        "core.api_key_cmd",
157        Kind::Str,
158        "Credential helper: a shell command whose trimmed stdout is the API key. [project-forbidden]",
159    ),
160    f(
161        "core.api_key_command",
162        Kind::StrArray,
163        "Credential helper as argv (exec'd directly, no shell); its trimmed stdout is the API key. Consulted before `api_key_cmd`. [project-forbidden]",
164    ),
165    f(
166        "core.update_check",
167        Kind::Bool,
168        "Check for a newer release at startup. Opt-in: absent/false means no startup network access.",
169    ),
170    f(
171        "core.effort",
172        Kind::Str,
173        "Reasoning-effort level passed to the provider (`low` | `medium` | `high`).",
174    ),
175    f(
176        "core.temperature",
177        Kind::Num,
178        "Sampling temperature.",
179    ),
180    f(
181        "core.max_tokens",
182        Kind::Int,
183        "Max output tokens per model turn.",
184    ),
185    f(
186        "core.max_iterations",
187        Kind::Int,
188        "Per-run tool-use iteration budget (must be >= 1).",
189    ),
190    f(
191        "core.max_total_output_tokens",
192        Kind::Int,
193        "Cap on cumulative completion tokens across one run; 0/absent = off.",
194    ),
195    f(
196        "core.max_budget_usd",
197        Kind::Num,
198        "Cap on the cumulative dollar cost of one run; 0/absent = off. Refused at startup for a model this build cannot price.",
199    ),
200    f(
201        "core.max_steps",
202        Kind::Int,
203        "Cap on the number of tool calls executed across one run; 0/absent = off. Distinct from `max_iterations` (model round-trips).",
204    ),
205    f(
206        "core.price_input_per_mtok",
207        Kind::Num,
208        "Dollars per million input tokens for this model, overriding the built-in price table. Set together with `price_output_per_mtok`.",
209    ),
210    f(
211        "core.price_output_per_mtok",
212        Kind::Num,
213        "Dollars per million output tokens for this model.",
214    ),
215    f(
216        "core.max_tool_output_bytes",
217        Kind::Int,
218        "Truncation cap on a single tool result.",
219    ),
220    f(
221        "core.parallel_tool_calls",
222        Kind::Bool,
223        "Execute independent tool calls from one turn concurrently.",
224    ),
225    f(
226        "core.tool_output_spill",
227        Kind::Bool,
228        "Write a truncated tool result's full bytes to a per-session spill file the model can read back.",
229    ),
230    f(
231        "core.shell_env_snapshot",
232        Kind::Bool,
233        "Snapshot the login shell's environment for shell tool calls.",
234    ),
235    f(
236        "core.system_prompt",
237        Kind::Str,
238        "Replace the system prompt. Supports `${VAR}` / `{file:…}` substitution. [project-forbidden]",
239    ),
240    f(
241        "core.append_system_prompt",
242        Kind::Str,
243        "Append to the system prompt rather than replacing it. [project-forbidden]",
244    ),
245    f(
246        "core.project_context",
247        Kind::Bool,
248        "Auto-load CLAUDE.md / AGENTS.md instruction files.",
249    ),
250    f(
251        "core.env_context",
252        Kind::Bool,
253        "Append an `# Environment` block (cwd, platform, date, git branch at the project root).",
254    ),
255    f(
256        "core.context_injections",
257        Kind::Bool,
258        "Append the configured synthetic context blocks to the system prompt.",
259    ),
260    f(
261        "core.nested_instructions",
262        Kind::Bool,
263        "Load instruction files from subdirectories on demand.",
264    ),
265    f(
266        "core.instruction_imports",
267        Kind::Bool,
268        "Expand `@relative/path` imports inside instruction files.",
269    ),
270    f(
271        "core.project_root_markers",
272        Kind::StrArray,
273        "Filenames/directories that mark the project root; every root walk stops at the first one. Defaults to [\".git\"].",
274    ),
275    f(
276        "core.hot_reload",
277        Kind::Bool,
278        "Reserved: live-apply config edits without restart. Parsed and round-tripped, with no consumer in this build.",
279    ),
280    f(
281        "core.project_doc_max_bytes",
282        Kind::Int,
283        "Hygiene cap on the total bytes of assembled instruction-file content.",
284    ),
285    f(
286        "core.project_doc_excludes",
287        Kind::StrArray,
288        "Glob/path patterns naming instruction files to skip when assembling project context.",
289    ),
290    f(
291        "core.project_doc_strip_comments",
292        Kind::Bool,
293        "Drop `<!-- … -->` spans from instruction files before injecting them.",
294    ),
295    f(
296        "core.file_mentions",
297        Kind::Bool,
298        "Expand `@path` tokens in a prompt into that file's contents, subject to the \
299         permission engine's read rules.",
300    ),
301    f(
302        "core.output_style",
303        Kind::Str,
304        "Named response-style layer appended to the system prompt (a built-in style, or a \
305         markdown file under the harness's own output-style roots).",
306    ),
307    f(
308        "core.path_rules",
309        Kind::Bool,
310        "Load `.claude/rules/*.md` rule files; a rule with `paths:` frontmatter is injected \
311         only when a tool touches a matching file.",
312    ),
313    f(
314        "core.additional_dirs",
315        Kind::StrArray,
316        "Extra roots tools may access. A project layer may only add contained relative paths.",
317    ),
318    f(
319        "core.extra_headers",
320        Kind::StrMap,
321        "Extra HTTP headers on every provider request. Values support substitution. [project-forbidden]",
322    ),
323    f(
324        "core.extra_body",
325        Kind::AnyMap,
326        "Extra JSON merged into every provider request body. [project-forbidden]",
327    ),
328    f(
329        "core.doom_loop_threshold",
330        Kind::Int,
331        "Break the run after this many identical repeated tool calls; absent = off.",
332    ),
333    f(
334        "core.model_switch.allow_switch",
335        Kind::Bool,
336        "Allow switching models mid-session (recorded as a `model_change` event).",
337    ),
338    f(
339        "core.model_switch.notice",
340        Kind::Bool,
341        "On a mid-session model change, splice a notice into the conversation so the incoming model reads the handoff.",
342    ),
343    f(
344        "core.retry.enabled",
345        Kind::Bool,
346        "Retry failed provider requests.",
347    ),
348    f(
349        "core.retry.max_retries",
350        Kind::Int,
351        "Maximum retry attempts.",
352    ),
353    f(
354        "core.retry.base_delay_ms",
355        Kind::Int,
356        "Base backoff delay in milliseconds (doubles per attempt).",
357    ),
358    f(
359        "core.tools.enabled",
360        Kind::StrArray,
361        "The default-active built-in tool names.",
362    ),
363    f(
364        "core.tools.schema_tier",
365        Kind::Str,
366        "Global advertised-schema tier (`full` | `medium` | `minimal`).",
367    ),
368    f(
369        "core.tools.read_file.multimodal",
370        Kind::Bool,
371        "Allow `read_file` to return image, PDF and notebook content as model-visible content.",
372    ),
373    f(
374        "core.tools.read_file.line_numbers",
375        Kind::Bool,
376        "Number `read_file` output `cat -n` style, from the requested offset.",
377    ),
378    f(
379        "core.tools.edit_file.require_read_before_edit",
380        Kind::Bool,
381        "Reject an edit to a path this session has not read.",
382    ),
383    f(
384        "core.tools.edit_file.notebook_aware",
385        Kind::Bool,
386        "Edit notebook cells as cells rather than as raw JSON.",
387    ),
388    f(
389        "core.tools.edit_file.schema_tier",
390        Kind::Str,
391        "Per-tool schema-tier override for `edit_file`.",
392    ),
393    f(
394        "core.tools.bash.enabled",
395        Kind::Bool,
396        "Register the `bash` tool.",
397    ),
398    f(
399        "core.tools.bash.description",
400        Kind::Str,
401        "Override the `bash` tool's advertised description.",
402    ),
403    f(
404        "core.tools.bash.schema_tier",
405        Kind::Str,
406        "Per-tool schema-tier override for `bash`.",
407    ),
408    f(
409        "core.tools.bash.timeout_secs",
410        Kind::Int,
411        "Per-command timeout for the `bash` tool.",
412    ),
413    f(
414        "core.skills.enabled",
415        Kind::Bool,
416        "Enable the skills subsystem.",
417    ),
418    f(
419        "core.skills.dirs",
420        Kind::StrArray,
421        "Extra skill roots, merged over the user + project defaults.",
422    ),
423    f(
424        "core.skills.harness",
425        Kind::Str,
426        "Whose documented skill-root table the loop discovers SKILL.md packages from \
427         (`claude-code`, `codex`, `opencode`, `pi`, `hermes`, `openclaw`).",
428    ),
429    f(
430        "core.skills.implicit_match",
431        Kind::Bool,
432        "Also load a skill's body when a message merely describes it, not only on an \
433         explicit `$slug` mention or `/name` invocation.",
434    ),
435    f(
436        "core.skills.shell_injection",
437        Kind::Bool,
438        "Execute `` !`cmd` `` inside a skill/command body when the body is loaded, through \
439         the permissions engine. Off leaves the token as literal text.",
440    ),
441    f(
442        "core.prompts",
443        Kind::StrMap,
444        "Named prompt/skill templates, merged key-wise onto the built-ins. [project-forbidden]",
445    ),
446    f(
447        "core.compaction.enabled",
448        Kind::Bool,
449        "Master switch for automatic history compaction.",
450    ),
451    f(
452        "core.compaction.after_messages",
453        Kind::Int,
454        "Compact once the history exceeds this many messages.",
455    ),
456    f(
457        "core.compaction.reserve_tokens",
458        Kind::Int,
459        "Token headroom compaction aims to leave free.",
460    ),
461    f(
462        "core.compaction.keep_recent_tokens",
463        Kind::Int,
464        "Recent-history tokens compaction never touches.",
465    ),
466    f(
467        "core.compaction.summarize",
468        Kind::Bool,
469        "Summarize compacted spans with a side model call instead of dropping them.",
470    ),
471    f(
472        "core.compaction.focus_instructions",
473        Kind::Str,
474        "Instructions steering what a compaction summary keeps. [project-forbidden]",
475    ),
476    f(
477        "core.session.dir",
478        Kind::Str,
479        "Session-store location. [project-forbidden]",
480    ),
481    f(
482        "core.session.name",
483        Kind::Str,
484        "Default session name. [project-forbidden]",
485    ),
486    f(
487        "core.session.persist",
488        Kind::Bool,
489        "Persist sessions; false = ephemeral. [project-forbidden]",
490    ),
491    f(
492        "core.session.retention_days",
493        Kind::Int,
494        "Retention window `sessions prune` enforces. [project-forbidden]",
495    ),
496    f(
497        "core.session.export_format",
498        Kind::Str,
499        "Human transcript export format (`text` | `html`). [project-forbidden]",
500    ),
501    f(
502        "core.session.auto_title",
503        Kind::Bool,
504        "Title a session automatically after the first exchange.",
505    ),
506    f(
507        "core.session.git_metadata",
508        Kind::Bool,
509        "Record git branch/sha with each session write. [project-forbidden]",
510    ),
511    f(
512        "core.session.append_only",
513        Kind::Bool,
514        "Flush every message to the session journal as it is produced. [project-forbidden]",
515    ),
516    f(
517        "core.session.queue_persist",
518        Kind::Bool,
519        "Record pending steering/follow-up inputs in the journal so they survive a restart. [project-forbidden]",
520    ),
521    f(
522        "core.steering.steering_mode",
523        Kind::Str,
524        "How queued steering input is delivered (`all` | `one-at-a-time`).",
525    ),
526    f(
527        "core.steering.follow_up_mode",
528        Kind::Str,
529        "How queued follow-up turns are delivered (`all` | `one-at-a-time`).",
530    ),
531    f(
532        "core.output.format",
533        Kind::Str,
534        "Default output format (`text` | `json`).",
535    ),
536    f(
537        "capabilities",
538        Kind::CapabilityMap,
539        "The §2 capability modules, keyed by module name.",
540    ),
541    f(
542        "experimental",
543        Kind::AnyMap,
544        "Staged feature-flag gates. `supercode features list` shows every flag this build knows and its stage.",
545    ),
546];
547
548/// Generate the published JSON Schema.
549pub fn config_schema() -> Value {
550    let mut root = Map::new();
551    for field in CONFIG_SCHEMA_FIELDS {
552        let mut leaf = field.kind.schema();
553        if let Some(obj) = leaf.as_object_mut() {
554            obj.insert(
555                "description".into(),
556                Value::String(field.description.into()),
557            );
558        }
559        insert_at(&mut root, field.path, leaf);
560    }
561    json!({
562        "$schema": "https://json-schema.org/draft/2020-12/schema",
563        "$id": CONFIG_SCHEMA_URL,
564        "title": "supercode config",
565        "description":
566            "The single supercode config file (COMPOSABLE-HARNESS-DESIGN.md §3.1): \
567             `.supercode.toml`, `.supercode.local.toml`, \
568             `~/.config/supercode/config.toml`, or the JSON mirror. \
569             Keys marked [project-forbidden] are stripped from a project-layer file \
570             (§3.3 monotonic tightening): a repo may narrow the harness, never widen \
571             or redirect it.",
572        "type": "object",
573        "additionalProperties": false,
574        "properties": Value::Object(root),
575    })
576}
577
578/// The schema as it is written to disk (pretty JSON, trailing newline).
579pub fn config_schema_json() -> String {
580    format!(
581        "{}\n",
582        serde_json::to_string_pretty(&config_schema()).expect("schema serializes")
583    )
584}
585
586/// Place `leaf` at the dotted `path`, creating intermediate object schemas
587/// (`type: object`, `additionalProperties: false`) as it goes.
588fn insert_at(root: &mut Map<String, Value>, path: &str, leaf: Value) {
589    let parts: Vec<&str> = path.split('.').collect();
590    let (last, parents) = parts.split_last().expect("non-empty path");
591    let mut cursor = root;
592    for part in parents {
593        let entry = cursor.entry((*part).to_string()).or_insert_with(
594            || json!({ "type": "object", "additionalProperties": false, "properties": {} }),
595        );
596        cursor = entry
597            .as_object_mut()
598            .expect("intermediate schema node is an object")
599            .entry("properties".to_string())
600            .or_insert_with(|| Value::Object(Map::new()))
601            .as_object_mut()
602            .expect("properties is an object");
603    }
604    cursor.insert((*last).to_string(), leaf);
605}
606
607/// Every dotted key path serde emits for a default [`HarnessConfig`] — the
608/// parser's own view of what the document contains. An empty object is a
609/// free-form table (`capabilities`, `experimental`, `core.prompts`) and
610/// therefore a leaf.
611pub fn parsed_key_paths() -> Vec<String> {
612    let value = serde_json::to_value(HarnessConfig::default()).expect("default config serializes");
613    let mut out = Vec::new();
614    collect_paths("", &value, &mut out);
615    out.sort();
616    out
617}
618
619fn collect_paths(prefix: &str, value: &Value, out: &mut Vec<String>) {
620    match value {
621        Value::Object(map) if !map.is_empty() => {
622            for (k, v) in map {
623                let path = if prefix.is_empty() {
624                    k.clone()
625                } else {
626                    format!("{prefix}.{k}")
627                };
628                collect_paths(&path, v, out);
629            }
630        }
631        _ if !prefix.is_empty() => out.push(prefix.to_string()),
632        _ => {}
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use std::collections::BTreeSet;
640    use std::path::PathBuf;
641
642    fn workspace_root() -> PathBuf {
643        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
644            .join("../..")
645            .canonicalize()
646            .unwrap()
647    }
648
649    /// dev/01 (D6 "Published JSON schema for config"): the schema and the
650    /// parser describe the SAME document — every key the parser accepts is
651    /// in the schema, and every schema key is one the parser emits. Adding
652    /// a `CoreSection` field without a schema entry fails here.
653    #[test]
654    fn schema_covers_exactly_the_parsed_keys() {
655        let declared: BTreeSet<String> = CONFIG_SCHEMA_FIELDS
656            .iter()
657            .map(|f| f.path.to_string())
658            .collect();
659        let parsed: BTreeSet<String> = parsed_key_paths().into_iter().collect();
660        let missing: Vec<&String> = parsed.difference(&declared).collect();
661        let extra: Vec<&String> = declared.difference(&parsed).collect();
662        assert!(
663            missing.is_empty(),
664            "keys the parser accepts but the schema omits: {missing:?}"
665        );
666        assert!(
667            extra.is_empty(),
668            "keys the schema declares but the parser never emits: {extra:?}"
669        );
670    }
671
672    /// dev/01: the declared TYPE is the parser's type. Each schema key is
673    /// fed back through the real TOML parser at its declared kind, and the
674    /// resulting document must also survive strict mode (no unknown keys).
675    #[test]
676    fn every_schema_key_parses_with_its_declared_type() {
677        for field in CONFIG_SCHEMA_FIELDS {
678            // `extends` is the one key whose VALUE is resolved (a preset
679            // name or a path), so its sample has to name a real preset —
680            // every other key's value is inert to the resolver.
681            let literal = if field.path == "extends" {
682                "\"supercode-default\""
683            } else {
684                field.kind.sample_toml()
685            };
686            let doc = toml_document(field.path, literal);
687            HarnessConfig::from_toml_str(&doc).unwrap_or_else(|e| {
688                panic!("{}: schema type rejected by parser: {e}\n{doc}", field.path)
689            });
690            let resolved = crate::configfile::resolve(
691                &doc,
692                None,
693                &crate::configfile::ResolveOptions { strict: true },
694            );
695            assert!(
696                resolved.is_ok(),
697                "{}: strict resolve rejected its own schema key: {:?}",
698                field.path,
699                resolved.err().map(|e| e.to_string())
700            );
701        }
702    }
703
704    /// A dotted path plus a TOML literal, rendered as a document. `$schema`
705    /// needs quoting; nothing else in the table does.
706    fn toml_document(path: &str, literal: &str) -> String {
707        let quoted: Vec<String> = path
708            .split('.')
709            .map(|p| {
710                if p.chars().all(|c| c.is_alphanumeric() || c == '_') {
711                    p.to_string()
712                } else {
713                    format!("\"{p}\"")
714                }
715            })
716            .collect();
717        format!("{} = {}\n", quoted.join("."), literal)
718    }
719
720    /// dev/01: the committed schema is what this build generates. A schema
721    /// nobody regenerated is a schema that lies to every editor pointed at
722    /// it, so drift is a test failure, not a chore.
723    #[test]
724    fn committed_schema_is_current() {
725        let path = workspace_root().join(CONFIG_SCHEMA_PATH);
726        // Regeneration door for this crate's own test run, so the schema can
727        // be refreshed without a built CLI binary:
728        // `SUPERCODE_UPDATE_CONFIG_SCHEMA=1 cargo test -p supercode-harness
729        // --lib config_schema`. `supercode config schema --write` is the
730        // product door and writes byte-identical output.
731        if std::env::var("SUPERCODE_UPDATE_CONFIG_SCHEMA").is_ok() {
732            std::fs::create_dir_all(path.parent().expect("schema dir")).expect("create schema dir");
733            std::fs::write(&path, config_schema_json()).expect("write schema");
734        }
735        let committed = std::fs::read_to_string(&path)
736            .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
737        assert_eq!(
738            committed,
739            config_schema_json(),
740            "{} is stale — regenerate with `supercode config schema --write`",
741            CONFIG_SCHEMA_PATH
742        );
743    }
744
745    /// dev/01: `$schema` is accepted by the real resolver in both formats,
746    /// which is the whole point of the key — a file that names its schema
747    /// must not be rejected by the tool the schema describes.
748    #[test]
749    fn schema_pointer_is_accepted_in_toml_and_json() {
750        let toml_doc = format!("\"$schema\" = \"{CONFIG_SCHEMA_URL}\"\n[core]\nmodel = \"m\"\n");
751        let resolved = crate::configfile::resolve(
752            &toml_doc,
753            None,
754            &crate::configfile::ResolveOptions { strict: true },
755        )
756        .expect("strict resolve accepts $schema");
757        assert_eq!(resolved.harness.schema.as_deref(), Some(CONFIG_SCHEMA_URL));
758        assert!(
759            !resolved.warnings.iter().any(|w| w.contains("$schema")),
760            "the schema pointer must not itself be diagnosed: {:?}",
761            resolved.warnings
762        );
763
764        let json_doc = format!("{{\"$schema\": \"{CONFIG_SCHEMA_URL}\", \"core\": {{}}}}");
765        let hc = HarnessConfig::from_json_str(&json_doc).expect("json mirror accepts $schema");
766        assert_eq!(hc.schema.as_deref(), Some(CONFIG_SCHEMA_URL));
767    }
768
769    /// The generated document is a well-formed schema skeleton: closed at
770    /// the root, and every declared path reachable through `properties`.
771    #[test]
772    fn generated_schema_is_closed_and_addressable() {
773        let schema = config_schema();
774        assert_eq!(schema["type"], "object");
775        assert_eq!(schema["additionalProperties"], Value::Bool(false));
776        for field in CONFIG_SCHEMA_FIELDS {
777            let mut node = &schema;
778            for part in field.path.split('.') {
779                node = &node["properties"][part];
780                assert!(
781                    !node.is_null(),
782                    "{} is unreachable in the generated schema",
783                    field.path
784                );
785            }
786            assert_eq!(
787                node["description"], field.description,
788                "{}: description lost",
789                field.path
790            );
791        }
792    }
793}