diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c67068..9568ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver ## [Unreleased] +- `jevgate.schema.json` is a JSON Schema of `jevgate.toml`, generated from the configuration types with every rule name and level, and `jevgate init` writes a `#:schema` line so editors with TOML schema support complete and check the file. - pre-commit hooks: `jevgate-system` runs the installed `jevgate` on the staged changes, and `jevgate` builds it from source with Rust first. - `--format sarif` writes a SARIF 2.1.0 log for GitHub code scanning, GitLab and editors: the findings `--format github` annotates, as `error` when they fail the gate and `warning` otherwise, with every rule's question, related locations, the finding's fingerprint and probability. Run errors and files that could not be judged are tool notifications. - Agent output is colored on a terminal: the headline by the gate's outcome, review and consider headings, and each finding's location. `--color auto|always|never` chooses, and `NO_COLOR` and `CLICOLOR_FORCE` are honored; JSON and GitHub annotations are never colored. diff --git a/Cargo.lock b/Cargo.lock index 491c413..8c29d17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -565,6 +565,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "endi" version = "1.1.1" @@ -971,6 +977,7 @@ dependencies = [ "keyring", "libc", "rpassword", + "schemars", "secret-service", "serde", "serde_json", @@ -1260,6 +1267,26 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "regex" version = "1.13.1" @@ -1393,6 +1420,31 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.4", +] + [[package]] name = "secret-service" version = "5.2.0" @@ -1465,6 +1517,17 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "serde_json" version = "1.0.151" diff --git a/Cargo.toml b/Cargo.toml index 41d62dc..def49bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,10 @@ tree-sitter-php = "=0.24.2" tree-sitter-java = "=0.23.5" ureq = { version = "=3.4.2", default-features = false, features = ["rustls", "json"] } +# The JSON Schema of jevgate.toml is generated by a test from the configuration types. +[dev-dependencies] +schemars = "=1.2.2" + [target.'cfg(unix)'.dependencies] libc = "=0.2.189" signal-hook = { version = "=0.4.4", default-features = false } diff --git a/README.md b/README.md index d842c0a..de8ce34 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ Other CI systems work the same way: install with `install.sh` or `cargo binstall ## Configuration -`jevgate init` writes a commented `jevgate.toml` at the repository root. The command line wins over the file, except that upload patterns and budgets in the file are ceilings that flags can only narrow. Unknown keys are errors. +`jevgate init` writes a commented `jevgate.toml` at the repository root. The command line wins over the file, except that upload patterns and budgets in the file are ceilings that flags can only narrow. Unknown keys are errors. Its first line points editors with TOML schema support (Even Better TOML, Taplo) to [`jevgate.schema.json`](jevgate.schema.json), which completes keys, rule names and levels and flags mistakes as you type. ```toml upload_allow = ["src/**", "tests/**"] # only these paths may be uploaded diff --git a/jevgate.schema.json b/jevgate.schema.json new file mode 100644 index 0000000..d1117e2 --- /dev/null +++ b/jevgate.schema.json @@ -0,0 +1,314 @@ +{ + "$defs": { + "Level": { + "anyOf": [ + { + "enum": [ + "review", + "consider", + "uncertain", + "report", + "none", + "off" + ], + "type": "string" + }, + { + "items": { + "enum": [ + "review", + "consider", + "uncertain", + "report", + "none", + "off" + ], + "type": "string" + }, + "type": "array" + } + ] + }, + "Rules": { + "anyOf": [ + { + "items": { + "enum": [ + "maintainability/file-organization", + "file_organization", + "maintainability/function-simplification", + "function_simplification", + "maintainability/shared-logic", + "shared_logic", + "maintainability/hardcoded-values", + "hardcoded_values", + "security/injection", + "injection", + "security/sensitive-data", + "sensitive_data", + "security/unsafe-settings", + "unsafe_settings", + "security/access-control", + "access_control", + "security/workflows", + "workflows", + "tests/value", + "test_value", + "tests/redundancy", + "test_redundancy", + "documentation/agent-context", + "agent_context", + "documentation/large-docs", + "large_docs", + "documentation/staleness", + "doc_staleness", + "documentation/duplication", + "doc_duplication", + "documentation/comments", + "comments", + "maintainability", + "security", + "tests", + "documentation", + "default", + "all" + ], + "type": "string" + }, + "type": "array" + }, + { + "additionalProperties": { + "$ref": "#/$defs/Level" + }, + "propertyNames": { + "enum": [ + "maintainability/file-organization", + "file_organization", + "maintainability/function-simplification", + "function_simplification", + "maintainability/shared-logic", + "shared_logic", + "maintainability/hardcoded-values", + "hardcoded_values", + "security/injection", + "injection", + "security/sensitive-data", + "sensitive_data", + "security/unsafe-settings", + "unsafe_settings", + "security/access-control", + "access_control", + "security/workflows", + "workflows", + "tests/value", + "test_value", + "tests/redundancy", + "test_redundancy", + "documentation/agent-context", + "agent_context", + "documentation/large-docs", + "large_docs", + "documentation/staleness", + "doc_staleness", + "documentation/duplication", + "doc_duplication", + "documentation/comments", + "comments", + "maintainability", + "security", + "tests", + "documentation", + "default", + "all" + ] + }, + "type": "object" + } + ], + "description": "`rules = [\"security\"]` selects rules; a `[rules]` table sets each rule's or\ngroup's gate level, or `\"off\"`, on top of the default group." + }, + "Scope": { + "additionalProperties": false, + "description": "`[[scope]]`: gate levels for the files `paths` match. `fail_on` applies to\nevery rule there, and `rules` to single rules or groups. The last scope\nthat matches a file and addresses a rule wins; other files and rules keep\nthe levels set outside scopes.", + "properties": { + "fail_on": { + "description": "The level for every rule in these files.", + "items": { + "enum": [ + "review", + "consider", + "uncertain", + "report", + "none" + ], + "type": "string" + }, + "type": "array" + }, + "paths": { + "description": "Globs of the files this scope applies to.", + "items": { + "type": "string" + }, + "type": "array" + }, + "rules": { + "additionalProperties": { + "$ref": "#/$defs/Level" + }, + "description": "Levels of single rules or groups in these files; `off` is not accepted (use `upload_deny`).", + "propertyNames": { + "enum": [ + "maintainability/file-organization", + "file_organization", + "maintainability/function-simplification", + "function_simplification", + "maintainability/shared-logic", + "shared_logic", + "maintainability/hardcoded-values", + "hardcoded_values", + "security/injection", + "injection", + "security/sensitive-data", + "sensitive_data", + "security/unsafe-settings", + "unsafe_settings", + "security/access-control", + "access_control", + "security/workflows", + "workflows", + "tests/value", + "test_value", + "tests/redundancy", + "test_redundancy", + "documentation/agent-context", + "agent_context", + "documentation/large-docs", + "large_docs", + "documentation/staleness", + "doc_staleness", + "documentation/duplication", + "doc_duplication", + "documentation/comments", + "comments", + "maintainability", + "security", + "tests", + "documentation", + "default", + "all" + ] + }, + "type": "object" + } + }, + "required": [ + "paths" + ], + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/Tech-Byte-Frontier/jevgate/main/jevgate.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "JevGate configuration. The command line wins over the file, except that upload patterns and budgets in the file are ceilings that flags can only narrow. Unknown keys are errors.", + "properties": { + "cache_ttl_secs": { + "description": "Cache lifetime in seconds for the `jev-latest` and `jev-preview` aliases; pinned versions never expire. Default: 3600.", + "minimum": 0, + "type": "integer" + }, + "concurrency": { + "description": "Ceiling on simultaneous requests (1-8). Default: 6.", + "maximum": 8, + "minimum": 1, + "type": "integer" + }, + "context": { + "description": "Files always sent as related evidence, like `--context`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "fail_on": { + "description": "The level for rules without their own, like `--fail-on`. Default: [\"review\"].", + "items": { + "enum": [ + "review", + "consider", + "uncertain", + "report", + "none" + ], + "type": "string" + }, + "type": "array" + }, + "generated": { + "description": "Globs of generated files, which are skipped, in addition to the built-in names.", + "items": { + "type": "string" + }, + "type": "array" + }, + "include_tests": { + "description": "Judge tests, like `--include-tests`. Default: false.", + "type": "boolean" + }, + "max_context_bytes": { + "description": "Ceiling on context bytes per request. Default: 32768.", + "minimum": 1, + "type": "integer" + }, + "max_file_bytes": { + "description": "Files larger than this are reported as needs-context, never truncated. Default: 262144.", + "minimum": 1, + "type": "integer" + }, + "max_requests": { + "description": "Ceiling on API attempts per invocation; flags can only lower it. Default: unlimited.", + "minimum": 1, + "type": "integer" + }, + "model": { + "description": "TypeSafe model; a pinned version keeps results repeatable. `--model` overrides it.", + "type": "string" + }, + "rules": { + "$ref": "#/$defs/Rules", + "description": "A list selects rules; a table gives each group or rule a level. Default: the `default` group." + }, + "scope": { + "description": "Gate levels for the files some paths match, such as report-only tooling.", + "items": { + "$ref": "#/$defs/Scope" + }, + "type": "array" + }, + "tests": { + "description": "Globs of additional test files.", + "items": { + "type": "string" + }, + "type": "array" + }, + "upload_allow": { + "description": "Globs of the paths that may be uploaded, including instruction files and context. Default: every path.", + "items": { + "type": "string" + }, + "type": "array" + }, + "upload_deny": { + "description": "Globs never uploaded, even when allowed.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "title": "jevgate.toml", + "type": "object" +} diff --git a/jevgate.toml b/jevgate.toml index d27b06f..03817cf 100644 --- a/jevgate.toml +++ b/jevgate.toml @@ -1,3 +1,4 @@ +#:schema ./jevgate.schema.json # The report renderer's escaping test holds an XSS string that the provider's # edge firewall blocks; it is reviewed locally, not uploaded. # The promo video under video/ is local only and never judged. diff --git a/src/config.rs b/src/config.rs index 7abcac2..63ad54f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,25 +10,36 @@ use std::{ }; #[derive(Default, Deserialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] #[serde(default, deny_unknown_fields)] pub struct Config { + /// Globs of the paths that may be uploaded, including instruction files and context. Default: every path. pub upload_allow: Vec, + /// Globs never uploaded, even when allowed. pub upload_deny: Vec, + /// Globs of generated files, which are skipped, in addition to the built-in names. pub generated: Vec, + /// Globs of additional test files. pub tests: Vec, + /// Files always sent as related evidence, like `--context`. pub context: Vec, + /// A list selects rules; a table gives each group or rule a level. Default: the `default` group. pub rules: Rules, + /// Ceiling on API attempts per invocation; flags can only lower it. Default: unlimited. pub max_requests: Option, + /// Ceiling on simultaneous requests (1-8). Default: 6. pub concurrency: Option, + /// Files larger than this are reported as needs-context, never truncated. Default: 262144. pub max_file_bytes: Option, + /// Ceiling on context bytes per request. Default: 32768. pub max_context_bytes: Option, - /// Default `--fail-on` values when none are passed. + /// The level for rules without their own, like `--fail-on`. Default: ["review"]. pub fail_on: Vec, - /// The model when `--model` is not passed. + /// TypeSafe model; a pinned version keeps results repeatable. `--model` overrides it. pub model: Option, - /// Cache lifetime for model aliases when `--cache-ttl-secs` is not passed. + /// Cache lifetime in seconds for the `jev-latest` and `jev-preview` aliases; pinned versions never expire. Default: 3600. pub cache_ttl_secs: Option, - /// Judge tests as if `--include-tests` were passed. + /// Judge tests, like `--include-tests`. Default: false. pub include_tests: bool, /// Gate levels for the files some paths match, such as report-only tooling. pub scope: Vec, @@ -39,11 +50,15 @@ pub struct Config { /// that matches a file and addresses a rule wins; other files and rules keep /// the levels set outside scopes. #[derive(Deserialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct Scope { + /// Globs of the files this scope applies to. pub paths: Vec, + /// The level for every rule in these files. #[serde(default)] pub fail_on: Vec, + /// Levels of single rules or groups in these files; `off` is not accepted (use `upload_deny`). #[serde(default)] pub rules: BTreeMap, } @@ -51,6 +66,7 @@ pub struct Scope { /// `rules = ["security"]` selects rules; a `[rules]` table sets each rule's or /// group's gate level, or `"off"`, on top of the default group. #[derive(Deserialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] #[serde(untagged)] pub enum Rules { List(Vec), @@ -64,6 +80,7 @@ impl Default for Rules { } #[derive(Clone, Deserialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] #[serde(untagged)] pub enum Level { One(String), diff --git a/src/config_schema.rs b/src/config_schema.rs new file mode 100644 index 0000000..0fce3c9 --- /dev/null +++ b/src/config_schema.rs @@ -0,0 +1,122 @@ +//! The JSON Schema of `jevgate.toml`, generated from the configuration types +//! with the rule names, gate levels and limits filled in. `jevgate.schema.json` +//! at the repository root holds it, and `jevgate init` points editors to it. +use crate::{catalog, config::Config, options::MAX_CONCURRENCY}; +use serde_json::{Value, json}; + +const ID: &str = + "https://raw.githubusercontent.com/Tech-Byte-Frontier/jevgate/main/jevgate.schema.json"; +/// Levels `fail_on` accepts; `[rules]` also accepts `off`. +const LEVELS: [&str; 5] = ["review", "consider", "uncertain", "report", "none"]; + +pub fn schema() -> Value { + let mut schema = serde_json::to_value(schemars::schema_for!(Config)).expect("a schema is JSON"); + tomlify(&mut schema); + schema["$id"] = json!(ID); + schema["title"] = json!("jevgate.toml"); + schema["description"] = json!( + "JevGate configuration. The command line wins over the file, except that upload patterns and budgets in the file are ceilings that flags can only narrow. Unknown keys are errors." + ); + let names = rule_names(); + let levels = json!(LEVELS); + let mut with_off = LEVELS.to_vec(); + with_off.push("off"); + let properties = &mut schema["properties"]; + properties["fail_on"]["items"]["enum"] = levels.clone(); + properties["concurrency"]["minimum"] = json!(1); + properties["concurrency"]["maximum"] = json!(MAX_CONCURRENCY); + for budget in ["max_requests", "max_file_bytes", "max_context_bytes"] { + properties[budget]["minimum"] = json!(1); + } + let definitions = &mut schema["$defs"]; + for level in definitions["Level"]["anyOf"].as_array_mut().unwrap() { + match level["type"].as_str() { + Some("string") => level["enum"] = json!(with_off), + _ => level["items"]["enum"] = json!(with_off), + } + } + for rules in definitions["Rules"]["anyOf"].as_array_mut().unwrap() { + match rules["type"].as_str() { + Some("array") => rules["items"]["enum"] = names.clone(), + _ => rules["propertyNames"] = json!({"enum": names}), + } + } + let scope = &mut definitions["Scope"]["properties"]; + scope["fail_on"]["items"]["enum"] = levels; + scope["rules"]["propertyNames"] = json!({"enum": names}); + schema +} + +/// Every rule ID, key and group, and the `default` and `all` groups. +fn rule_names() -> Value { + let rules = catalog::rules(); + let mut names: Vec<&str> = rules.iter().flat_map(|r| [r.id, r.key]).collect(); + names.extend(catalog::groups()); + names.extend([catalog::DEFAULT_GROUP, catalog::ALL_GROUP]); + json!(names) +} + +/// TOML has no null and the descriptions state each default, so optional +/// values become plain types and generated defaults and formats are dropped. +fn tomlify(value: &mut Value) { + match value { + Value::Object(map) => { + map.remove("default"); + map.remove("format"); + if let Some(Value::Array(types)) = map.get_mut("type") { + types.retain(|t| t != "null"); + if let [single] = types.as_slice() { + let single = single.clone(); + map.insert("type".into(), single); + } + } + map.values_mut().for_each(tomlify); + } + Value::Array(items) => items.iter_mut().for_each(tomlify), + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FILE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/jevgate.schema.json"); + + /// The checked-in schema matches the configuration types. After changing + /// them, rerun with `JEVGATE_WRITE_SCHEMA=1` to rewrite the file. + #[test] + fn checked_in_schema_matches_the_configuration() { + let text = serde_json::to_string_pretty(&schema()).unwrap() + "\n"; + if std::env::var_os("JEVGATE_WRITE_SCHEMA").is_some() { + std::fs::write(FILE, &text).unwrap(); + } + // Git may check the file out with CRLF line ends on Windows. + let file = std::fs::read_to_string(FILE) + .unwrap_or_default() + .replace('\r', ""); + assert!( + file == text.replace('\r', ""), + "jevgate.schema.json is out of date; run JEVGATE_WRITE_SCHEMA=1 cargo test" + ); + } + + #[test] + fn rule_names_and_levels_are_listed() { + let schema = schema(); + let names = schema["$defs"]["Scope"]["properties"]["rules"]["propertyNames"]["enum"] + .as_array() + .unwrap(); + for name in [ + "security", + "security/injection", + "injection", + "default", + "all", + ] { + assert!(names.contains(&json!(name)), "{name}"); + } + assert_eq!(schema["properties"]["max_requests"]["type"], "integer"); + assert!(schema["properties"]["fail_on"].get("default").is_none()); + } +} diff --git a/src/init.rs b/src/init.rs index ca2f71a..35e5c7b 100644 --- a/src/init.rs +++ b/src/init.rs @@ -108,7 +108,8 @@ fn render(allow: &[String]) -> String { } } format!( - r#"# JevGate configuration, written by `jevgate init`. Unknown keys are errors. + r#"#:schema https://raw.githubusercontent.com/Tech-Byte-Frontier/jevgate/v{version}/jevgate.schema.json +# JevGate configuration, written by `jevgate init`. Unknown keys are errors. # `jevgate rules` lists every rule; `jevgate check --dry-run --show-requests` # shows what would be uploaded without sending anything. @@ -140,7 +141,8 @@ upload_deny = ["**/.env*", "**/*.pem", "**/*.key"] # paths = ["scripts/**", "tools/**"] # fail_on = ["report"] "#, - model = crate::options::DEFAULT_MODEL + model = crate::options::DEFAULT_MODEL, + version = env!("CARGO_PKG_VERSION"), ) } diff --git a/src/main.rs b/src/main.rs index d870ef8..178fb95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,8 @@ mod check; mod command; mod components; mod config; +#[cfg(test)] +mod config_schema; mod context; mod context_units; mod discovery;