From 8b9fcc121c1286803241ea4824329644f12509cd Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 25 Jun 2026 21:04:35 -0700 Subject: [PATCH 01/18] feat(config): add project manifest CLI --- crates/wesley-cli/src/main.rs | 286 ++++++++- crates/wesley-cli/tests/cli.rs | 116 ++++ crates/wesley-core/src/domain/mod.rs | 1 + .../src/domain/project_manifest.rs | 568 ++++++++++++++++++ crates/wesley-core/src/lib.rs | 1 + crates/wesley-core/tests/project_manifest.rs | 177 ++++++ wesley.config.json | 55 ++ 7 files changed, 1194 insertions(+), 10 deletions(-) create mode 100644 crates/wesley-core/src/domain/project_manifest.rs create mode 100644 crates/wesley-core/tests/project_manifest.rs create mode 100644 wesley.config.json diff --git a/crates/wesley-cli/src/main.rs b/crates/wesley-cli/src/main.rs index 227b800d..e9cfc54c 100644 --- a/crates/wesley-cli/src/main.rs +++ b/crates/wesley-cli/src/main.rs @@ -8,11 +8,13 @@ use std::process::{Command, ExitCode}; use wesley_core::{ build_contract_bundle_manifest_v1, compute_content_hash, compute_law_hash_v1, compute_registry_hash, diff_law_ir_v1, diff_schema_sdl, extract_operation_directive_args, - format_law_diff_markdown_v1, list_schema_operations_sdl, load_weslaw_yaml, lower_schema_sdl, - lower_wes_channel_directives_to_law_ir_v1, normalize_schema_sdl, record_law_binding_error_v1, - resolve_operation_selections, resolve_operation_selections_with_schema, + format_law_diff_markdown_v1, list_schema_operations_sdl, load_project_manifest, + load_weslaw_yaml, lower_schema_sdl, lower_wes_channel_directives_to_law_ir_v1, + normalize_schema_sdl, record_law_binding_error_v1, resolve_operation_selections, + resolve_operation_selections_with_schema, select_changed_schema_paths, ContractBundleManifestV1, FootprintLawV1, LawDiffReportV1, LawEntryBodyV1, LawIrV1, - OperationType, ScalarSemanticsLawV1, SchemaDelta, TypeKind, WeslawError, WesleyError, WesleyIR, + OperationType, ProjectManifest, ProjectManifestError, ResolvedSchemaPath, ScalarSemanticsLawV1, + SchemaDelta, SelectedSchemaPath, TypeKind, WeslawError, WesleyError, WesleyIR, }; use wesley_emit_rust::{ emit_le_binary_rust, emit_rust_with_operations, emit_rust_with_operations_and_law, @@ -58,6 +60,7 @@ fn run(args: Vec) -> Result { Ok(EXIT_OK) } Some("doctor") => run_doctor_command(&args[1..]), + Some("config") => run_config_command(&args[1..]), Some("init-law") if wants_help(&args[1..]) => { print_init_law_help(); Ok(EXIT_OK) @@ -392,6 +395,91 @@ fn run_doctor_command(args: &[String]) -> Result { } } +fn run_config_command(args: &[String]) -> Result { + match args.first().map(String::as_str) { + None | Some("--help") | Some("-h") => { + print_config_help(); + Ok(EXIT_OK) + } + Some("validate") if wants_help(&args[1..]) => { + print_config_help(); + Ok(EXIT_OK) + } + Some("validate") => { + let options = parse_options(&args[1..], "config validate")?; + let (manifest_path, manifest) = load_manifest_from_options(&options)?; + let report = ConfigInspectReport { + valid: true, + manifest_path: manifest_path.display().to_string(), + resolved_schema_paths: manifest.resolved_schema_paths(), + manifest, + }; + + if options.json { + print_json(&report)?; + } else { + println!("Manifest valid: {}", report.manifest_path); + } + Ok(EXIT_OK) + } + Some("inspect") if wants_help(&args[1..]) => { + print_config_help(); + Ok(EXIT_OK) + } + Some("inspect") => { + let options = parse_options(&args[1..], "config inspect")?; + let (manifest_path, manifest) = load_manifest_from_options(&options)?; + let report = ConfigInspectReport { + valid: true, + manifest_path: manifest_path.display().to_string(), + resolved_schema_paths: manifest.resolved_schema_paths(), + manifest, + }; + + if options.json { + print_json(&report)?; + } else { + println!("Manifest: {}", report.manifest_path); + println!("Bundle dir: {}", report.manifest.bundle_dir); + for schema in &report.resolved_schema_paths { + println!("Schema {}: {}", schema.id, schema.path); + } + for target in &report.manifest.targets { + println!("Target: {}", target.name); + } + } + Ok(EXIT_OK) + } + Some("changed-schemas") if wants_help(&args[1..]) => { + print_config_help(); + Ok(EXIT_OK) + } + Some("changed-schemas") => { + let options = parse_options(&args[1..], "config changed-schemas")?; + let (manifest_path, manifest) = load_manifest_from_options(&options)?; + let changed_files = changed_files_from_options(&options)?; + let selected_schema_paths = select_changed_schema_paths(&manifest, &changed_files); + let report = ConfigChangedSchemasReport { + manifest_path: manifest_path.display().to_string(), + changed_files, + selected_schema_paths, + }; + + if options.json { + print_json(&report)?; + } else { + for schema in &report.selected_schema_paths { + println!("{} {} ({})", schema.id, schema.path, schema.reason); + } + } + Ok(EXIT_OK) + } + Some(command) => Err(CliError::usage(format!( + "unknown config command '{command}'" + ))), + } +} + fn run_schema_command(args: &[String]) -> Result { match args.first().map(String::as_str) { None | Some("--help") | Some("-h") => { @@ -404,7 +492,7 @@ fn run_schema_command(args: &[String]) -> Result { } Some("lower") => { let options = parse_options(&args[1..], "schema lower")?; - let schema_path = options.required_schema("schema lower")?; + let schema_path = schema_path_or_manifest_default(&options, "schema lower")?; let sdl = read_file(&schema_path, "schema")?; let ir = lower_schema_sdl(&sdl)?; print_json(&ir)?; @@ -416,7 +504,7 @@ fn run_schema_command(args: &[String]) -> Result { } Some("hash") => { let options = parse_options(&args[1..], "schema hash")?; - let schema_path = options.required_schema("schema hash")?; + let schema_path = schema_path_or_manifest_default(&options, "schema hash")?; let sdl = read_file(&schema_path, "schema")?; let ir = lower_schema_sdl(&sdl)?; let schema_hash = compute_registry_hash(&ir)?; @@ -435,7 +523,7 @@ fn run_schema_command(args: &[String]) -> Result { } Some("operations") => { let options = parse_options(&args[1..], "schema operations")?; - let schema_path = options.required_schema("schema operations")?; + let schema_path = schema_path_or_manifest_default(&options, "schema operations")?; let sdl = read_file(&schema_path, "schema")?; let operations = list_schema_operations_sdl(&sdl)?; @@ -824,6 +912,7 @@ fn print_doctor_text(report: &DoctorReport) { #[derive(Default)] struct ParsedOptions { schema: Option, + config: Option, law: Option, old_schema: Option, new_schema: Option, @@ -836,6 +925,8 @@ struct ParsedOptions { family: Option, profile: Option, subject: Option, + changed: Vec, + changed_file: Option, format: Option, breaking_only: bool, exit_code: bool, @@ -925,6 +1016,39 @@ fn parse_options(args: &[String], command: &str) -> Result { + index += 1; + options.config = Some(PathBuf::from(required_value(args, index, "--config")?)); + } + "--config" => { + return Err(CliError::usage(format!( + "unknown option '--config' for `{command}`" + ))); + } + "--changed" if command == "config changed-schemas" => { + index += 1; + options + .changed + .push(required_value(args, index, "--changed")?); + } + "--changed" => { + return Err(CliError::usage(format!( + "unknown option '--changed' for `{command}`" + ))); + } + "--changed-file" if command == "config changed-schemas" => { + index += 1; + options.changed_file = Some(PathBuf::from(required_value( + args, + index, + "--changed-file", + )?)); + } + "--changed-file" => { + return Err(CliError::usage(format!( + "unknown option '--changed-file' for `{command}`" + ))); + } "--old" => { index += 1; options.old_schema = Some(PathBuf::from(required_value(args, index, "--old")?)); @@ -1122,6 +1246,95 @@ fn write_file(path: &Path, content: &str, label: &str) -> Result<(), CliError> { }) } +fn schema_path_or_manifest_default( + options: &ParsedOptions, + command: &str, +) -> Result { + if let Some(schema) = options.schema.clone() { + return Ok(schema); + } + + let (manifest_path, manifest) = load_manifest_from_options(options)?; + let schemas = manifest.resolved_schema_paths(); + match schemas.as_slice() { + [schema] => Ok(resolve_manifest_relative_path(&manifest_path, &schema.path)), + [] => Err(CliError::usage(format!( + "missing --schema for `{command}` and manifest has no schemaPaths" + ))), + _ => Err(CliError::usage(format!( + "missing --schema for `{command}` and manifest has multiple schemaPaths; pass --schema explicitly" + ))), + } +} + +fn load_manifest_from_options( + options: &ParsedOptions, +) -> Result<(PathBuf, ProjectManifest), CliError> { + let manifest_path = if let Some(config) = options.config.clone() { + config + } else { + discover_manifest_path()? + }; + let source = read_file(&manifest_path, "project manifest")?; + let manifest = load_project_manifest(&source)?; + Ok((manifest_path, manifest)) +} + +fn discover_manifest_path() -> Result { + const CANDIDATES: &[&str] = &[ + "wesley.config.json", + "wesley.config.yaml", + "wesley.config.yml", + ".wesley/config.json", + ]; + + let mut dir = env::current_dir() + .map_err(|source| CliError::Git(format!("failed to read current directory: {source}")))?; + loop { + for candidate in CANDIDATES { + let path = dir.join(candidate); + if path.is_file() { + return Ok(path); + } + } + + if !dir.pop() { + break; + } + } + + Err(CliError::usage( + "no Wesley manifest found; pass --config or create wesley.config.json", + )) +} + +fn resolve_manifest_relative_path(manifest_path: &Path, path: &str) -> PathBuf { + let path = PathBuf::from(path); + if path.is_absolute() { + path + } else { + manifest_path + .parent() + .unwrap_or_else(|| Path::new("")) + .join(path) + } +} + +fn changed_files_from_options(options: &ParsedOptions) -> Result, CliError> { + let mut changed = options.changed.clone(); + if let Some(path) = &options.changed_file { + let content = read_file(path, "changed-file list")?; + changed.extend( + content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_owned), + ); + } + Ok(changed) +} + fn write_emit_metadata_if_requested( path: Option<&Path>, ir: &WesleyIR, @@ -1297,6 +1510,23 @@ fn print_json(value: &impl serde::Serialize) -> Result<(), CliError> { Ok(()) } +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct ConfigInspectReport { + valid: bool, + manifest_path: String, + resolved_schema_paths: Vec, + manifest: ProjectManifest, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct ConfigChangedSchemasReport { + manifest_path: String, + changed_files: Vec, + selected_schema_paths: Vec, +} + #[derive(Clone, Copy)] enum OutputFormat { Text, @@ -2039,6 +2269,9 @@ Commands: normalize-sdl Print the Rust-core normalized SDL view doctor Run Rust-native health checks init-law Scaffold weslaw/v1 from known SDL law directives + config validate Validate a Wesley project manifest + config inspect Print resolved manifest schema paths and targets + config changed-schemas Select schema sets affected by changed files schema lower Lower GraphQL SDL to Wesley L1 IR JSON schema hash Print the Wesley L1 registry hash for GraphQL SDL schema operations List Query/Mutation/Subscription root operations @@ -2100,6 +2333,28 @@ Options: ); } +fn print_config_help() { + println!( + "\ +Wesley project manifest commands + +Manifest files are domain-free JSON or YAML documents. The CLI discovers +wesley.config.json, wesley.config.yaml, or wesley.config.yml by walking upward +from the current directory when --config is omitted. + +Usage: + wesley config validate [--config ] [--json] + wesley config inspect [--config ] [--json] + wesley config changed-schemas [--config ] [--changed ...] [--changed-file ] [--json] + +Options: + --config Manifest path; defaults to upward discovery + --changed Changed file path; may be passed more than once + --changed-file Newline-delimited changed file list + --json Emit JSON output" + ); +} + fn print_normalize_sdl_help() { println!( "\ @@ -2212,6 +2467,7 @@ enum CliError { }, Core(WesleyError), Law(WeslawError), + Config(ProjectManifestError), Git(String), Json(String), } @@ -2224,9 +2480,12 @@ impl CliError { fn exit_code(&self) -> u8 { match self { Self::Usage(_) => EXIT_USAGE, - Self::Io { .. } | Self::Core(_) | Self::Law(_) | Self::Git(_) | Self::Json(_) => { - EXIT_FAILURE - } + Self::Io { .. } + | Self::Core(_) + | Self::Law(_) + | Self::Config(_) + | Self::Git(_) + | Self::Json(_) => EXIT_FAILURE, } } } @@ -2255,6 +2514,7 @@ impl std::fmt::Display for CliError { } Ok(()) } + Self::Config(error) => write!(formatter, "{error}"), Self::Git(error) => write!(formatter, "git error: {error}"), Self::Json(error) => write!(formatter, "failed to serialize JSON output: {error}"), } @@ -2273,6 +2533,12 @@ impl From for CliError { } } +impl From for CliError { + fn from(error: ProjectManifestError) -> Self { + Self::Config(error) + } +} + impl From for CliError { fn from(error: serde_json::Error) -> Self { Self::Json(error.to_string()) diff --git a/crates/wesley-cli/tests/cli.rs b/crates/wesley-cli/tests/cli.rs index 74d75084..b1e213d2 100644 --- a/crates/wesley-cli/tests/cli.rs +++ b/crates/wesley-cli/tests/cli.rs @@ -13,6 +13,8 @@ fn help_exits_zero_without_footprint_command() { assert!(stdout.contains("schema lower")); assert!(stdout.contains("schema operations")); assert!(stdout.contains("schema diff")); + assert!(stdout.contains("config validate")); + assert!(stdout.contains("config changed-schemas")); assert!(stdout.contains("law validate")); assert!(stdout.contains("law lint")); assert!(stdout.contains("law diff")); @@ -28,6 +30,120 @@ fn help_exits_zero_without_footprint_command() { assert!(!stdout.contains("check-footprint")); } +#[test] +fn config_validate_and_changed_schemas_emit_domain_free_manifest_reports() { + let dir = temp_dir("config-manifest"); + std::fs::create_dir_all(dir.join("schemas/core")).expect("schema dir should create"); + std::fs::create_dir_all(dir.join("schemas/audit")).expect("schema dir should create"); + std::fs::write( + dir.join("schemas/core/schema.graphql"), + "type Query { core: String }\n", + ) + .expect("core schema should write"); + std::fs::write( + dir.join("schemas/audit/schema.graphql"), + "type Query { audit: String }\n", + ) + .expect("audit schema should write"); + let config = dir.join("wesley.config.json"); + std::fs::write( + &config, + r#" + { + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": [ + { + "id": "core", + "path": "schemas/core/schema.graphql", + "rebuildOnGlobs": ["schemas/core/**"] + }, + { + "id": "audit", + "path": "schemas/audit/schema.graphql", + "rebuildOnGlobs": ["schemas/audit/**"] + } + ], + "targets": [ + { + "name": "rust-models", + "module": "wesley.emit.rust", + "exclusiveGroup": "model-emitter", + "default": true + } + ] + } + "#, + ) + .expect("config should write"); + + let output = wesley() + .args(["config", "validate", "--config"]) + .arg(&config) + .arg("--json") + .output() + .expect("wesley should run"); + assert_success(&output); + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + let report: serde_json::Value = serde_json::from_str(&stdout).expect("stdout should be json"); + assert_eq!(report["valid"], true); + assert_eq!( + report["manifest"]["schemaPaths"].as_array().unwrap().len(), + 2 + ); + + let output = wesley() + .args(["config", "changed-schemas", "--config"]) + .arg(&config) + .args(["--changed", "schemas/core/types.graphql", "--json"]) + .output() + .expect("wesley should run"); + assert_success(&output); + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + let report: serde_json::Value = serde_json::from_str(&stdout).expect("stdout should be json"); + assert_eq!(report["selectedSchemaPaths"].as_array().unwrap().len(), 1); + assert_eq!(report["selectedSchemaPaths"][0]["id"], "core"); + assert_eq!( + report["selectedSchemaPaths"][0]["bundleDir"], + ".wesley-cache/core" + ); + assert!(report["selectedSchemaPaths"][0]["reason"] + .as_str() + .unwrap() + .contains("schemas/core/**")); +} + +#[test] +fn schema_commands_discover_single_schema_manifest_when_schema_flag_is_omitted() { + let dir = temp_dir("config-discovery"); + std::fs::create_dir_all(dir.join("schema")).expect("schema dir should create"); + std::fs::write( + dir.join("schema/schema.graphql"), + "type Query { health: Boolean }\n", + ) + .expect("schema should write"); + std::fs::write( + dir.join("wesley.config.json"), + r#" + { + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": ["schema/schema.graphql"] + } + "#, + ) + .expect("config should write"); + + let output = wesley() + .current_dir(&dir) + .args(["schema", "hash", "--json"]) + .output() + .expect("wesley should run"); + + assert_success(&output); + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + let report: serde_json::Value = serde_json::from_str(&stdout).expect("stdout should be json"); + assert!(report["schemaHash"].as_str().unwrap().len() == 64); +} + #[test] fn removed_footprint_checker_is_not_a_wesley_command() { let output = wesley() diff --git a/crates/wesley-core/src/domain/mod.rs b/crates/wesley-core/src/domain/mod.rs index 390ceaa1..e527ab76 100644 --- a/crates/wesley-core/src/domain/mod.rs +++ b/crates/wesley-core/src/domain/mod.rs @@ -7,4 +7,5 @@ pub mod law; pub(crate) mod normalized_sdl; pub mod operation; pub mod operation_artifact; +pub mod project_manifest; pub mod schema_delta; diff --git a/crates/wesley-core/src/domain/project_manifest.rs b/crates/wesley-core/src/domain/project_manifest.rs new file mode 100644 index 00000000..b50f7a0b --- /dev/null +++ b/crates/wesley-core/src/domain/project_manifest.rs @@ -0,0 +1,568 @@ +//! Domain-free Wesley project manifest parsing and validation. +//! +//! The manifest names GraphQL source files, generic output/evidence locations, +//! and extension target metadata. It intentionally does not assign database, +//! runtime, renderer, or product semantics to those targets. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map as JsonMap, Value as JsonValue}; +use yaml_rust2::{Yaml, YamlLoader}; + +/// Supported Wesley project manifest API version. +pub const PROJECT_MANIFEST_API_VERSION: &str = "wesley.project-manifest/v1"; + +/// Domain-free project manifest for Wesley compiler and evidence workflows. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProjectManifest { + /// Manifest API version. + #[serde(default = "default_api_version")] + pub api_version: String, + /// GraphQL schema paths, optionally with per-schema rebuild globs. + #[serde(default)] + pub schema_paths: Vec, + /// Directory used for generated Wesley evidence bundles. + #[serde(default = "default_bundle_dir")] + pub bundle_dir: String, + /// Global file globs that force every schema set to rebuild. + #[serde(default)] + pub rebuild_on_globs: Vec, + /// Preferred PR comment update behavior for automation. + #[serde(default)] + pub comment_mode: CommentMode, + /// Optional dashboard artifact settings. + #[serde(default)] + pub dashboard: DashboardConfig, + /// Selected extension targets. Target-specific semantics belong to modules. + #[serde(default)] + pub targets: Vec, +} + +impl ProjectManifest { + /// Returns schema paths with stable ids and explicit per-schema globs. + pub fn resolved_schema_paths(&self) -> Vec { + self.schema_paths + .iter() + .enumerate() + .map(|(index, entry)| entry.resolve(index)) + .collect() + } +} + +/// Schema path entry, either compact string form or object form. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +pub enum SchemaPathConfig { + /// Compact schema path form. + Path(String), + /// Detailed schema path form. + Detailed(DetailedSchemaPathConfig), +} + +/// Detailed schema path form with a stable id and rebuild globs. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DetailedSchemaPathConfig { + /// Stable schema set id. + #[serde(default)] + pub id: Option, + /// GraphQL SDL path. + pub path: String, + /// File globs that rebuild this schema set. + #[serde(default)] + pub rebuild_on_globs: Vec, +} + +impl SchemaPathConfig { + fn resolve(&self, index: usize) -> ResolvedSchemaPath { + match self { + Self::Path(path) => ResolvedSchemaPath { + id: slug_from_path(path, index), + path: path.clone(), + rebuild_on_globs: Vec::new(), + }, + Self::Detailed(DetailedSchemaPathConfig { + id, + path, + rebuild_on_globs, + }) => ResolvedSchemaPath { + id: id.clone().unwrap_or_else(|| slug_from_path(path, index)), + path: path.clone(), + rebuild_on_globs: rebuild_on_globs.clone(), + }, + } + } +} + +/// Normalized schema path used for validation and changed-file selection. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedSchemaPath { + /// Stable schema set id. + pub id: String, + /// GraphQL SDL path. + pub path: String, + /// File globs that rebuild this schema set. + pub rebuild_on_globs: Vec, +} + +/// Schema selected by changed-file analysis. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SelectedSchemaPath { + /// Stable schema set id. + pub id: String, + /// GraphQL SDL path. + pub path: String, + /// Bundle directory for this schema set. + pub bundle_dir: String, + /// Why this schema set was selected. + pub reason: String, +} + +/// PR comment update behavior for Wesley automation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CommentMode { + /// Update the existing bot comment when possible. + #[default] + Update, + /// Append a new comment for each run. + Append, + /// Do not write PR comments. + Silent, +} + +impl CommentMode { + /// Stable manifest string for this mode. + pub fn as_str(self) -> &'static str { + match self { + Self::Update => "update", + Self::Append => "append", + Self::Silent => "silent", + } + } +} + +/// Optional dashboard artifact settings. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DashboardConfig { + /// Whether to emit dashboard artifacts. + #[serde(default)] + pub enabled: bool, + /// Dashboard artifact path. + #[serde(default)] + pub artifact_path: Option, +} + +/// Domain-free extension target selection. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ManifestTargetConfig { + /// Stable target name. + pub name: String, + /// Optional external module identity that owns target semantics. + #[serde(default)] + pub module: Option, + /// Whether this target is selected when no explicit target is requested. + #[serde(default, rename = "default")] + pub is_default: bool, + /// Optional output directory for this target. + #[serde(default)] + pub output_dir: Option, + /// Generic mutually-exclusive target group. + #[serde(default)] + pub exclusive_group: Option, + /// Target names that cannot be selected with this target. + #[serde(default)] + pub conflicts_with: Vec, +} + +/// Parse or validation error for a Wesley project manifest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProjectManifestError { + /// The manifest could not be parsed as JSON or YAML. + Parse(String), + /// The manifest API version is unsupported. + UnsupportedApiVersion(String), + /// The manifest shape parsed but failed validation. + Validation(Vec), +} + +impl std::fmt::Display for ProjectManifestError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Parse(message) => write!(formatter, "manifest parse error: {message}"), + Self::UnsupportedApiVersion(version) => write!( + formatter, + "unsupported manifest apiVersion '{version}'; expected {PROJECT_MANIFEST_API_VERSION}" + ), + Self::Validation(diagnostics) => { + write!(formatter, "manifest validation failed")?; + for diagnostic in diagnostics { + write!(formatter, "; {diagnostic}")?; + } + Ok(()) + } + } + } +} + +impl std::error::Error for ProjectManifestError {} + +/// Parse and validate a Wesley project manifest from JSON or YAML. +pub fn load_project_manifest(source: &str) -> Result { + let value = parse_manifest_value(source)?; + let manifest = serde_json::from_value::(value) + .map_err(|source| ProjectManifestError::Parse(source.to_string()))?; + validate_project_manifest(&manifest)?; + Ok(manifest) +} + +/// Validate a parsed Wesley project manifest. +pub fn validate_project_manifest(manifest: &ProjectManifest) -> Result<(), ProjectManifestError> { + if manifest.api_version != PROJECT_MANIFEST_API_VERSION { + return Err(ProjectManifestError::UnsupportedApiVersion( + manifest.api_version.clone(), + )); + } + + let mut diagnostics = Vec::new(); + let schemas = manifest.resolved_schema_paths(); + let mut schema_ids = BTreeSet::new(); + let mut schema_paths = BTreeSet::new(); + for schema in &schemas { + if schema.id.trim().is_empty() { + diagnostics.push("schemaPaths contains a blank id".to_owned()); + } + if !schema_id_is_path_safe(&schema.id) { + diagnostics.push(format!( + "schemaPath id '{}' must contain only ASCII letters, digits, '.', '_', or '-'", + schema.id + )); + } + if schema.path.trim().is_empty() { + diagnostics.push(format!("schemaPath `{}` has a blank path", schema.id)); + } + if !schema_ids.insert(schema.id.clone()) { + diagnostics.push(format!("duplicate schemaPath id '{}'", schema.id)); + } + if !schema_paths.insert(normalize_path(&schema.path)) { + diagnostics.push(format!("duplicate schemaPath path '{}'", schema.path)); + } + } + + if manifest.bundle_dir.trim().is_empty() { + diagnostics.push("bundleDir must not be blank".to_owned()); + } + + let mut target_names = BTreeSet::new(); + let mut default_targets = Vec::new(); + let mut exclusive_groups: BTreeMap> = BTreeMap::new(); + for target in &manifest.targets { + if target.name.trim().is_empty() { + diagnostics.push("targets contains a blank name".to_owned()); + continue; + } + if !target_names.insert(target.name.clone()) { + diagnostics.push(format!("duplicate target '{}'", target.name)); + } + if target.is_default { + default_targets.push(target.name.clone()); + } + if let Some(group) = target.exclusive_group.as_deref() { + if group.trim().is_empty() { + diagnostics.push(format!( + "target '{}' has a blank exclusiveGroup", + target.name + )); + } else { + exclusive_groups + .entry(group.to_owned()) + .or_default() + .push(target.name.clone()); + } + } + } + + if default_targets.len() > 1 { + diagnostics.push(format!( + "multiple default targets selected: {}", + default_targets.join(", ") + )); + } + + for (group, targets) in exclusive_groups { + if targets.len() > 1 { + diagnostics.push(format!( + "exclusive group '{group}' selects multiple targets: {}", + targets.join(", ") + )); + } + } + + for target in &manifest.targets { + for conflict in &target.conflicts_with { + if target_names.contains(conflict) { + diagnostics.push(format!( + "target '{}' conflicts with selected target '{}'", + target.name, conflict + )); + } + } + } + + if diagnostics.is_empty() { + Ok(()) + } else { + Err(ProjectManifestError::Validation(diagnostics)) + } +} + +/// Select schema paths affected by a set of changed files. +pub fn select_changed_schema_paths( + manifest: &ProjectManifest, + changed_files: impl IntoIterator>, +) -> Vec { + let changed = changed_files + .into_iter() + .map(|path| normalize_path(path.as_ref())) + .filter(|path| !path.is_empty()) + .collect::>(); + let schemas = manifest.resolved_schema_paths(); + + if changed.is_empty() { + let schema_count = schemas.len(); + return schemas + .into_iter() + .map(|schema| SelectedSchemaPath { + bundle_dir: schema_bundle_dir(&manifest.bundle_dir, &schema.id, schema_count), + id: schema.id, + path: schema.path, + reason: "no changed files provided".to_owned(), + }) + .collect(); + } + + for glob in &manifest.rebuild_on_globs { + if changed.iter().any(|path| glob_matches(glob, path)) { + let schema_count = schemas.len(); + return schemas + .into_iter() + .map(|schema| SelectedSchemaPath { + bundle_dir: schema_bundle_dir(&manifest.bundle_dir, &schema.id, schema_count), + id: schema.id, + path: schema.path, + reason: format!("matched global rebuild glob `{glob}`"), + }) + .collect(); + } + } + + let schema_count = schemas.len(); + schemas + .into_iter() + .filter_map(|schema| { + let schema_path = normalize_path(&schema.path); + let bundle_dir = schema_bundle_dir(&manifest.bundle_dir, &schema.id, schema_count); + if changed.iter().any(|path| path == &schema_path) { + return Some(SelectedSchemaPath { + bundle_dir, + id: schema.id, + path: schema.path, + reason: format!("matched schema path `{schema_path}`"), + }); + } + + for glob in &schema.rebuild_on_globs { + if changed.iter().any(|path| glob_matches(glob, path)) { + return Some(SelectedSchemaPath { + bundle_dir, + id: schema.id, + path: schema.path, + reason: format!("matched schema rebuild glob `{glob}`"), + }); + } + } + + None + }) + .collect() +} + +fn schema_bundle_dir(base: &str, schema_id: &str, schema_count: usize) -> String { + let base = normalize_path(base); + if schema_count <= 1 { + return base; + } + format!("{base}/{schema_id}") +} + +fn schema_id_is_path_safe(id: &str) -> bool { + id.chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')) +} + +fn default_api_version() -> String { + PROJECT_MANIFEST_API_VERSION.to_owned() +} + +fn default_bundle_dir() -> String { + ".wesley-cache".to_owned() +} + +fn parse_manifest_value(source: &str) -> Result { + match serde_json::from_str::(source) { + Ok(value) => Ok(value), + Err(json_error) => { + let documents = YamlLoader::load_from_str(source).map_err(|yaml_error| { + ProjectManifestError::Parse(format!( + "not valid JSON ({json_error}); not valid YAML ({yaml_error})" + )) + })?; + let Some(document) = documents.first() else { + return Err(ProjectManifestError::Parse( + "YAML source contained no document".to_owned(), + )); + }; + yaml_to_json(document) + } + } +} + +fn yaml_to_json(value: &Yaml) -> Result { + match value { + Yaml::Real(value) => value + .parse::() + .map(JsonValue::from) + .map_err(|source| ProjectManifestError::Parse(source.to_string())), + Yaml::Integer(value) => Ok(JsonValue::from(*value)), + Yaml::String(value) => Ok(JsonValue::from(value.clone())), + Yaml::Boolean(value) => Ok(JsonValue::from(*value)), + Yaml::Array(values) => values + .iter() + .map(yaml_to_json) + .collect::, _>>() + .map(JsonValue::from), + Yaml::Hash(values) => { + let mut object = JsonMap::new(); + for (key, value) in values { + let key = match key { + Yaml::String(key) => key.clone(), + _ => { + return Err(ProjectManifestError::Parse( + "YAML manifest keys must be strings".to_owned(), + )); + } + }; + object.insert(key, yaml_to_json(value)?); + } + Ok(JsonValue::Object(object)) + } + Yaml::Null | Yaml::BadValue => Ok(JsonValue::Null), + Yaml::Alias(_) => Err(ProjectManifestError::Parse( + "YAML aliases are not supported in Wesley manifests".to_owned(), + )), + } +} + +fn slug_from_path(path: &str, index: usize) -> String { + let mut slug = normalize_path(path) + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + while slug.contains("--") { + slug = slug.replace("--", "-"); + } + let slug = slug.trim_matches('-').to_owned(); + if slug.is_empty() { + format!("schema-{index}") + } else { + slug + } +} + +fn normalize_path(path: &str) -> String { + path.trim() + .trim_start_matches("./") + .replace('\\', "/") + .split('/') + .filter(|segment| !segment.is_empty() && *segment != ".") + .collect::>() + .join("/") +} + +fn glob_matches(pattern: &str, path: &str) -> bool { + let pattern = normalize_path(pattern); + let path = normalize_path(path); + if pattern.is_empty() { + return false; + } + if !pattern.contains('*') && !pattern.contains('?') { + return pattern == path; + } + + let pattern_segments = pattern.split('/').collect::>(); + let path_segments = path.split('/').collect::>(); + match_glob_segments(&pattern_segments, &path_segments) +} + +fn match_glob_segments(pattern: &[&str], path: &[&str]) -> bool { + match pattern.split_first() { + None => path.is_empty(), + Some((&"**", rest)) => { + match_glob_segments(rest, path) + || (!path.is_empty() && match_glob_segments(pattern, &path[1..])) + } + Some((segment_pattern, rest)) => { + if let Some((path_segment, path_rest)) = path.split_first() { + segment_matches(segment_pattern, path_segment) + && match_glob_segments(rest, path_rest) + } else { + false + } + } + } +} + +fn segment_matches(pattern: &str, value: &str) -> bool { + let pattern = pattern.as_bytes(); + let value = value.as_bytes(); + let mut pattern_index = 0; + let mut value_index = 0; + let mut star_index = None; + let mut star_value_index = 0; + + while value_index < value.len() { + if pattern_index < pattern.len() + && (pattern[pattern_index] == b'?' || pattern[pattern_index] == value[value_index]) + { + pattern_index += 1; + value_index += 1; + } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + star_index = Some(pattern_index); + star_value_index = value_index; + pattern_index += 1; + } else if let Some(star) = star_index { + pattern_index = star + 1; + star_value_index += 1; + value_index = star_value_index; + } else { + return false; + } + } + + while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + pattern_index += 1; + } + + pattern_index == pattern.len() +} diff --git a/crates/wesley-core/src/lib.rs b/crates/wesley-core/src/lib.rs index 6fee8e81..6525a11b 100644 --- a/crates/wesley-core/src/lib.rs +++ b/crates/wesley-core/src/lib.rs @@ -28,6 +28,7 @@ pub use domain::ir::*; pub use domain::law::*; pub use domain::operation::*; pub use domain::operation_artifact::*; +pub use domain::project_manifest::*; pub use domain::schema_delta::*; pub use ports::lowering::*; pub use resilience::*; diff --git a/crates/wesley-core/tests/project_manifest.rs b/crates/wesley-core/tests/project_manifest.rs new file mode 100644 index 00000000..1f31ac85 --- /dev/null +++ b/crates/wesley-core/tests/project_manifest.rs @@ -0,0 +1,177 @@ +use wesley_core::{load_project_manifest, select_changed_schema_paths}; + +#[test] +fn project_manifest_loads_json_and_normalizes_defaults() { + let manifest = load_project_manifest( + r#" + { + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": [ + { "id": "core", "path": "schemas/core/schema.graphql" }, + "schemas/audit/schema.graphql" + ], + "targets": [ + { + "name": "rust-models", + "module": "wesley.emit.rust", + "exclusiveGroup": "model-emitter", + "default": true + } + ] + } + "#, + ) + .expect("manifest should load"); + + assert_eq!(manifest.api_version, "wesley.project-manifest/v1"); + assert_eq!(manifest.bundle_dir, ".wesley-cache"); + assert_eq!(manifest.comment_mode.as_str(), "update"); + assert_eq!(manifest.resolved_schema_paths()[0].id, "core"); + assert_eq!( + manifest.resolved_schema_paths()[1].id, + "schemas-audit-schema-graphql" + ); + assert_eq!(manifest.targets[0].name, "rust-models"); +} + +#[test] +fn project_manifest_loads_yaml() { + let manifest = load_project_manifest( + r#" +apiVersion: wesley.project-manifest/v1 +bundleDir: .wesley-cache/platform +commentMode: append +schemaPaths: + - id: core + path: schemas/core/schema.graphql + rebuildOnGlobs: + - schemas/core/** +dashboard: + enabled: true + artifactPath: .wesley-cache/platform/dashboard +"#, + ) + .expect("YAML manifest should load"); + + assert_eq!(manifest.bundle_dir, ".wesley-cache/platform"); + assert_eq!(manifest.comment_mode.as_str(), "append"); + assert!(manifest.dashboard.enabled); + assert_eq!( + manifest.dashboard.artifact_path.as_deref(), + Some(".wesley-cache/platform/dashboard") + ); +} + +#[test] +fn project_manifest_rejects_mutually_exclusive_targets() { + let error = load_project_manifest( + r#" +{ + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": ["schema.graphql"], + "targets": [ + { "name": "rust", "exclusiveGroup": "model-emitter" }, + { "name": "typescript", "exclusiveGroup": "model-emitter" } + ] +} +"#, + ) + .expect_err("conflicting targets should fail validation"); + + assert!(error + .to_string() + .contains("exclusive group 'model-emitter' selects multiple targets")); +} + +#[test] +fn changed_schema_selection_uses_schema_and_global_globs() { + let manifest = load_project_manifest( + r#" +{ + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": [ + { + "id": "core", + "path": "schemas/core/schema.graphql", + "rebuildOnGlobs": ["schemas/core/**", "shared/core.graphql"] + }, + { + "id": "audit", + "path": "schemas/audit/schema.graphql", + "rebuildOnGlobs": ["schemas/audit/**"] + } + ], + "bundleDir": ".wesley-cache/platform", + "rebuildOnGlobs": ["wesley.config.json"] +} +"#, + ) + .expect("manifest should load"); + + let selected = select_changed_schema_paths(&manifest, ["schemas/core/types.graphql"]); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].id, "core"); + assert_eq!(selected[0].bundle_dir, ".wesley-cache/platform/core"); + assert_eq!( + selected[0].reason, + "matched schema rebuild glob `schemas/core/**`" + ); + + let selected = select_changed_schema_paths(&manifest, ["wesley.config.json"]); + assert_eq!(selected.len(), 2); + assert_eq!(selected[0].bundle_dir, ".wesley-cache/platform/core"); + assert_eq!(selected[1].bundle_dir, ".wesley-cache/platform/audit"); + assert!(selected + .iter() + .all(|schema| schema.reason == "matched global rebuild glob `wesley.config.json`")); + + let selected = select_changed_schema_paths(&manifest, std::iter::empty::<&str>()); + assert_eq!(selected.len(), 2); + assert!(selected + .iter() + .all(|schema| schema.reason == "no changed files provided")); +} + +#[test] +fn project_manifest_rejects_schema_ids_that_are_not_path_safe() { + let error = load_project_manifest( + r#" +{ + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": [ + { "id": "../bad", "path": "schema.graphql" } + ] +} +"#, + ) + .expect_err("path-unsafe ids should fail validation"); + + assert!(error + .to_string() + .contains("schemaPath id '../bad' must contain only ASCII letters")); +} + +#[test] +fn project_manifest_rejects_unknown_schema_path_fields() { + let error = load_project_manifest( + r#" +{ + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": [ + { + "id": "core", + "path": "schema.graphql", + "domain": "not-wesley" + } + ] +} +"#, + ) + .expect_err("unknown schemaPath fields should fail validation"); + + let message = error.to_string(); + assert!( + message.contains("did not match any variant of untagged enum SchemaPathConfig"), + "{message}" + ); +} diff --git a/wesley.config.json b/wesley.config.json new file mode 100644 index 00000000..b51c6363 --- /dev/null +++ b/wesley.config.json @@ -0,0 +1,55 @@ +{ + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": [ + { + "id": "ecommerce", + "path": "test/fixtures/examples/ecommerce.graphql", + "rebuildOnGlobs": [ + "test/fixtures/examples/ecommerce.graphql", + "test/fixtures/weslaw/**", + "schemas/**" + ] + }, + { + "id": "reference", + "path": "test/fixtures/reference/schema.graphql", + "rebuildOnGlobs": ["test/fixtures/reference/**"] + } + ], + "bundleDir": "test/fixtures/examples/.wesley-cache", + "rebuildOnGlobs": [ + "wesley.config.json", + ".github/workflows/wesley-holmes.yml", + ".github/actions/holmes-setup/action.yml", + ".github/actions/run-holmes-command/action.yml", + "Cargo.toml", + "Cargo.lock", + "crates/**", + "packages/wesley-holmes/**", + "scripts/prepare-shipme-cert-fixture.mjs", + "docs/holmes-dashboard/**" + ], + "commentMode": "update", + "dashboard": { + "enabled": true, + "artifactPath": "docs/holmes-dashboard" + }, + "targets": [ + { + "name": "l1-ir", + "module": "wesley.core.lower", + "default": true, + "outputDir": "generated/l1" + }, + { + "name": "rust-models", + "module": "wesley.emit.rust", + "outputDir": "generated/rust" + }, + { + "name": "typescript-declarations", + "module": "wesley.emit.typescript", + "outputDir": "generated/typescript" + } + ] +} From 1808a426cc86cb5ae81922c0e568a9223e611657 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 25 Jun 2026 21:04:41 -0700 Subject: [PATCH 02/18] ci(holmes): run schema-set matrix from manifest --- .github/workflows/wesley-holmes.yml | 245 +++++++++++++----- packages/wesley-holmes/src/cli.mjs | 4 +- packages/wesley-holmes/src/pr-comment-cli.mjs | 33 ++- packages/wesley-holmes/src/pr-comment.mjs | 123 +++++++++ .../wesley-holmes/test/pr-comment.test.mjs | 68 ++++- test/ci-workflows.bats | 46 +++- 6 files changed, 427 insertions(+), 92 deletions(-) diff --git a/.github/workflows/wesley-holmes.yml b/.github/workflows/wesley-holmes.yml index 40d34ef1..4ff885bc 100644 --- a/.github/workflows/wesley-holmes.yml +++ b/.github/workflows/wesley-holmes.yml @@ -16,96 +16,170 @@ permissions: contents: read jobs: - wesley-generate: - name: '๐Ÿš€ Wesley Generation' + detect-schema-sets: + name: '๐Ÿงญ Detect Schema Sets' runs-on: ubuntu-latest outputs: - schema: ${{ steps.detect.outputs.schema }} - bundle_dir: ${{ steps.detect.outputs.bundle_dir }} + schema_sets: ${{ steps.detect.outputs.schema_sets }} + selected_count: ${{ steps.detect.outputs.selected_count }} steps: - name: Harden runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 - with: egress-policy: audit + - name: '๐Ÿ“ฆ Checkout Repository' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - - name: '๐Ÿ•ต๏ธ Detect Schema' + - name: Verify Rust toolchain + run: | + rustc --version + cargo --version + + - name: '๐Ÿงญ Detect Schema Sets' id: detect shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + BEFORE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} run: | set -euo pipefail - if [ -n "${WESLEY_SCHEMA:-}" ] && [ -f "$WESLEY_SCHEMA" ]; then - schema="$WESLEY_SCHEMA" - elif [ -n "${HOLMES_SCHEMA:-}" ] && [ -f "$HOLMES_SCHEMA" ]; then - schema="$HOLMES_SCHEMA" + + changed_file="$RUNNER_TEMP/wesley-changed-files.txt" + : > "$changed_file" + if [ "$EVENT_NAME" = "pull_request" ] && [ -n "${BASE_SHA:-}" ]; then + git diff --name-only "$BASE_SHA" "$HEAD_SHA" > "$changed_file" || true + elif [ -n "${BEFORE_SHA:-}" ] && [ "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then + git diff --name-only "$BEFORE_SHA" "$HEAD_SHA" > "$changed_file" || true + fi + + if cargo run --bin wesley -- config inspect --json > "$RUNNER_TEMP/wesley-manifest.json" 2> "$RUNNER_TEMP/wesley-manifest.err"; then + cargo run --bin wesley -- config changed-schemas \ + --changed-file "$changed_file" \ + --json > "$RUNNER_TEMP/wesley-selected.json" + node <<'NODE' > "$RUNNER_TEMP/wesley-schema-sets.json" + const fs = require('fs'); + const report = JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP + '/wesley-selected.json', 'utf8')); + const sets = (report.selectedSchemaPaths || []).map((schema) => ({ + id: schema.id, + schema: schema.path, + bundle_dir: schema.bundleDir + })); + process.stdout.write(JSON.stringify(sets)); + NODE else - mapfile -t files < <(git ls-files "**/*.graphql" 2>/dev/null || true) - picked="" - if [ ${#files[@]} -gt 0 ]; then - for f in "${files[@]}"; do - if [ "${f##*/}" = "schema.graphql" ]; then picked="$f"; break; fi - done - if [ -z "$picked" ]; then picked="${files[0]}"; fi + echo "Manifest discovery failed; falling back to legacy schema detection" >&2 + cat "$RUNNER_TEMP/wesley-manifest.err" >&2 || true + if [ -n "${WESLEY_SCHEMA:-}" ] && [ -f "$WESLEY_SCHEMA" ]; then + schema="$WESLEY_SCHEMA" + elif [ -n "${HOLMES_SCHEMA:-}" ] && [ -f "$HOLMES_SCHEMA" ]; then + schema="$HOLMES_SCHEMA" + else + mapfile -t files < <(git ls-files "**/*.graphql" 2>/dev/null || true) + picked="" + if [ ${#files[@]} -gt 0 ]; then + for f in "${files[@]}"; do + if [ "${f##*/}" = "schema.graphql" ]; then picked="$f"; break; fi + done + if [ -z "$picked" ]; then picked="${files[0]}"; fi + fi + schema="${picked:-$HOLMES_SCHEMA}" fi - schema="${picked:-$HOLMES_SCHEMA}" + base_dir="$(dirname "$schema")" + bundle_dir="$base_dir/.wesley-cache" + SCHEMA="$schema" BUNDLE_DIR="$bundle_dir" \ + node -e 'process.stdout.write(JSON.stringify([{id:"default",schema:process.env.SCHEMA,bundle_dir:process.env.BUNDLE_DIR}]))' \ + > "$RUNNER_TEMP/wesley-schema-sets.json" fi - base_dir="$(dirname "$schema")" - bundle_dir="$base_dir/.wesley-cache" - echo "schema=$schema" >> "$GITHUB_OUTPUT" - echo "bundle_dir=$bundle_dir" >> "$GITHUB_OUTPUT" - echo "Detected schema: $schema" - echo "Bundle dir: $bundle_dir" + + selected_count="$(jq 'length' "$RUNNER_TEMP/wesley-schema-sets.json")" + echo "Selected schema sets: $selected_count" + jq -r '.[] | " - \(.id): \(.schema) -> \(.bundle_dir)"' "$RUNNER_TEMP/wesley-schema-sets.json" + { + echo 'schema_sets<> "$GITHUB_OUTPUT" + + - name: '๐Ÿ“ค Upload Dashboard Template' + if: ${{ steps.detect.outputs.selected_count != '0' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: dashboard-template + path: docs/holmes-dashboard + if-no-files-found: error + + wesley-generate: + name: '๐Ÿš€ Wesley Generation' + runs-on: ubuntu-latest + needs: detect-schema-sets + if: ${{ needs.detect-schema-sets.outputs.selected_count != '0' }} + strategy: + fail-fast: false + matrix: + schema_set: ${{ fromJson(needs.detect-schema-sets.outputs.schema_sets) }} + + steps: + - name: Harden runner + + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 + + with: + egress-policy: audit + - name: '๐Ÿ“ฆ Checkout Repository' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 - name: '๐Ÿ› ๏ธ Setup HOLMES environment' uses: ./.github/actions/holmes-setup with: - bundle-dir: ${{ steps.detect.outputs.bundle_dir }} - schema: ${{ steps.detect.outputs.schema }} + bundle-dir: ${{ matrix.schema_set.bundle_dir }} + schema: ${{ matrix.schema_set.schema }} out-dir: ${{ env.HOLMES_OUT_DIR }} - artifact-name: ${{ env.HOLMES_ARTIFACT }} + artifact-name: ${{ env.HOLMES_ARTIFACT }}-${{ matrix.schema_set.id }} always-generate: 'true' - name: '๐Ÿ“Š Display Scores' run: | echo "## ๐Ÿ“Š Generation Scores" - if [ -f "${{ steps.detect.outputs.bundle_dir }}/scores.json" ]; then - cat "${{ steps.detect.outputs.bundle_dir }}/scores.json" | jq '.scores' + if [ -f "${{ matrix.schema_set.bundle_dir }}/scores.json" ]; then + cat "${{ matrix.schema_set.bundle_dir }}/scores.json" | jq '.scores' else echo "No scores.json found yet" fi - - name: '๐Ÿ“ค Upload Dashboard Template' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: dashboard-template - path: docs/holmes-dashboard - if-no-files-found: error - - name: '๐Ÿ“ฆ Prepare Bundle for Upload' run: | - ls -la "${{ steps.detect.outputs.bundle_dir }}" - test -f "${{ steps.detect.outputs.bundle_dir }}/bundle.json" + ls -la "${{ matrix.schema_set.bundle_dir }}" + test -f "${{ matrix.schema_set.bundle_dir }}/bundle.json" - name: '๐Ÿ’พ Upload Bundle' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: ${{ env.HOLMES_ARTIFACT }} + name: ${{ env.HOLMES_ARTIFACT }}-${{ matrix.schema_set.id }} path: | - ${{ steps.detect.outputs.bundle_dir }}/bundle.json - ${{ steps.detect.outputs.bundle_dir }}/scores.json - ${{ steps.detect.outputs.bundle_dir }}/history.json + ${{ matrix.schema_set.bundle_dir }}/bundle.json + ${{ matrix.schema_set.bundle_dir }}/scores.json + ${{ matrix.schema_set.bundle_dir }}/history.json if-no-files-found: error holmes-investigate: name: '๐Ÿ” HOLMES Investigation' runs-on: ubuntu-latest - needs: wesley-generate + needs: [detect-schema-sets, wesley-generate] + if: ${{ needs.detect-schema-sets.outputs.selected_count != '0' }} + strategy: + fail-fast: false + matrix: + schema_set: ${{ fromJson(needs.detect-schema-sets.outputs.schema_sets) }} steps: - name: Harden runner @@ -120,10 +194,10 @@ jobs: - name: '๐Ÿ› ๏ธ Setup HOLMES environment' uses: ./.github/actions/holmes-setup with: - bundle-dir: ${{ needs.wesley-generate.outputs.bundle_dir }} - schema: ${{ needs.wesley-generate.outputs.schema }} + bundle-dir: ${{ matrix.schema_set.bundle_dir }} + schema: ${{ matrix.schema_set.schema }} out-dir: ${{ env.HOLMES_OUT_DIR }} - artifact-name: ${{ env.HOLMES_ARTIFACT }} + artifact-name: ${{ env.HOLMES_ARTIFACT }}-${{ matrix.schema_set.id }} - name: '๐Ÿ” Run Investigation' id: holmes @@ -131,20 +205,32 @@ jobs: with: command: investigate report-name: holmes - schema-path: ${{ needs.wesley-generate.outputs.schema }} - bundle-dir: ${{ needs.wesley-generate.outputs.bundle_dir }} + schema-path: ${{ matrix.schema_set.schema }} + bundle-dir: ${{ matrix.schema_set.bundle_dir }} + + - name: '๐Ÿ“š Scope Report' + shell: bash + run: | + set -euo pipefail + mkdir -p "reports-by-schema/${{ matrix.schema_set.id }}" + cp -R reports/holmes "reports-by-schema/${{ matrix.schema_set.id }}/" - name: '๐Ÿ’พ Save Report' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: holmes-report - path: reports/holmes + name: holmes-report-${{ matrix.schema_set.id }} + path: reports-by-schema if-no-files-found: error watson-verify: name: '๐Ÿฉบ WATSON Verification' runs-on: ubuntu-latest - needs: [wesley-generate, holmes-investigate] + needs: [detect-schema-sets, wesley-generate, holmes-investigate] + if: ${{ needs.detect-schema-sets.outputs.selected_count != '0' }} + strategy: + fail-fast: false + matrix: + schema_set: ${{ fromJson(needs.detect-schema-sets.outputs.schema_sets) }} steps: - name: Harden runner @@ -159,10 +245,10 @@ jobs: - name: '๐Ÿ› ๏ธ Setup HOLMES environment' uses: ./.github/actions/holmes-setup with: - bundle-dir: ${{ needs.wesley-generate.outputs.bundle_dir }} - schema: ${{ needs.wesley-generate.outputs.schema }} + bundle-dir: ${{ matrix.schema_set.bundle_dir }} + schema: ${{ matrix.schema_set.schema }} out-dir: ${{ env.HOLMES_OUT_DIR }} - artifact-name: ${{ env.HOLMES_ARTIFACT }} + artifact-name: ${{ env.HOLMES_ARTIFACT }}-${{ matrix.schema_set.id }} - name: '๐Ÿฉบ Run Verification' id: watson @@ -170,20 +256,32 @@ jobs: with: command: verify report-name: watson - schema-path: ${{ needs.wesley-generate.outputs.schema }} - bundle-dir: ${{ needs.wesley-generate.outputs.bundle_dir }} + schema-path: ${{ matrix.schema_set.schema }} + bundle-dir: ${{ matrix.schema_set.bundle_dir }} + + - name: '๐Ÿ“š Scope Report' + shell: bash + run: | + set -euo pipefail + mkdir -p "reports-by-schema/${{ matrix.schema_set.id }}" + cp -R reports/watson "reports-by-schema/${{ matrix.schema_set.id }}/" - name: '๐Ÿ’พ Save Report' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: watson-report - path: reports/watson + name: watson-report-${{ matrix.schema_set.id }} + path: reports-by-schema if-no-files-found: error moriarty-predict: name: '๐Ÿ”ฎ MORIARTY Predictions' runs-on: ubuntu-latest - needs: [wesley-generate, watson-verify] + needs: [detect-schema-sets, wesley-generate, watson-verify] + if: ${{ needs.detect-schema-sets.outputs.selected_count != '0' }} + strategy: + fail-fast: false + matrix: + schema_set: ${{ fromJson(needs.detect-schema-sets.outputs.schema_sets) }} steps: - name: Harden runner @@ -200,10 +298,10 @@ jobs: - name: '๐Ÿ› ๏ธ Setup HOLMES environment' uses: ./.github/actions/holmes-setup with: - bundle-dir: ${{ needs.wesley-generate.outputs.bundle_dir }} - schema: ${{ needs.wesley-generate.outputs.schema }} + bundle-dir: ${{ matrix.schema_set.bundle_dir }} + schema: ${{ matrix.schema_set.schema }} out-dir: ${{ env.HOLMES_OUT_DIR }} - artifact-name: ${{ env.HOLMES_ARTIFACT }} + artifact-name: ${{ env.HOLMES_ARTIFACT }}-${{ matrix.schema_set.id }} always-generate: 'true' - name: '๐Ÿ”„ Ensure history for MORIARTY' @@ -219,7 +317,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 continue-on-error: true env: - BUNDLE_DIR: ${{ needs.wesley-generate.outputs.bundle_dir }} + BUNDLE_DIR: ${{ matrix.schema_set.bundle_dir }} BASE_REF: ${{ github.base_ref }} TIMEFRAME_HOURS: 168 with: @@ -323,8 +421,8 @@ jobs: with: command: predict report-name: moriarty - schema-path: ${{ needs.wesley-generate.outputs.schema }} - bundle-dir: ${{ needs.wesley-generate.outputs.bundle_dir }} + schema-path: ${{ matrix.schema_set.schema }} + bundle-dir: ${{ matrix.schema_set.bundle_dir }} env: MORIARTY_BASE_REF: '${{ github.base_ref }}' MORIARTY_USE_GIT: '1' @@ -333,18 +431,25 @@ jobs: MORIARTY_ACTIVITY_COMMITS_PER_DAY: '6' MORIARTY_ACTIVITY_RELEVANT_PER_DAY: '4' + - name: '๐Ÿ“š Scope Report' + shell: bash + run: | + set -euo pipefail + mkdir -p "reports-by-schema/${{ matrix.schema_set.id }}" + cp -R reports/moriarty "reports-by-schema/${{ matrix.schema_set.id }}/" + - name: '๐Ÿ’พ Save Report' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: moriarty-report - path: reports/moriarty + name: moriarty-report-${{ matrix.schema_set.id }} + path: reports-by-schema if-no-files-found: error comment-report: name: '๐Ÿ“ Post Investigation Report' runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - needs: [holmes-investigate, watson-verify, moriarty-predict] + if: github.event_name == 'pull_request' && needs.detect-schema-sets.outputs.selected_count != '0' + needs: [detect-schema-sets, holmes-investigate, watson-verify, moriarty-predict] env: HOLMES_STATUS: ${{ needs.holmes-investigate.result }} WATSON_STATUS: ${{ needs.watson-verify.result }} diff --git a/packages/wesley-holmes/src/cli.mjs b/packages/wesley-holmes/src/cli.mjs index e2d52d03..9ec603fc 100755 --- a/packages/wesley-holmes/src/cli.mjs +++ b/packages/wesley-holmes/src/cli.mjs @@ -46,7 +46,7 @@ function loadBundle(bundlePath) { } } throw new Error( - `No Wesley bundle found at ${resolved}. Run "wesley generate --emit-bundle" first.` + `No Wesley bundle found at ${resolved}. Generate or restore a Wesley evidence bundle before running HOLMES.` ); } @@ -76,7 +76,7 @@ async function main() { 'after', ` Requires: - .wesley-cache/bundle.json Generated by: wesley generate --emit-bundle + .wesley-cache/bundle.json Generated or restored before HOLMES runs .wesley-cache/history.json Built over time by multiple generations "When you have eliminated the impossible, whatever remains, diff --git a/packages/wesley-holmes/src/pr-comment-cli.mjs b/packages/wesley-holmes/src/pr-comment-cli.mjs index 37b7f560..17f10a23 100644 --- a/packages/wesley-holmes/src/pr-comment-cli.mjs +++ b/packages/wesley-holmes/src/pr-comment-cli.mjs @@ -2,7 +2,12 @@ import { pathToFileURL } from 'node:url'; -import { buildHolmesSuiteComment, loadHolmesSuiteReports } from './pr-comment.mjs'; +import { + buildHolmesMultiSchemaComment, + buildHolmesSuiteComment, + loadHolmesSuiteReportSets, + loadHolmesSuiteReports +} from './pr-comment.mjs'; if (isDirectExecution()) { main(); @@ -10,17 +15,27 @@ if (isDirectExecution()) { function main(argv = process.argv.slice(2)) { const options = parseArgs(argv); - const reports = loadHolmesSuiteReports(options.reportsDir, { + const statuses = { holmes: options.holmesStatus, watson: options.watsonStatus, moriarty: options.moriartyStatus - }); - - const body = buildHolmesSuiteComment({ - pullRequestNumber: options.prNumber, - headSha: options.headSha, - ...reports - }); + }; + const reportSets = loadHolmesSuiteReportSets(options.reportsDir, statuses); + const reports = loadHolmesSuiteReports(options.reportsDir, statuses); + + const body = + reportSets.length > 1 || reportSets[0]?.id !== 'default' + ? buildHolmesMultiSchemaComment({ + pullRequestNumber: options.prNumber, + headSha: options.headSha, + statuses, + schemaReports: reportSets + }) + : buildHolmesSuiteComment({ + pullRequestNumber: options.prNumber, + headSha: options.headSha, + ...reports + }); process.stdout.write(`${body}\n`); } diff --git a/packages/wesley-holmes/src/pr-comment.mjs b/packages/wesley-holmes/src/pr-comment.mjs index ec99e410..27b4e5e4 100644 --- a/packages/wesley-holmes/src/pr-comment.mjs +++ b/packages/wesley-holmes/src/pr-comment.mjs @@ -91,6 +91,96 @@ export function buildHolmesSuiteComment({ return lines.join('\n'); } +export function buildHolmesMultiSchemaComment({ + pullRequestNumber, + headSha = '', + statuses = {}, + schemaReports = [] +}) { + if (schemaReports.length === 0) { + return buildHolmesSuiteComment({ + pullRequestNumber, + headSha, + statuses + }); + } + + const lines = [ + HOLMES_SUITE_COMMENT_MARKER, + renderCurrentShaMarker(headSha), + `# ๐Ÿ” The Case of Pull Request #${pullRequestNumber}`, + '', + '## Schema Sets', + '', + ...schemaReports.map((entry) => `- \`${entry.id}\``), + '', + ...schemaReports.flatMap((entry, index) => [ + index === 0 ? '' : '---', + '', + `## Schema Set \`${entry.id}\``, + '', + '### Plain-English Readout', + '', + ...renderPlainEnglishReadout({ + holmesReport: entry.holmesReport, + watsonReport: entry.watsonReport, + moriartyReport: entry.moriartyReport, + statuses, + reportStates: entry.reportStates + }), + '', + ...renderNextActions({ + holmesReport: entry.holmesReport, + watsonReport: entry.watsonReport, + moriartyReport: entry.moriartyReport, + statuses, + reportStates: entry.reportStates + }), + '', + renderReportSection( + `๐Ÿ•ต๏ธ SHA-lock HOLMES full report for ${entry.id}`, + entry.holmesMarkdown, + statuses.holmes, + 'holmes', + 'holmes-report.md', + entry.markdownStates.holmes + ), + '', + renderReportSection( + `๐Ÿฉบ Dr. WATSON full report for ${entry.id}`, + entry.watsonMarkdown, + statuses.watson, + 'watson', + 'watson-report.md', + entry.markdownStates.watson + ), + '', + renderReportSection( + `๐Ÿ”ฎ Professor MORIARTY full report for ${entry.id}`, + entry.moriartyMarkdown, + statuses.moriarty, + 'moriarty', + 'moriarty-report.md', + entry.markdownStates.moriarty + ), + '' + ]), + '---', + '', + ...renderGlossary(), + '', + '---', + '', + '_Machine-readable reports are grouped by schema set in workflow artifacts._', + '', + '---', + '', + '*Filed at 221B Repository Street*' + ]; + + return lines.join('\n'); +} + export function loadHolmesSuiteReports(reportsDir, statuses = {}) { const holmesReport = readJsonReport(reportsDir, 'holmes', 'holmes-report.json'); const watsonReport = readJsonReport(reportsDir, 'watson', 'watson-report.json'); @@ -134,6 +224,39 @@ export function loadHolmesSuiteReports(reportsDir, statuses = {}) { }; } +export function loadHolmesSuiteReportSets(reportsDir, statuses = {}) { + const schemaDirs = findSchemaReportDirs(reportsDir); + if (schemaDirs.length === 0) { + return [ + { + id: 'default', + ...loadHolmesSuiteReports(reportsDir, statuses) + } + ]; + } + + return schemaDirs.map((entry) => ({ + id: entry.name, + ...loadHolmesSuiteReports(entry.path, statuses) + })); +} + +function findSchemaReportDirs(reportsDir) { + if (!existsSync(reportsDir)) return []; + return readdirSync(reportsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + path: path.join(reportsDir, entry.name) + })) + .filter((entry) => + ['holmes', 'watson', 'moriarty'].some((reportName) => + existsSync(path.join(entry.path, reportName)) + ) + ) + .sort((left, right) => left.name.localeCompare(right.name)); +} + function renderCurrentShaMarker(headSha) { const sha = normalizeOptionalString(headSha); return sha ? `` : ''; diff --git a/packages/wesley-holmes/test/pr-comment.test.mjs b/packages/wesley-holmes/test/pr-comment.test.mjs index 7cdbce91..53b1ee1b 100644 --- a/packages/wesley-holmes/test/pr-comment.test.mjs +++ b/packages/wesley-holmes/test/pr-comment.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -353,6 +353,72 @@ test('pr-comment CLI builds comment output without external argument parser depe } }); +test('pr-comment CLI aggregates schema-scoped HOLMES report directories', () => { + const reportsDir = mkdtempSync(path.join(os.tmpdir(), 'holmes-pr-comment-schema-sets-')); + try { + for (const schemaId of ['ecommerce', 'reference']) { + for (const reportName of ['holmes', 'watson', 'moriarty']) { + mkdirSync(path.join(reportsDir, schemaId, reportName), { recursive: true }); + } + writeFileSync( + path.join(reportsDir, schemaId, 'holmes', 'holmes-report.json'), + JSON.stringify(sampleHolmesReport()) + ); + writeFileSync( + path.join(reportsDir, schemaId, 'watson', 'watson-report.json'), + JSON.stringify(sampleWatsonReport()) + ); + writeFileSync( + path.join(reportsDir, schemaId, 'moriarty', 'moriarty-report.json'), + JSON.stringify(sampleMoriartyReport()) + ); + writeFileSync( + path.join(reportsDir, schemaId, 'holmes', 'holmes-report.md'), + `### holmes ${schemaId}\n` + ); + writeFileSync( + path.join(reportsDir, schemaId, 'watson', 'watson-report.md'), + `### watson ${schemaId}\n` + ); + writeFileSync( + path.join(reportsDir, schemaId, 'moriarty', 'moriarty-report.md'), + `### moriarty ${schemaId}\n` + ); + } + + const result = spawnSync( + process.execPath, + [ + prCommentCliPath, + '--reports-dir', + reportsDir, + '--pr-number', + '467', + '--head-sha', + 'feedfacedeadbeef', + '--holmes-status', + 'success', + '--watson-status', + 'success', + '--moriarty-status', + 'success' + ], + { + encoding: 'utf8' + } + ); + + assert.equal(result.status, 0, result.stderr); + assert.ok(result.stdout.includes('## Schema Sets')); + assert.ok(result.stdout.includes('## Schema Set `ecommerce`')); + assert.ok(result.stdout.includes('## Schema Set `reference`')); + assert.ok(result.stdout.includes('### holmes ecommerce')); + assert.ok(result.stdout.includes('### holmes reference')); + } finally { + rmSync(reportsDir, { recursive: true, force: true }); + } +}); + test('pr-comment CLI can be imported without executing the entrypoint', () => { const result = spawnSync( process.execPath, diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index 787f084a..a345c1a9 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -187,6 +187,32 @@ load 'bats-plugins/bats-assert/load' [ "$output" -eq 0 ] } +@test "HOLMES workflow uses Wesley project manifest for selective schema sets" { + run bash -lc "grep -F 'detect-schema-sets:' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 1 ] + + run bash -lc "grep -F 'cargo run --bin wesley -- config inspect --json' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 1 ] + + run bash -lc "grep -F 'cargo run --bin wesley -- config changed-schemas' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 1 ] + + run bash -lc "grep -F 'schema_set: \${{ fromJson(needs.detect-schema-sets.outputs.schema_sets) }}' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 4 ] + + run bash -lc "grep -F 'reports-by-schema/\${{ matrix.schema_set.id }}' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 6 ] + + run bash -lc "grep -F 'steps.detect.outputs.selected_count' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -ge 1 ] +} + @test "repo Bats tests use vendored plugins without runtime fetches" { run test -f test/vendor/bats-plugins/bats-support/load.bash assert_success @@ -446,32 +472,32 @@ load 'bats-plugins/bats-assert/load' [ "$output" -eq 2 ] } -@test "wesley-holmes workflow propagates detected schema outputs into analysis jobs" { +@test "wesley-holmes workflow propagates selected schema matrix into analysis jobs" { run bash -lc "grep -F 'outputs:' .github/workflows/wesley-holmes.yml | wc -l" assert_success [ "$output" -ge 1 ] - run bash -lc "grep -F 'steps.detect.outputs.schema' .github/workflows/wesley-holmes.yml | wc -l" + run bash -lc "grep -F 'schema_sets: \${{ steps.detect.outputs.schema_sets }}' .github/workflows/wesley-holmes.yml | wc -l" assert_success - [ "$output" -ge 2 ] + [ "$output" -eq 1 ] - run bash -lc "grep -F 'steps.detect.outputs.bundle_dir' .github/workflows/wesley-holmes.yml | wc -l" + run bash -lc "grep -F 'selected_count: \${{ steps.detect.outputs.selected_count }}' .github/workflows/wesley-holmes.yml | wc -l" assert_success - [ "$output" -ge 2 ] + [ "$output" -eq 1 ] - run bash -lc "grep -F 'needs.wesley-generate.outputs.schema' .github/workflows/wesley-holmes.yml | wc -l" + run bash -lc "grep -F 'matrix.schema_set.schema' .github/workflows/wesley-holmes.yml | wc -l" assert_success - [ "$output" -ge 3 ] + [ "$output" -ge 4 ] - run bash -lc "grep -F 'needs.wesley-generate.outputs.bundle_dir' .github/workflows/wesley-holmes.yml | wc -l" + run bash -lc "grep -F 'matrix.schema_set.bundle_dir' .github/workflows/wesley-holmes.yml | wc -l" assert_success [ "$output" -ge 4 ] - run bash -lc "grep -F 'needs: [wesley-generate, holmes-investigate]' .github/workflows/wesley-holmes.yml | wc -l" + run bash -lc "grep -F 'needs: [detect-schema-sets, wesley-generate, holmes-investigate]' .github/workflows/wesley-holmes.yml | wc -l" assert_success [ "$output" -eq 1 ] - run bash -lc "grep -F 'needs: [wesley-generate, watson-verify]' .github/workflows/wesley-holmes.yml | wc -l" + run bash -lc "grep -F 'needs: [detect-schema-sets, wesley-generate, watson-verify]' .github/workflows/wesley-holmes.yml | wc -l" assert_success [ "$output" -eq 1 ] From f1b3a5f7d5b70186e131628863f06436c7fa584b Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 25 Jun 2026 21:04:47 -0700 Subject: [PATCH 03/18] docs(platform): document manifest and module onramp --- CHANGELOG.md | 23 + CONTRIBUTING.md | 13 + README.md | 12 + docs/GUIDE.md | 14 +- docs/README.md | 9 +- docs/architecture/holmes-architecture.md | 10 +- docs/architecture/holmes-integration.md | 562 +++--------------- docs/build-artifacts.md | 18 +- docs/guides/extending.md | 60 +- docs/guides/generator-plugins.md | 18 +- docs/guides/module-authoring.md | 164 +++++ docs/guides/quick-start.md | 21 + docs/reference/cli.md | 149 +++-- docs/reference/project-manifest.md | 188 ++++++ docs/truth-manifest.json | 15 + test/domain-empty-boundary.bats | 22 + .../fixtures/extensions/fixture-zoo/README.md | 14 + .../blade-heavy/fixture-extension.json | 28 + .../compiler-heavy/fixture-extension.json | 37 ++ .../evidence-heavy/fixture-extension.json | 32 + 20 files changed, 832 insertions(+), 577 deletions(-) create mode 100644 docs/guides/module-authoring.md create mode 100644 docs/reference/project-manifest.md create mode 100644 test/fixtures/extensions/fixture-zoo/README.md create mode 100644 test/fixtures/extensions/fixture-zoo/blade-heavy/fixture-extension.json create mode 100644 test/fixtures/extensions/fixture-zoo/compiler-heavy/fixture-extension.json create mode 100644 test/fixtures/extensions/fixture-zoo/evidence-heavy/fixture-extension.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f6cbafd..2f188576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,34 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve ## [Unreleased] +### Added + +- **Project manifest and config CLI**: Added the domain-free + `wesley.project-manifest/v1` JSON/YAML manifest with schema paths, bundle + directories, rebuild globs, comment mode, dashboard settings, and generic + target metadata. The native CLI now exposes `wesley config validate`, + `wesley config inspect`, and `wesley config changed-schemas`; single-schema + manifests can also provide the default schema for `schema lower`, `schema +hash`, and `schema operations`. +- **Fixture module zoo**: Added descriptor-only compiler-heavy, + evidence-heavy, and BLADE-heavy fixture modules under + `test/fixtures/extensions/fixture-zoo`, with domain-empty regression guards. +- **Contributor onramp**: Added a public near-term roadmap issue and scoped + `good first issue` starter tasks, and linked the onboarding path from + `CONTRIBUTING.md`. + ### Changed - **Release documentation gate**: The release runbook, release policy, and human sign-off checklist now require a `docs/topics/` accuracy and coverage audit before tagging, with minimum 90% accuracy and 90% coverage floors. +- **HOLMES schema selection**: The HOLMES workflow now reads the Wesley project + manifest first, computes changed schema sets with `wesley config +changed-schemas`, runs schema-scoped matrix jobs, and keeps per-schema report + artifacts grouped for one aggregate PR comment. +- **Extension documentation**: Added current project-manifest and module + authoring references, and clarified that `wesley.config.mjs` and the dynamic + JavaScript module loader are retired from generic Wesley core. ### Removed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 17d0ce6e..2daa611e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,19 @@ Repository files are the evidence ledger. Design packets, witnesses, retros, release notes, and signpost docs record stable truth and proof after work is done. The Chronicle files in the repo root are historical archive only. +## Contributor Onramp + +New contributors should start from scoped GitHub Issues, not from repo-local +backlog files: + +- [Good first issues](https://github.com/flyingrobots/wesley/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22) +- [Near-term roadmap issue](https://github.com/flyingrobots/wesley/issues/646) +- [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18) + +Starter issues must already have a goalpost milestone and one scheduling-state +label such as `v0.3.0`. If an issue still has a `triage:*` label, it is not a +starter task until a maintainer schedules, splits, moves, or closes it. + ## Design Requirements Every non-trivial cycle packet under `docs/design//` must name: diff --git a/README.md b/README.md index 7a850261..65b4638d 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,16 @@ Run the local release-quality gate before opening a PR: cargo xtask preflight ``` +Validate a project manifest when a repo wants config-driven schema selection: + +```bash +cargo wesley config validate --config wesley.config.json --json +cargo wesley config changed-schemas \ + --config wesley.config.json \ + --changed test/fixtures/examples/ecommerce.graphql \ + --json +``` + The retained pnpm workspace supports docs, Holmes assurance tooling, and workspace checks. Use Node `>=22.12.0` with pnpm `9.15.9` when working from this checkout. @@ -140,7 +150,9 @@ For complete history, read [CHANGELOG.md](./CHANGELOG.md). - [End To End](./docs/END_TO_END.md) - [Entrypoints](./docs/ENTRYPOINTS.md) - [CLI reference](./docs/reference/cli.md) +- [Project manifest](./docs/reference/project-manifest.md) - [Directive truth table](./docs/reference/directives.md) +- [Module authoring](./docs/guides/module-authoring.md) - [Technical teardown](./docs/TECHNICAL_TEARDOWN.md) - [Release policy](./docs/governance/RELEASE_POLICY.md) - [Contributing](./CONTRIBUTING.md) diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 412b4f3e..2f5aee36 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -21,6 +21,8 @@ artifacts. - **Strict preflight**: `cargo xtask preflight` - **Explicit alias**: `cargo xtask strict-preflight` - **Release check**: `cargo xtask release-check` +- **Project manifest**: `cargo wesley config validate --json` +- **Changed schemas**: `cargo wesley config changed-schemas --changed --json` The Rust-native CLI is now the normal front door for Wesley core work. The native binary stays small while core behavior moves into the Rust library. @@ -58,6 +60,9 @@ The historical package CLI is retired. Use the native commands: - **Law Rebind**: `wesley law rebind --schema --law [--accept --out ]` - **Law Capabilities**: `wesley law capabilities --law [--json]` - **Law Coverage**: `wesley law coverage --schema --law [--profile release] [--json]` +- **Manifest Validate**: `wesley config validate [--config ] [--json]` +- **Manifest Inspect**: `wesley config inspect [--config ] [--json]` +- **Changed Schemas**: `wesley config changed-schemas [--config ] [--changed ...] [--changed-file ] [--json]` - **Rust**: `wesley emit rust --schema --out [--law ]` - **TypeScript**: `wesley emit typescript --schema --out [--law ]` @@ -93,8 +98,12 @@ not currently load arbitrary JavaScript modules as product commands. External targets still own target semantics, generators, witness scopes, release profiles, and runtime conventions. Today that ownership is expressed -through explicit Rust emitters, external repos such as `wesley-postgres`, or -future target protocols. Wesley core does not own those meanings. +through explicit Rust emitters, descriptor-only fixture modules, external repos +such as `wesley-postgres`, or future target protocols. Wesley core does not own +those meanings. Use [Project Manifest](./reference/project-manifest.md) for +current config-driven schema and target metadata, and +[Module Authoring Guide](./guides/module-authoring.md) for the current extension +boundary. For the active ownership rule, see [design/0014-domain-empty-core-boundary](./design/0014-domain-empty-core-boundary/domain-empty-core-boundary.md). @@ -167,6 +176,7 @@ Wesley is a tiered engine designed to enforce contract integrity across platform - [ ] **I am adding a generic projection**: Start in `crates/wesley-emit-rust` or `crates/wesley-emit-typescript`. - [ ] **I am adding a domain target**: Put it in an owning external repo or design an explicit target protocol before wiring it into Wesley. - [ ] **I am extending Wesley**: Use `docs/guides/extending.md` to pick the Rust core, native CLI, emitter, external module, or `xtask` boundary. +- [ ] **I am configuring schema sets**: Use `docs/reference/project-manifest.md` and validate with `wesley config validate`. - [ ] **I am contributing to Wesley**: Read `METHOD.md` and `BEARING.md`. - [ ] **I am touching Continuum behavior**: Work in the Continuum-owned module/repo, not here. - [ ] **I am touching PostgreSQL or Supabase behavior**: Work in `wesley-postgres`, not here. diff --git a/docs/README.md b/docs/README.md index b47a800e..735afd9f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,8 +20,10 @@ which signpost is supposed to answer which question. | [SDL, Shape, And Law](./SDL.md) | Why GraphQL SDL is Wesley's contract substrate and where domain law interpretation belongs. | | [BEARING](./BEARING.md) | Current direction, what is already real in the repo, and the tensions that still matter. | | [Extending Wesley](./guides/extending.md) | How to add Rust compiler behavior, native CLI commands, emitter projections, or external modules. | -| [CLI Reference](./reference/cli.md) | Current Rust-native `wesley` command reference. | -| [Directive Truth Table](./reference/directives.md) | Current directive support levels, aliases, external families, and fixture boundaries. | +| [Module Authoring](./guides/module-authoring.md) | Current Rust-native extension boundary, descriptor fixtures, and troubleshooting. | +| [CLI Reference](./reference/cli.md) | Current Rust-native `wesley` command reference. | +| [Project Manifest](./reference/project-manifest.md) | Current JSON/YAML manifest schema for schemas, rebuild selection, bundles, and target metadata. | +| [Directive Truth Table](./reference/directives.md) | Current directive support levels, aliases, external families, and fixture boundaries. | | [VISION](./VISION.md) | Bounded executive synthesis grounded in repo-visible truth. | | [Design Packets](./design/README.md) | Active design packets and doctrinal boundary notes. | | [METHOD Process](./method/process.md) | How cycles run, close, and reconcile in this repo. | @@ -87,11 +89,13 @@ It also now has a more explicit METHOD closeout surface under - [Wesley North Star](./NORTHSTAR.md) - [SDL, Shape, And Law](./SDL.md) - [CLI Reference](./reference/cli.md) +- [Project Manifest](./reference/project-manifest.md) - [Directive Truth Table](./reference/directives.md) - [BEARING](./BEARING.md) - [Design Packets](./design/README.md) - [Wesley Core Versus Toolchain](./architecture/wesley-core-vs-toolchain.md) - [Extending Wesley](./guides/extending.md) +- [Module Authoring](./guides/module-authoring.md) - [Module Contract](./design/wesley-module-contract.md) ### Historical Continuum Extraction Context @@ -118,6 +122,7 @@ It also now has a more explicit METHOD closeout surface under - [Invariants](./invariants/README.md) - [Legends](./method/legends/README.md) - [CLI Reference](./reference/cli.md) +- [Project Manifest](./reference/project-manifest.md) - [Directive Truth Table](./reference/directives.md) ## Current Honesty Rules diff --git a/docs/architecture/holmes-architecture.md b/docs/architecture/holmes-architecture.md index b5869afa..bd6201af 100644 --- a/docs/architecture/holmes-architecture.md +++ b/docs/architecture/holmes-architecture.md @@ -2,7 +2,8 @@ ## Package Overview -`@wesley/holmes` is a sidecar package that inspects the evidence bundle emitted by `wesley generate` and produces three complementary reports: +`@wesley/holmes` is a sidecar package that inspects Wesley evidence bundles and +produces three complementary reports: - **Holmes** performs the core investigation, weighting coverage evidence and surfacing security/test gates. - **Watson** re-verifies the bundle contents independently, spot-checking git history, recalculating scores, and flagging inconsistencies. @@ -20,7 +21,12 @@ The CLI uses Commander to provide subcommands that wrap each investigator and a - `holmes report [--json ]` - `holmes weights [--file ] [--json ]` -Each command loads the required generated artifacts (`.wesley-cache/bundle.json`, `.wesley-cache/history.json`) plus visible Holmes config such as `wesley.weights.json`, validates the generated report structure, and optionally writes structured JSON alongside the human-readable Markdown output. Unknown commands automatically fall back to Commanderโ€™s help, which still prints the original banner, requirements list, and quote block. +Each command loads the required generated or restored artifacts +(`.wesley-cache/bundle.json`, `.wesley-cache/history.json`) plus visible Holmes +config such as `wesley.weights.json`, validates the generated report structure, +and optionally writes structured JSON alongside the human-readable Markdown +output. Unknown commands automatically fall back to Commanderโ€™s help, which +still prints the original banner, requirements list, and quote block. ## Holmes Investigator diff --git a/docs/architecture/holmes-integration.md b/docs/architecture/holmes-integration.md index c1241e31..3f5eb6d3 100644 --- a/docs/architecture/holmes-integration.md +++ b/docs/architecture/holmes-integration.md @@ -1,508 +1,142 @@ -# Wesley + SHA-lock HOLMES Integration +# Wesley HOLMES Integration -## The Schema Intelligence System + -Wesley generates code from GraphQL. SHA-lock HOLMES proves it's correct, WATSON verifies it independently, and MORIARTY predicts when it's production-ready. +HOLMES is Wesley's assurance sidecar. It consumes explicit evidence artifacts +and reports on their quality. It does not become the source of truth for +GraphQL shape, `weslaw`, target semantics, runtime behavior, or product policy. -**HOLMES is a separate sidecar package** (`@wesley/holmes`) that consumes Wesley's evidence bundle without bloating the core. +The current CI integration is intentionally narrow: -```mermaid -graph TB - subgraph "Wesley Core" - Schema[GraphQL Schema] - Parser[Parser] - IR[Domain IR] - Generators[Generators] - end - - subgraph "Generated Artifacts" - SQL[SQL DDL] - Types[TypeScript] - Zod[Zod Schemas] - Tests[pgTAP Tests] - end - - subgraph "Evidence System" - EvidenceMap[Evidence Map] - Scores[SCS/MRI/TCI] - Bundle[.wesley-cache/bundle] - end - - subgraph "SHA-lock HOLMES" - Holmes[Investigation] - Watson[Verification] - Moriarty[Prediction] - end - - Schema --> Parser --> IR --> Generators - Generators --> SQL & Types & Zod & Tests - Generators --> EvidenceMap - IR --> Scores - EvidenceMap & Scores --> Bundle - Bundle --> Holmes --> Watson --> Moriarty - - style Schema fill:#f9f,stroke:#333,stroke-width:4px - style Bundle fill:#9f9,stroke:#333,stroke-width:2px -``` - -## New Directives - -Wesley now supports intelligent directives that enable HOLMES analysis: - -### Identity Directives - -```graphql -directive @uid(value: String!) on OBJECT | FIELD_DEFINITION - -type User @table @uid("tbl:user") { - id: ID! @primaryKey @uid("col:user.id") - email: String! @unique @uid("col:user.email") -} -``` - -**Purpose**: Stable identities that survive renames. HOLMES uses these to track elements across commits. - -### Weight & Criticality Directives - -```graphql -directive @weight(value: Int! = 5) on OBJECT | FIELD_DEFINITION -directive @critical on OBJECT | FIELD_DEFINITION -directive @sensitive on FIELD_DEFINITION -directive @pii on FIELD_DEFINITION - -type User @table @critical { - id: ID! @primaryKey - email: String! @pii @weight(8) - password: String! @sensitive @weight(10) - theme: String @weight(2) # Low priority UI preference -} +```text +wesley.config.json + -> wesley config changed-schemas + -> schema-scoped HOLMES matrix jobs + -> grouped report artifacts + -> one aggregate PR comment ``` -**Purpose**: Not all fields are equal. Critical auth fields matter more than UI preferences. +## Ownership Boundary -### Default Weights +| Surface | Owner | +| ----------------------------------------------------- | ----------------------------------------- | +| GraphQL SDL parsing and deterministic IR | `wesley-core` | +| Project manifest parsing and changed-schema selection | `wesley-core` and `wesley-cli` | +| Evidence fixture preparation in this repo | `scripts/prepare-shipme-cert-fixture.mjs` | +| HOLMES, WATSON, and MORIARTY report rendering | `packages/wesley-holmes` | +| Target semantics and runtime behavior | External target modules or sibling repos | -| Directive/Type | Default Weight | Rationale | -| -------------- | -------------- | ------------------------------ | -| `@critical` | 10 | Mission-critical functionality | -| `@primaryKey` | 10 | Core identity field | -| `@sensitive` | 9 | Security-critical | -| `@foreignKey` | 8 | Relationship integrity | -| `@unique` | 8 | Business constraint | -| `@pii` | 8 | Privacy compliance | -| `@index` | 5 | Performance optimization | -| Default field | 5 | Standard field | +HOLMES may judge evidence quality. It must not reinterpret the schema or decide +what a database, runtime, renderer, scheduler, or product target means. -## The Evidence Map +## Project Manifest Input -Every `wesley generate` now produces generated evidence artifacts under `.wesley-cache/`: +The workflow reads the current +[Project Manifest](../reference/project-manifest.md) before falling back to +legacy heuristic detection. A manifest can declare multiple schema sets: ```json { - "sha": "abc123def456", - "timestamp": "2024-03-20T10:30:00Z", - "evidence": { - "col:user.email": { - "sql": [ - { - "file": "out/schema.sql", - "lines": "42-49", - "sha": "abc123d", - "timestamp": "2024-03-20T10:30:00Z" - } - ], - "ts": [ - { - "file": "out/types/User.d.ts", - "lines": "5-10", - "sha": "abc123d" - } - ], - "zod": [ - { - "file": "out/zod/user.ts", - "lines": "9-15", - "sha": "abc123d" - } - ], - "tests": [ - { - "file": "tests/schema/constraints.sql", - "lines": "70-90", - "sha": "abc123d" - } - ] + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": [ + { + "id": "ecommerce", + "path": "test/fixtures/examples/ecommerce.graphql", + "rebuildOnGlobs": ["test/fixtures/examples/ecommerce.graphql"] + }, + { + "id": "reference", + "path": "test/fixtures/reference/schema.graphql", + "rebuildOnGlobs": ["test/fixtures/reference/**"] } - } + ], + "bundleDir": "test/fixtures/examples/.wesley-cache", + "rebuildOnGlobs": ["wesley.config.json"] } ``` -**Purpose**: HOLMES can cite `file:lines@sha` without brittle grep. Every claim is SHA-locked. - -## The Scoring System - -Wesley calculates three scores: - -### SCS - Schema Coverage Score +The detection job computes changed files from the PR or push diff, then runs: -**Formula**: `ฮฃ(weight ร— present) / ฮฃ(weight)` - -- Measures: Did artifacts exist for each schema element? -- Range: 0-1 (0% to 100%) -- Threshold: 0.8 (80%) recommended -- Breakdown surfaced in bundles: - - **sql** โ€“ weighted DDL coverage for each UID - - **types** โ€“ TypeScript emission coverage - - **validation** โ€“ Zod/runtime schema coverage - - **tests** โ€“ pgTAP suites covering the element - -Example: - -```javascript -// High-weight critical field missing TypeScript type -User.password: weight=10, sql=โœ“, ts=โœ—, zod=โœ“ โ†’ 66% coverage -User.theme: weight=2, sql=โœ“, ts=โœ“, zod=โœ“ โ†’ 100% coverage - -// Weighted average favors critical field -SCS = (10 ร— 0.66 + 2 ร— 1.0) / 12 = 0.72 (72%) +```bash +wesley config inspect --json +wesley config changed-schemas --changed-file "$changed_file" --json ``` -### MRI - Migration Risk Index - -**Formula**: Risk points normalized to 0-1 - -| Operation | Risk Points | Reason | -| ------------------------- | ----------- | ------------------ | -| DROP TABLE | 40 | Data loss | -| DROP COLUMN | 25 | Data loss | -| ALTER TYPE (unsafe) | 30 | May fail | -| ADD NOT NULL (no default) | 25 | Requires backfill | -| RENAME (no @uid) | 10 | Breaks references | -| CREATE INDEX (blocking) | 10 | Performance impact | +If no changed files are available, every schema set is selected. If changed +files match a schema-local glob, only that schema set is selected. If changed +files match a top-level rebuild glob, every schema set is selected. If no +schema set matches, the workflow skips the HOLMES matrix. -Breakdown vectors in `scores.breakdown.mri`: +For multi-schema manifests, selected schema sets receive isolated bundle +directories below `bundleDir`, such as: -- **drops** โ€“ destructive operations such as `DROP TABLE`/`DROP COLUMN` -- **renames** โ€“ renames without `@uid` continuity -- **defaults** โ€“ new NOT NULL columns missing defaults/backfill strategy -- **typeChanges** โ€“ ALTER TYPE operations (unsafe casts weigh more) -- **indexes** โ€“ blocking index creation or missing `CONCURRENTLY` -- **other** โ€“ residual operations recorded for transparency - -Example: - -```sql --- Migration with MRI = 0.55 (55% risk) -DROP COLUMN users.old_field; -- +25 -ALTER COLUMN posts.count TYPE bigint; -- +30 (unsafe) --- Total: 55 points โ†’ 0.55 MRI +```text +test/fixtures/examples/.wesley-cache/ecommerce +test/fixtures/examples/.wesley-cache/reference ``` -### TCI - Test Confidence Index - -**Weighted formula**: - -- Structure tests: 20% -- Constraint tests: 45% (weighted by field importance) -- Migration tests: 25% -- Performance tests: 10% -- Bundle sub-metrics: - - **unitConstraints** โ€“ weighted structure/constraint coverage (with depth detail) - - **rls** โ€“ RLS policy verification for tables annotated with `@wes_rls` - - **integrationRelations** โ€“ behaviour/computed/relationship checks - - **e2eOps** โ€“ migration steps exercised by pgTAP suites +## Matrix Jobs -Example: - -``` -Structure: 15/15 tables tested = 100% ร— 0.20 = 0.20 -Constraints: 23/25 tested = 92% ร— 0.45 = 0.41 -Migrations: 8/10 tested = 80% ร— 0.25 = 0.20 -Performance: 1/5 indexes = 20% ร— 0.10 = 0.02 -TCI = 0.83 (83%) -``` +`.github/workflows/wesley-holmes.yml` runs these jobs: -## The Truth Bundle +| Job | Responsibility | +| -------------------- | ------------------------------------------------------------------------- | +| `detect-schema-sets` | Build the selected schema-set matrix from the manifest and changed files. | +| `wesley-generate` | Prepare the evidence bundle for each selected schema set. | +| `holmes-investigate` | Run the HOLMES investigation report per selected schema set. | +| `watson-verify` | Run the WATSON verification report per selected schema set. | +| `moriarty-predict` | Run the MORIARTY forecast per selected schema set. | +| `comment-report` | Download grouped reports and update one aggregate PR comment. | -Every `wesley generate` creates a `.wesley-cache/` bundle: +Report artifacts are grouped by schema set: +```text +reports/ + ecommerce/ + holmes/holmes-report.json + watson/watson-report.json + moriarty/moriarty-report.json + reference/ + holmes/holmes-report.json + watson/watson-report.json + moriarty/moriarty-report.json ``` -.wesley-cache/ -โ”œโ”€โ”€ schema.ast.json # Normalized AST (sorted) -โ”œโ”€โ”€ schema.ir.json # Wesley domain IR -โ”œโ”€โ”€ artifacts.json # {artifact: [files]} with hashes -โ”œโ”€โ”€ evidence-map.json # Element โ†’ file:lines@sha -โ”œโ”€โ”€ snapshot.json # Previous IR for diffs -โ”œโ”€โ”€ scores.json # SCS/MRI/TCI scores + breakdowns -โ””โ”€โ”€ history.json # Score history for predictions -``` - -Bundles include `"bundleVersion": "2.0.0"` to let downstream consumers branch on schema changes without guessing. - -## CI Integration Notes for Moriarty - -Moriarty can leverage the PRโ€™s actual git graph to better infer active work on a branch, preventing false โ€œplateauโ€ warnings when SCS hasnโ€™t ticked yet: -- Ensure checkout is unshallowed and remote branches are fetched: - - Use `actions/checkout@v4` with `fetch-depth: 0`. - - Optionally run `git fetch --prune --unshallow --tags` and fetch refs under `refs/remotes/origin/*`. -- Provide the base branch (typically `github.base_ref`) as `MORIARTY_BASE_REF`. -- Optional environment tuning: - - `MORIARTY_GIT_WINDOW_HOURS`: 24 (fallback activity window) - - `MORIARTY_ACTIVITY_THRESHOLD`: 0.35 (suppress plateau if activity above this) - - `MORIARTY_ACTIVITY_COMMITS_PER_DAY`: 6 - - `MORIARTY_ACTIVITY_RELEVANT_PER_DAY`: 4 +The PR comment builder detects that grouped layout and renders one anchored +comment with separate sections for each schema set. -Moriarty blends signals as follows: +## Dashboard Artifact -- `activityIndex = 0.6 ร— PRActivity + 0.4 ร— WindowActivity` (each normalized 0โ€“1) -- Blended velocity = `0.7 ร— SCSRecentVelocity + 0.3 ร— activityIndex ร— 0.02` -- Plateau triggers only when blended velocity is below 1%/day AND the activity index is below threshold. +The workflow uploads `docs/holmes-dashboard` as a dashboard template and +assembles a `holmes-dashboard` artifact with the available suite JSON reports. +The dashboard is an artifact viewer, not a separate product website. -This keeps the predictor conservative: activity doesnโ€™t โ€œbuyโ€ readiness, it only prevents premature โ€œstalledโ€ judgments in active branches. +## Current Fixture Limitation -## Package Architecture +The repository workflow still uses +`scripts/prepare-shipme-cert-fixture.mjs` to create deterministic local evidence +fixtures when a bundle is missing or regeneration is requested. That script is a +test fixture generator for the assurance workflow. It is not a general +Wesley-generated database, validation, or product artifact pipeline. -Wesley and HOLMES are separate packages: - -``` -crates/wesley-core # Rust compiler authority -crates/wesley-cli # Native product CLI -@wesley/holmes # Sidecar intelligence package -``` +Target modules that need real domain artifacts should generate them in their +own repo or through an explicitly designed external target boundary. -## Commands +## Local Checks -### Wesley (Native Compiler) +Useful focused checks: ```bash -# Emit artifacts through the native CLI -wesley emit rust --schema schema.graphql --out generated/model.rs -wesley emit typescript --schema schema.graphql --out generated/types.ts - -# Run product checks -cargo xtask preflight +cargo wesley config validate --json +cargo wesley config changed-schemas \ + --changed test/fixtures/examples/ecommerce.graphql \ + --json +bats -t test/ci-workflows.bats +node --test packages/wesley-holmes/test/pr-comment.test.mjs ``` -### HOLMES (Sidecar Intelligence) +Run the full gate before opening a PR: ```bash -# Install separately -npm install -g @wesley/holmes - -# Run investigation -holmes investigate - -# Emit machine-readable JSON alongside markdown -holmes investigate --json holmes-report.json > holmes-report.md - -# WATSON verification -holmes verify --json watson-report.json > watson-report.md - -# MORIARTY predictions -holmes predict --json moriarty-report.json > moriarty-report.md - -# Combined report -holmes report --json holmes-suite.json > holmes-suite.md - -# All commands accept --json to persist structured output -``` - -The JSON documents contain the same information rendered in the markdown (investigation metadata, evidence tables, verification stats, velocity analysis, etc.) and are ideal for downstream automation. - -## CI/CD Integration - -```yaml -name: Wesley + SHA-lock HOLMES - -on: [push, pull_request] - -jobs: - wesley-generate: - steps: - - run: wesley generate --schema schema.graphql --emit-bundle - - holmes-investigate: - needs: wesley-generate - steps: - - uses: ./.github/actions/holmes-setup - - run: | - holmes investigate \ - --json holmes-report.json > holmes-report.md - - uses: actions/upload-artifact@v4 - with: - name: holmes-report - path: | - holmes-report.md - holmes-report.json - - watson-verify: - needs: holmes-investigate - steps: - - uses: ./.github/actions/holmes-setup - - run: | - holmes verify \ - --json watson-report.json > watson-report.md - - uses: actions/upload-artifact@v4 - with: - name: watson-report - path: | - watson-report.md - watson-report.json - - moriarty-predict: - needs: watson-verify - steps: - - uses: ./.github/actions/holmes-setup - - run: | - holmes predict \ - --json moriarty-report.json > moriarty-report.md - - uses: actions/upload-artifact@v4 - with: - name: moriarty-report - path: | - moriarty-report.md - moriarty-report.json - - comment-reports: - needs: [holmes-investigate, watson-verify, moriarty-predict] - steps: - - uses: actions/download-artifact@v4 - with: - merge-multiple: true - path: reports - - name: Post summary comment - run: node .github/scripts/holmes-comment.mjs # combines markdown sections -``` - -## Report Validation & Dashboard - -- **End-to-end integration test** โ€“ `test/holmes-e2e.bats` runs the full suite (`wesley generate --emit-bundle` โ†’ `holmes investigate/verify/predict`) and asserts that both Markdown and JSON artifacts exist with the expected keys (SCS/TCI/MRI, verdicts, velocity metrics). The test fails loudly if any file is missing, so HOLMES regressions surface immediately during local Bats runs or in the CLI workflows. -- **JSON schema validation** โ€“ `@wesley/holmes` ships lightweight runtime schemas (`packages/wesley-holmes/src/report-schemas.mjs`) with targeted node tests. The CLI validates each report against the schema before emitting JSON, which prevents malformed artifacts from leaking into CI. -- **Static dashboard artifact** โ€“ The HOLMES workflow now assembles a `holmes-dashboard` artifact containing `docs/holmes-dashboard/index.html` plus the suite JSON. Open the HTML locally (or host via GitHub Pages) to visualize recent SCS/TCI/MRI history, MORIARTY velocity/ETA, and verdict summaries without needing additional tooling. - -The GitHub comment highlights the markdown narratives and links directly to the JSON artifacts so other workflows (or local tooling) can consume structured results without scraping text. - -## Machine-Readable Reports - -- `holmes-report.json` โ€“ investigation summary, weighted evidence table, gate results, verdict metadata -- `watson-report.json` โ€“ citation verification counts, recalculated SCS, inconsistencies, opinion verdict -- `moriarty-report.json` โ€“ latest score snapshot, blended velocity, optional ETA windows, detected patterns, recent history - -These files live under the HOLMES workflow artifacts (flat files, no subdirectories) and mirror the markdown comment content. The combined `holmes report --json holmes-suite.json` command is convenient for local dashboards. - -## History Hydration & Caching - -- Each `wesley generate --emit-bundle` appends a point to `.wesley-cache/history.json` (day, timestamp, SCS/TCI/MRI, and evidence-trust metadata when citation quality is known). -- When MORIARTY runs in CI, the CLI merges local history, the merge-base snapshot (`git show :.wesley-cache/history.json`), and a GitHub Actions cache keyed by commit SHA (with branch/base fallbacks). This gives predictions continuity across branch reruns. - -## Customising Weighting - -HOLMES now loads weights from `wesley.weights.json`. Use the following structure (all numeric weights): - -```json -{ - "default": 5, - "substrings": { - "password": 12, - "ssn": 11 - }, - "directives": { - "sensitive": 10, - "critical": 9 - }, - "overrides": { - "col:User.email": 8, - "tbl:Orders.*": 7 - } -} -``` - -Precedence: **overrides โ†’ directives โ†’ substrings โ†’ default**. Keys in `overrides` can target exact UIDs (`col:User.email`) or wildcard suffixes (`tbl:Orders.*`). Directive keys omit the leading `@` (`"sensitive": 10`) and honour the same aliases Wesley already supports (e.g. `pk`, `primaryKey`, or `@primaryKey` all map to the same entry). - -Environment overrides still work when needed: - -| Variable | Purpose | -| --------------------------- | --------------------------------------- | -| `WESLEY_HOLMES_WEIGHTS` | JSON string override (highest priority) | -| `WESLEY_HOLMES_WEIGHT_FILE` | Path override for the config file | - -Run `holmes weights:validate [--file path]` to lint configuration files locally. The HOLMES report now states which source supplied the weights and the reason behind each elementโ€™s weight. - -## Security Gates - -Wesley + HOLMES enforces security automatically: - -### Sensitive Field Gate - -```graphql -password: String! @sensitive -``` - -โŒ **BLOCKS** if no hash constraint in SQL: - -```sql --- Required constraint -CHECK (char_length(password_hash) = 60) -- bcrypt -``` - -### PII Field Gate - -```graphql -email: String! @pii -``` - -โš ๏ธ **WARNS** if no RLS masking policy - -### RLS Coverage Gate - -```graphql -type Post @table @rls -``` - -โŒ **BLOCKS** if RLS enabled but policies missing for used operations - -## Example Investigation Output - -```markdown -## ๐Ÿ” SHA-lock HOLMES Investigation - -**Weighted Completion**: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘ 84% (156/185 weighted points) -**Verification Status**: 47/47 claims independently verified -**Ship Verdict**: ELEMENTARY - -| Feature | Weight | Source | Status | Evidence | Deduction | -| ------------- | ------ | -------------------------- | ------ | ----------------------- | ----------------------- | -| User.password | 12 | Override col:User.password | โœ… | `schema.sql:45@abc123d` | "Properly hashed!" | -| User.email | 8 | Substring email | โœ… | `schema.sql:42@abc123d` | "Unique as required" | -| Post.content | 5 | Default | โš ๏ธ | Missing Zod validation | "Minor oversight" | -| User.theme | 2 | Substring theme | โœ… | `types.ts:8@abc123d` | "Low priority complete" | - -## ๐Ÿ“Š Scores - -- **SCS**: 0.84 (84%) โœ… Threshold: 80% -- **MRI**: 0.23 (23%) โœ… Threshold: โ‰ค40% -- **TCI**: 0.71 (71%) โœ… Threshold: 70% - -## ๐Ÿ”ฎ Prediction - -Based on velocity of 3.2%/day: - -- **ETA**: 5 days (March 25, 2024) -- **Confidence**: 87% +cargo xtask preflight ``` - -## Benefits - -1. **Weighted Priorities**: Critical fields matter more than cosmetic ones -2. **SHA-Locked Evidence**: Every citation tied to commit SHA -3. **Independent Verification**: WATSON double-checks HOLMES -4. **Risk Assessment**: Know migration danger before production -5. **Predictive ETAs**: Data-driven completion estimates -6. **Automatic Security**: Sensitive fields enforced - -## The Revolution Continues - -Wesley generates the code. HOLMES proves it's correct. WATSON verifies independently. MORIARTY predicts readiness. - -**One schema. Complete intelligence.** diff --git a/docs/build-artifacts.md b/docs/build-artifacts.md index a2ffa69d..69a2bea9 100644 --- a/docs/build-artifacts.md +++ b/docs/build-artifacts.md @@ -2,15 +2,15 @@ Wesley generates several directories and files as part of its compile and validation workflows. These artifacts are ignored by Git so they can be safely regenerated, but it helps to know what they contain before cleaning them up. -| Artifact | Produced By | Purpose / Contents | Safe to Delete? | -| --------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| `.wesley-cache/` | `wesley generate`, `wesley cert-*`, HOLMES/Moriarty counterfactual analysis | Generated evidence bundle, score reports, SHIPME certificate, HOLMES inputs, ledger state, and counterfactual cache. Delete when you no longer need the latest local run state. | โœ… Generated each run. | -| `out/` | `wesley generate` | Generic artifacts emitted by loaded transmutations and modules. | โœ… Generated from the current schema. | -| `out/zod/` | Legacy compatibility `wesley zod` command or external target modules | JavaScript validation schemas while the legacy CLI remains; richer target output should move to an owning module. | โœ… Regenerated when commands run. | -| `test/fixtures/examples/out/` | `pnpm generate:example`, direct CLI runs using the bundled fixtures | Generated artifacts for the ecommerce demo schema (follows the same subdirectory layout). | โœ… Regenerated on next demo run. | -| `test/fixtures/examples/.wesley-cache/` | `pnpm generate:example`, demo rehearsals | Evidence bundle for example schema; mirrors root `.wesley-cache/`. | โœ… Regenerated with demo commands. | -| `coverage/` | `pnpm test:coverage` | Coverage reports from Jest/Vitest suites. | โœ… Pure test output. | -| `dist/` | Package-level build scripts (`pnpm -r build`) | Transpiled bundles for any package that emits compiled JS. | โœ… Rebuilt by the corresponding package build. | +| Artifact | Produced By | Purpose / Contents | Safe to Delete? | +| --------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| `.wesley-cache/` | Wesley evidence fixtures, HOLMES/Moriarty analysis, and external targets | Generated evidence bundle, score reports, SHIPME certificate, HOLMES inputs, ledger state, and counterfactual cache. Delete when you no longer need the latest local run state. | โœ… Generated each run. | +| `out/` | Native emit commands or external target modules | Generic artifacts emitted by explicit CLI commands such as `wesley emit rust` / `wesley emit typescript`, or by owning external targets. | โœ… Generated from the current schema. | +| `out/zod/` | External target modules | JavaScript validation schemas are no longer a core Wesley command; reintroduce them through an owning external target when needed. | โœ… Regenerated when commands run. | +| `test/fixtures/examples/out/` | `pnpm generate:example`, direct CLI runs using the bundled fixtures | Generated artifacts for the ecommerce demo schema (follows the same subdirectory layout). | โœ… Regenerated on next demo run. | +| `test/fixtures/examples/.wesley-cache/` | `pnpm generate:example`, demo rehearsals | Evidence bundle for example schema; mirrors root `.wesley-cache/`. | โœ… Regenerated with demo commands. | +| `coverage/` | `pnpm test:coverage` | Coverage reports from Jest/Vitest suites. | โœ… Pure test output. | +| `dist/` | Package-level build scripts (`pnpm -r build`) | Transpiled bundles for any package that emits compiled JS. | โœ… Rebuilt by the corresponding package build. | > โ„น๏ธ Additional temporary directories may appear under individual packages when running bespoke scripts. They follow the same patternโ€”anything listed in `.gitignore` is expected to be disposable unless you are auditing the output. diff --git a/docs/guides/extending.md b/docs/guides/extending.md index 7987fb58..ef21ef5a 100644 --- a/docs/guides/extending.md +++ b/docs/guides/extending.md @@ -104,49 +104,41 @@ External modules may bring: Wesley should provide the generic compiler facts those modules need. The module should decide what those facts mean for its domain. -## Legacy Node Module Notes +## Project Manifest And Descriptor Notes -The repository still contains historical Node package surfaces while the -Rust-native front door takes over. If you must touch the legacy module loader, -use the current module entry shape, not the obsolete top-level `generators` -shape. +Use the JSON/YAML project manifest for local schema and target metadata: -Use `wesley.config.mjs` like this: - -```mjs -export default { - modules: [ +```json +{ + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": ["schema.graphql"], + "targets": [ { - specifier: './my-wesley-module.mjs', - config: { - output: 'generated' - } + "name": "my-external-target", + "module": "my.external.module", + "default": true, + "outputDir": "generated/my-target" } ] -}; +} ``` -A module advertises capabilities: - -```mjs -export default { - name: 'my-domain-module', - capabilities: { - wesley: { - targets: [ - { - name: 'my-domain', - description: 'Emit my-domain artifacts from Wesley compiler facts' - } - ] - } - } -}; +Validate it with: + +```bash +wesley config validate --json ``` -Treat loaded modules as trusted code. Use `WESLEY_DISABLE_MODULES=1` for a -no-module diagnostic run, and use `WESLEY_MODULE_ALLOWLIST` in CI when only -approved module specifiers may load. +Fixture modules should be descriptor-only JSON under +`test/fixtures/extensions/`. They may advertise capability metadata for tests +and docs, but they must not execute code or make Wesley core own target +semantics. + +For the detailed current boundary, see +[Module Authoring Guide](./module-authoring.md) and +[Project Manifest](../reference/project-manifest.md). The historical +`wesley.config.mjs`, `WESLEY_MODULES`, `WESLEY_DISABLE_MODULES`, and +`WESLEY_MODULE_ALLOWLIST` path is retired from generic Wesley core. ## Validation Checklist diff --git a/docs/guides/generator-plugins.md b/docs/guides/generator-plugins.md index feefc98c..d376cd02 100644 --- a/docs/guides/generator-plugins.md +++ b/docs/guides/generator-plugins.md @@ -5,14 +5,21 @@ modules for domain targets. This page documents the retired Node generator plugin contract as migration context. The older package surfaces are gone; keep new generator work in Rust emitters or external modules. -For new work, start with [Extending Wesley](./extending.md). Add generic -compiler facts in Rust, and put domain targets in external modules or crates. +For new work, start with [Extending Wesley](./extending.md), +[Module Authoring Guide](./module-authoring.md), and +[Project Manifest](../reference/project-manifest.md). Add generic compiler +facts in Rust, declare local schema/target metadata in `wesley.config.json` or +YAML, and put domain targets in external modules or crates. --- -## Quick Start +## Historical Quick Start -The smallest possible generator plugin: +This section is historical. It describes the retired JavaScript generator +plugin runner so old docs and migration audits remain intelligible. Do not use +this interface for new Wesley core work. + +The smallest retired generator plugin looked like this: ```mjs // my-plugin.mjs @@ -44,7 +51,8 @@ export class HelloPlugin { export default HelloPlugin; ``` -Expose it from a Wesley module and register that module in `wesley.config.mjs`: +It was exposed from a Wesley module and registered in the retired +`wesley.config.mjs` loader: ```mjs // my-wesley-module.mjs diff --git a/docs/guides/module-authoring.md b/docs/guides/module-authoring.md new file mode 100644 index 00000000..09ea7c3b --- /dev/null +++ b/docs/guides/module-authoring.md @@ -0,0 +1,164 @@ +# Module Authoring Guide + + + +Wesley extensions start from a strict boundary: + +```text +GraphQL SDL -> deterministic Wesley IR -> external target assigns meaning +``` + +Wesley core owns GraphQL parsing, normalization, deterministic IR, schema +hashes, generic operation facts, and evidence plumbing. A module or sibling repo +owns target semantics, runtime behavior, generated product artifacts, policy, +and release conventions. + +## Current Loading Boundary + +The retired Node `wesley.config.mjs` module loader is not the active Wesley core +loading path. The Rust-native CLI does not execute arbitrary JavaScript modules. + +Current extension work should use one of these boundaries: + +| Need | Current Boundary | +| ------------------------------------- | ---------------------------------------------------------------------- | +| Generic compiler fact | Add a Rust API in `wesley-core`. | +| Generic Rust or TypeScript projection | Extend `wesley-emit-rust` or `wesley-emit-typescript`. | +| Domain-specific target | Put the module in an owning external crate, package, or sibling repo. | +| Local target selection metadata | Declare a target in `wesley.config.json` or YAML. | +| Hermetic examples | Add descriptor-only fixture modules under `test/fixtures/extensions/`. | + +Future executable target loading should go through an explicit Rust-native +registry, WASM capability boundary, or external-process protocol. Do not rebuild +the old dynamic JavaScript command-dispatch path in Wesley core. + +## Descriptor Shape + +A descriptor-only fixture module should be plain data: + +```json +{ + "apiVersion": "wesley.fixture-extension/v1", + "name": "example-compiler-target", + "description": "Hermetic fixture target for compiler-boundary tests.", + "schemas": ["../../consumer-models/example.graphql"], + "capabilities": { + "wesley": { + "targets": [ + { + "name": "example-target", + "executionMode": "external-process", + "runtimeModel": "stateless" + } + ] + } + }, + "boundary": { + "domainOwnership": "external-fixture", + "wesleyOwns": ["schema lowering", "target descriptor validation"], + "wesleyDoesNotOwn": ["runtime execution", "product behavior"] + } +} +``` + +Keep descriptors deterministic and inspectable: + +- no executable code in fixture descriptors +- no network access +- no ambient clock or filesystem mutation +- no product-specific semantics in Wesley core +- no hidden target defaults outside the manifest or descriptor + +## Project Manifest Target + +Local projects select target metadata in the project manifest: + +```json +{ + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": ["schema.graphql"], + "targets": [ + { + "name": "example-target", + "module": "example.external.module", + "default": true, + "outputDir": "generated/example" + } + ] +} +``` + +Validate the manifest before relying on it: + +```bash +wesley config validate --json +wesley config inspect --json +``` + +If the target is mutually exclusive with another selected target, use +`exclusiveGroup` or `conflictsWith`. See +[Project Manifest](../reference/project-manifest.md#target-compatibility). + +## Capability Registry Model + +The Rust capability registry models target descriptors and pre-execution policy. +It does not execute modules. + +Descriptor fields currently modeled in Rust include: + +| Field | Meaning | +| -------------------------- | ----------------------------------------------------------- | +| `module` | Module identity that contributed the target. | +| `target` | Stable target name. | +| `isDefault` | Whether the target is selected when no target is requested. | +| `executionMode` | `rust-native`, `wasm`, or `external-process`. | +| `portabilityFloor` | Minimum host promise for the target. | +| `requiredContract` | Capability ABI version range. | +| `runtimeModel` | `stateless` or `resource-handles`. | +| `requestedHostImports` | Explicit imports requested before execution. | +| `requestedResourceHandles` | Explicit host-created resource handles. | + +The host policy layer can reject unavailable imports, incompatible ABI ranges, +and resource-handle requests before execution. That is a safety boundary, not a +runtime. + +## Fixture Module Zoo + +Use the fixture zoo for local examples: + +- `test/fixtures/extensions/fixture-zoo/compiler-heavy` +- `test/fixtures/extensions/fixture-zoo/evidence-heavy` +- `test/fixtures/extensions/fixture-zoo/blade-heavy` + +The zoo is intentionally fake. It demonstrates capability mixes without +claiming that Wesley owns those domains. + +## Troubleshooting + +| Symptom | Likely Cause | Fix | +| --------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------- | +| `no Wesley manifest found` | No discovered `wesley.config.json`, YAML manifest, or `.wesley/config.json`. | Pass `--config` or add a manifest. | +| `manifest parse error` | Invalid JSON/YAML or unsupported YAML construct. | Use JSON or simple YAML with string keys and no aliases. | +| `unsupported manifest apiVersion` | The manifest targets another API version. | Use `wesley.project-manifest/v1`. | +| `duplicate target` | Two selected descriptors use the same target name. | Rename or remove one target. | +| `multiple default targets selected` | More than one target has `"default": true`. | Pick one default. | +| `exclusive group ... selects multiple targets` | Two selected targets declare the same `exclusiveGroup`. | Split into separate configs or select one target. | +| `target ... conflicts with selected target` | `conflictsWith` names another selected target. | Remove one side of the conflict. | +| Missing schema when running `schema hash` | Multi-schema manifest cannot infer one schema. | Pass `--schema` explicitly. | +| Target needs Postgres, Echo, Continuum, renderer, or runtime behavior | The work is outside Wesley core. | Move it to the owning repo or target module. | + +Environment variables from the retired JavaScript loader, including +`WESLEY_CONFIG`, `WESLEY_MODULES`, `WESLEY_DISABLE_MODULES`, and +`WESLEY_MODULE_ALLOWLIST`, are historical migration context for Wesley core. +They are not the Rust-native module loading surface. + +## Author Checklist + +- Keep the target descriptor domain-free in Wesley. +- Put target behavior in the owning external module or sibling repo. +- Add a manifest example only when it validates with `wesley config validate`. +- Add fixture descriptors under `test/fixtures/extensions/` when Wesley needs + hermetic regression coverage. +- Update [Project Manifest](../reference/project-manifest.md) if the manifest + schema changes. +- Run `cargo xtask preflight` before opening a PR. diff --git a/docs/guides/quick-start.md b/docs/guides/quick-start.md index 726e236f..58dd528a 100644 --- a/docs/guides/quick-start.md +++ b/docs/guides/quick-start.md @@ -44,6 +44,25 @@ Generated compiler artifacts go wherever `--out` points. Metadata sidecars record the schema hash, generator identity, generator version, and native execution mode. +## Optional project manifest + +Create `wesley.config.json` when a project wants repeatable schema discovery or +multi-schema rebuild selection: + +```json +{ + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": ["schema.graphql"], + "bundleDir": ".wesley-cache" +} +``` + +Validate it: + +```bash +cargo wesley config validate --json +``` + ## Diff a schema change Copy the file, make a change, and compare the two SDL states: @@ -56,6 +75,8 @@ cargo wesley schema diff --old schema.graphql --new schema.next.graphql --format ## Tips - Use `cargo wesley --help` for native compiler commands. +- Use [the project manifest reference](../reference/project-manifest.md) when + configuring schema sets, rebuild globs, bundles, or target metadata. - Use `cargo xtask docs-check` for documentation-only changes. - Use `cargo xtask legacy-preflight` only when changing legacy packages or pnpm workspace files. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6344be50..83b8f60f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -17,36 +17,39 @@ wesley [options] Options: -| Option | Meaning | -| --------------- | ------------ | -| `-h`, `--help` | Show help | +| Option | Meaning | +| ----------------- | ------------ | +| `-h`, `--help` | Show help | | `-V`, `--version` | Show version | Commands: -| Command | Purpose | -| ------------------------------- | -------------------------------------------------------------- | -| `normalize-sdl` | Print the Rust-core normalized SDL view | -| `doctor` | Run Rust-native health checks | -| `init-law` | Scaffold `weslaw/v1` from known SDL law directives | -| `schema lower` | Lower GraphQL SDL to Wesley L1 IR JSON | -| `schema hash` | Print the Wesley L1 registry hash for GraphQL SDL | -| `schema operations` | List Query/Mutation/Subscription root operations | -| `schema diff` | Compare GraphQL SDL states as Wesley L1 IR | -| `law validate` | Validate `weslaw` against active GraphQL SDL | -| `law lint` | Validate `weslaw` structure without schema binding | -| `law diff` | Compare `weslaw` semantic Law IR states | -| `law explain` | Explain active laws bound to one subject | -| `law rebind` | Re-anchor `weslaw` to an active schema hash | -| `law capabilities` | Emit report-only footprint capability summaries | -| `law coverage` | Report profile/category-aware law coverage | -| `emit rust` | Emit Rust models and operation bindings from GraphQL SDL | -| `emit typescript` | Emit TypeScript declarations and operation bindings from SDL | -| `emit le-binary-typescript` | Emit TypeScript LE-binary codecs from GraphQL SDL | -| `emit le-binary-rust` | Emit Rust LE-binary codecs from GraphQL SDL | -| `operation selections` | Resolve selected operation fields | -| `operation directive-args` | Extract operation directive arguments as JSON | -| `version` | Print the native CLI version | +| Command | Purpose | +| --------------------------- | ------------------------------------------------------------ | +| `normalize-sdl` | Print the Rust-core normalized SDL view | +| `doctor` | Run Rust-native health checks | +| `init-law` | Scaffold `weslaw/v1` from known SDL law directives | +| `config validate` | Validate a Wesley project manifest | +| `config inspect` | Print resolved manifest schema paths and targets | +| `config changed-schemas` | Select schema sets affected by changed files | +| `schema lower` | Lower GraphQL SDL to Wesley L1 IR JSON | +| `schema hash` | Print the Wesley L1 registry hash for GraphQL SDL | +| `schema operations` | List Query/Mutation/Subscription root operations | +| `schema diff` | Compare GraphQL SDL states as Wesley L1 IR | +| `law validate` | Validate `weslaw` against active GraphQL SDL | +| `law lint` | Validate `weslaw` structure without schema binding | +| `law diff` | Compare `weslaw` semantic Law IR states | +| `law explain` | Explain active laws bound to one subject | +| `law rebind` | Re-anchor `weslaw` to an active schema hash | +| `law capabilities` | Emit report-only footprint capability summaries | +| `law coverage` | Report profile/category-aware law coverage | +| `emit rust` | Emit Rust models and operation bindings from GraphQL SDL | +| `emit typescript` | Emit TypeScript declarations and operation bindings from SDL | +| `emit le-binary-typescript` | Emit TypeScript LE-binary codecs from GraphQL SDL | +| `emit le-binary-rust` | Emit Rust LE-binary codecs from GraphQL SDL | +| `operation selections` | Resolve selected operation fields | +| `operation directive-args` | Extract operation directive arguments as JSON | +| `version` | Print the native CLI version | ## Normalized SDL @@ -56,10 +59,10 @@ wesley normalize-sdl --schema [--hash] Options: -| Option | Meaning | -| --------------------- | ------------------------------------ | -| `-s`, `--schema ` | GraphQL SDL file | -| `--hash` | Print the SHA-256 of normalized SDL | +| Option | Meaning | +| ----------------------- | ----------------------------------- | +| `-s`, `--schema ` | GraphQL SDL file | +| `--hash` | Print the SHA-256 of normalized SDL | ## Doctor @@ -73,10 +76,10 @@ pnpm, config modules, or plugin packages. Options: -| Option | Meaning | -| ---------------------- | ---------------- | -| `--json` | Emit JSON output | -| `--format text|json` | Output format | +| Option | Meaning | +| -------------- | ---------------- | ------------- | +| `--json` | Emit JSON output | +| `--format text | json` | Output format | ## Init Law @@ -89,11 +92,36 @@ description-derived suggestions. Draft suggestions are not active law. Options: -| Option | Meaning | -| ----------------------- | -------------------------------------------- | -| `-s`, `--schema ` | GraphQL SDL file | -| `--family ` | Contract family id for the generated law | -| `--out ` | Optional output path; stdout when omitted | +| Option | Meaning | +| ----------------------- | ----------------------------------------- | +| `-s`, `--schema ` | GraphQL SDL file | +| `--family ` | Contract family id for the generated law | +| `--out ` | Optional output path; stdout when omitted | + +## Config + +```text +wesley config validate [--config ] [--json] +wesley config inspect [--config ] [--json] +wesley config changed-schemas [--config ] [--changed ...] [--changed-file ] [--json] +``` + +`config` commands operate on the domain-free Wesley project manifest. When +`--config` is omitted, the CLI walks upward from the current directory looking +for `wesley.config.json`, `wesley.config.yaml`, `wesley.config.yml`, or +`.wesley/config.json`. + +Options: + +| Option | Meaning | +| ----------------------- | ----------------------------------------------- | +| `--config ` | Manifest path; defaults to upward discovery | +| `--changed ` | Changed file path; may be passed more than once | +| `--changed-file ` | Newline-delimited changed file list | +| `--json` | Emit JSON output | + +The project manifest is documented in +[Project Manifest](./project-manifest.md). ## Schema @@ -107,14 +135,17 @@ wesley schema diff --schema --against [--format text|json|summary] Options: -| Option | Meaning | -| ----------------------- | -------------------------------------------- | -| `-s`, `--schema ` | GraphQL SDL file | -| `--old ` | Old/base GraphQL SDL file | -| `--new ` | New/target GraphQL SDL file | -| `--against ` | Git revision that provides old schema state | -| `--base ` | Alias for `--against` | -| `--json` | Emit JSON output | +| Option | Meaning | +| ----------------------- | ------------------------------------------- | +| `-s`, `--schema ` | GraphQL SDL file | +| `--old ` | Old/base GraphQL SDL file | +| `--new ` | New/target GraphQL SDL file | +| `--against ` | Git revision that provides old schema state | +| `--base ` | Alias for `--against` | +| `--json` | Emit JSON output | + +`schema lower`, `schema hash`, and `schema operations` may omit `--schema` only +when the discovered project manifest contains exactly one schema path. ## Law @@ -156,13 +187,13 @@ schema declares Query, Mutation, or Subscription fields. Options: -| Option | Meaning | -| ------------------------- | ----------------------------------------------------------- | -| `-s`, `--schema ` | GraphQL SDL file | -| `--law ` | Optional `weslaw/v1` file for bundle hashes | -| `--out ` | Output file | -| `--metadata-out ` | Deterministic metadata JSON sidecar | -| `--codec-import ` | Writer/Reader/CodecError module specifier for LE-binary | +| Option | Meaning | +| ----------------------- | ------------------------------------------------------- | +| `-s`, `--schema ` | GraphQL SDL file | +| `--law ` | Optional `weslaw/v1` file for bundle hashes | +| `--out ` | Output file | +| `--metadata-out ` | Deterministic metadata JSON sidecar | +| `--codec-import ` | Writer/Reader/CodecError module specifier for LE-binary | ## Operation @@ -173,11 +204,11 @@ wesley operation directive-args --operation --directive [--json] Options: -| Option | Meaning | -| ---------------------------- | ------------------------------------- | -| `-o`, `--operation ` | GraphQL operation file | -| `-s`, `--schema ` | Optional GraphQL schema SDL file | -| `-d`, `--directive ` | Directive name, without or with `@` | +| Option | Meaning | +| -------------------------- | ----------------------------------- | +| `-o`, `--operation ` | GraphQL operation file | +| `-s`, `--schema ` | Optional GraphQL schema SDL file | +| `-d`, `--directive ` | Directive name, without or with `@` | ## Version diff --git a/docs/reference/project-manifest.md b/docs/reference/project-manifest.md new file mode 100644 index 00000000..5f4e8de7 --- /dev/null +++ b/docs/reference/project-manifest.md @@ -0,0 +1,188 @@ +# Wesley Project Manifest + + + +The Wesley project manifest is a domain-free JSON or YAML file that names +GraphQL schema sets, output/evidence locations, changed-file rebuild rules, PR +comment behavior, dashboard artifacts, and generic target descriptors. + +It does not define database behavior, runtime law, product semantics, renderer +semantics, or external module execution. Wesley reads the manifest to decide +which GraphQL structure to lower and which generic target metadata is selected; +extensions and sibling repos decide what those targets mean. + +## File Names + +The native CLI discovers the first manifest it finds while walking upward from +the current directory: + +- `wesley.config.json` +- `wesley.config.yaml` +- `wesley.config.yml` +- `.wesley/config.json` + +The retired JavaScript `wesley.config.mjs` loader is not the active config +surface for Wesley core. Use JSON or YAML for current project manifests. + +## Commands + +```bash +wesley config validate --config wesley.config.json --json +wesley config inspect --config wesley.config.json --json +wesley config changed-schemas \ + --config wesley.config.json \ + --changed test/fixtures/examples/ecommerce.graphql \ + --json +``` + +`schema lower`, `schema hash`, and `schema operations` can omit `--schema` only +when the discovered manifest has exactly one schema path. Multi-schema +manifests require an explicit `--schema` for those commands. + +## Minimal Manifest + +```json +{ + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": ["schema.graphql"] +} +``` + +Defaults: + +| Field | Default | +| ------------- | ---------------------------- | +| `apiVersion` | `wesley.project-manifest/v1` | +| `bundleDir` | `.wesley-cache` | +| `commentMode` | `update` | +| `dashboard` | disabled | +| `targets` | empty | + +## Multi-Schema Manifest + +```json +{ + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": [ + { + "id": "core", + "path": "schemas/core/schema.graphql", + "rebuildOnGlobs": ["schemas/core/**"] + }, + { + "id": "audit", + "path": "schemas/audit/schema.graphql", + "rebuildOnGlobs": ["schemas/audit/**"] + } + ], + "bundleDir": ".wesley-cache", + "rebuildOnGlobs": ["wesley.config.json"], + "commentMode": "update", + "dashboard": { + "enabled": true, + "artifactPath": "docs/holmes-dashboard" + } +} +``` + +`wesley config changed-schemas` selects schema sets deterministically: + +- no changed files: every schema set is selected +- changed file equals a schema path: that schema set is selected +- changed file matches a schema's `rebuildOnGlobs`: that schema set is selected +- changed file matches top-level `rebuildOnGlobs`: every schema set is selected +- no match: no schema set is selected + +For multi-schema manifests, selected schemas receive isolated bundle +directories under `bundleDir`: + +```json +{ + "selectedSchemaPaths": [ + { + "id": "core", + "path": "schemas/core/schema.graphql", + "bundleDir": ".wesley-cache/core", + "reason": "matched schema rebuild glob `schemas/core/**`" + } + ] +} +``` + +Schema IDs must be path-safe because workflow artifacts and bundle directories +use them. IDs may contain only ASCII letters, digits, `.`, `_`, and `-`. + +## Target Compatibility + +Targets are selected metadata, not built-in domain behavior. + +```json +{ + "targets": [ + { + "name": "rust-models", + "module": "wesley.emit.rust", + "default": true, + "outputDir": "generated/rust" + }, + { + "name": "typescript-declarations", + "module": "wesley.emit.typescript", + "outputDir": "generated/typescript" + } + ] +} +``` + +Compatibility is expressed with generic fields: + +| Field | Meaning | +| ---------------- | ------------------------------------------------------------------ | +| `name` | Stable target name. Must be unique. | +| `module` | Optional module identity that owns target behavior. | +| `default` | Marks the single default target. More than one default is invalid. | +| `outputDir` | Optional target output directory. | +| `exclusiveGroup` | Targets in the same selected group are mutually exclusive. | +| `conflictsWith` | Explicit target names this target cannot be selected with. | + +Example invalid selection: + +```json +{ + "targets": [ + { "name": "alpha-models", "exclusiveGroup": "model-emitter" }, + { "name": "beta-models", "exclusiveGroup": "model-emitter" } + ] +} +``` + +The validator reports: + +```text +exclusive group 'model-emitter' selects multiple targets: alpha-models, beta-models +``` + +Wesley does not ship a Prisma, Drizzle, Postgres, Continuum, Echo, Vite, Vue, or +product-specific compatibility matrix. Those matrices belong to the owning +external target modules or sibling repos. + +## Validation Rules + +`wesley config validate` rejects: + +- unsupported `apiVersion` +- unknown top-level fields +- unknown fields inside detailed `schemaPaths` entries +- blank schema IDs or paths +- schema IDs that are not path-safe +- duplicate schema IDs +- duplicate schema paths +- blank `bundleDir` +- blank target names +- duplicate target names +- multiple default targets +- multiple selected targets in one `exclusiveGroup` +- `conflictsWith` references that point at another selected target + +The manifest schema is intentionally small so Wesley can remain a compiler +front door rather than a domain platform. diff --git a/docs/truth-manifest.json b/docs/truth-manifest.json index 54bd5ce0..d9274073 100644 --- a/docs/truth-manifest.json +++ b/docs/truth-manifest.json @@ -36,11 +36,21 @@ "status": "current", "owner": "@flyingrobots" }, + { + "path": "docs/reference/project-manifest.md", + "status": "current", + "owner": "@flyingrobots" + }, { "path": "docs/reference/directives.md", "status": "current", "owner": "@flyingrobots" }, + { + "path": "docs/guides/module-authoring.md", + "status": "current", + "owner": "@flyingrobots" + }, { "path": "docs/method/process.md", "status": "current", @@ -76,6 +86,11 @@ "status": "current", "owner": "@flyingrobots" }, + { + "path": "docs/architecture/holmes-integration.md", + "status": "current", + "owner": "@flyingrobots" + }, { "path": "docs/holmes-policy-spec.md", "status": "current", diff --git a/test/domain-empty-boundary.bats b/test/domain-empty-boundary.bats index fb8cbd17..b5f06638 100644 --- a/test/domain-empty-boundary.bats +++ b/test/domain-empty-boundary.bats @@ -102,3 +102,25 @@ load 'bats-plugins/bats-assert/load' run rg -n "@wesley/continuum|wesley-continuum|Continuum-specific defaults" docs/README.md docs/architecture/wesley-core-vs-toolchain.md assert_failure } + +@test "fixture module zoo remains descriptor-only and domain-empty" { + run test -f test/fixtures/extensions/fixture-zoo/compiler-heavy/fixture-extension.json + assert_success + + run test -f test/fixtures/extensions/fixture-zoo/evidence-heavy/fixture-extension.json + assert_success + + run test -f test/fixtures/extensions/fixture-zoo/blade-heavy/fixture-extension.json + assert_success + + run bash -lc "find test/fixtures/extensions/fixture-zoo -type f \\( -name '*.mjs' -o -name '*.js' \\) | wc -l" + assert_success + [ "$output" -eq 0 ] + + run bash -lc "grep -R '\"descriptorOnly\": true' test/fixtures/extensions/fixture-zoo | wc -l" + assert_success + [ "$output" -eq 3 ] + + run rg -n "Postgres|Supabase|Continuum|Echo|Vite|Vue|runtime execution.*true" test/fixtures/extensions/fixture-zoo + assert_failure +} diff --git a/test/fixtures/extensions/fixture-zoo/README.md b/test/fixtures/extensions/fixture-zoo/README.md new file mode 100644 index 00000000..3a10656f --- /dev/null +++ b/test/fixtures/extensions/fixture-zoo/README.md @@ -0,0 +1,14 @@ +# Fixture Module Zoo + +The fixture zoo contains descriptor-only extension examples for Wesley's +domain-empty module boundary. + +Each descriptor is intentionally fake and hermetic: + +- `compiler-heavy` demonstrates compiler and emitter capability metadata. +- `evidence-heavy` demonstrates evidence and judgment capability metadata. +- `blade-heavy` demonstrates BLADE-oriented scenario metadata. + +The descriptors are not executable modules. They exist so docs, tests, and +contributors can inspect realistic capability mixes without making any target +domain part of Wesley core. diff --git a/test/fixtures/extensions/fixture-zoo/blade-heavy/fixture-extension.json b/test/fixtures/extensions/fixture-zoo/blade-heavy/fixture-extension.json new file mode 100644 index 00000000..eaa14465 --- /dev/null +++ b/test/fixtures/extensions/fixture-zoo/blade-heavy/fixture-extension.json @@ -0,0 +1,28 @@ +{ + "apiVersion": "wesley.fixture-extension/v1", + "name": "fixture-zoo-blade-heavy", + "description": "Descriptor-only fixture module for BLADE scenario capability metadata.", + "schemas": ["../../../consumer-models/stack-witness-0001-file-history.graphql"], + "capabilities": { + "wesley": { + "targets": [ + { + "name": "fixture-blade-scenario", + "executionMode": "external-process", + "runtimeModel": "resource-handles" + } + ], + "scenarioHooks": ["fixture-key-material", "artifact-admission-check", "scenario-transcript"] + } + }, + "boundary": { + "domainOwnership": "external-fixture", + "descriptorOnly": true, + "wesleyOwns": [ + "scenario descriptor validation", + "artifact metadata shape", + "target descriptor validation" + ], + "wesleyDoesNotOwn": ["cryptographic key custody", "runtime admission", "operator policy"] + } +} diff --git a/test/fixtures/extensions/fixture-zoo/compiler-heavy/fixture-extension.json b/test/fixtures/extensions/fixture-zoo/compiler-heavy/fixture-extension.json new file mode 100644 index 00000000..805ad9e3 --- /dev/null +++ b/test/fixtures/extensions/fixture-zoo/compiler-heavy/fixture-extension.json @@ -0,0 +1,37 @@ +{ + "apiVersion": "wesley.fixture-extension/v1", + "name": "fixture-zoo-compiler-heavy", + "description": "Descriptor-only fixture module for compiler and emitter capability metadata.", + "schemas": ["../../../consumer-models/stack-witness-0001-file-history.graphql"], + "capabilities": { + "wesley": { + "targets": [ + { + "name": "fixture-l1-ir", + "executionMode": "rust-native", + "runtimeModel": "stateless" + }, + { + "name": "fixture-typescript-declarations", + "executionMode": "rust-native", + "runtimeModel": "stateless" + } + ], + "compilerFacts": [ + "schema-lowering", + "operation-selection-paths", + "directive-argument-extraction" + ] + } + }, + "boundary": { + "domainOwnership": "external-fixture", + "descriptorOnly": true, + "wesleyOwns": [ + "schema lowering", + "operation metadata extraction", + "target descriptor validation" + ], + "wesleyDoesNotOwn": ["runtime execution", "application behavior", "storage behavior"] + } +} diff --git a/test/fixtures/extensions/fixture-zoo/evidence-heavy/fixture-extension.json b/test/fixtures/extensions/fixture-zoo/evidence-heavy/fixture-extension.json new file mode 100644 index 00000000..0f9861aa --- /dev/null +++ b/test/fixtures/extensions/fixture-zoo/evidence-heavy/fixture-extension.json @@ -0,0 +1,32 @@ +{ + "apiVersion": "wesley.fixture-extension/v1", + "name": "fixture-zoo-evidence-heavy", + "description": "Descriptor-only fixture module for evidence and judgment capability metadata.", + "schemas": ["../../../consumer-models/stack-witness-0001-file-history.graphql"], + "capabilities": { + "wesley": { + "targets": [ + { + "name": "fixture-evidence-report", + "executionMode": "external-process", + "runtimeModel": "stateless" + } + ], + "evidenceHooks": ["artifact-citation-report", "coverage-summary", "judgment-summary"] + } + }, + "boundary": { + "domainOwnership": "external-fixture", + "descriptorOnly": true, + "wesleyOwns": [ + "evidence artifact wiring", + "report metadata shape", + "target descriptor validation" + ], + "wesleyDoesNotOwn": [ + "release judgment policy", + "runtime observation", + "product readiness criteria" + ] + } +} From c1b6ea7bbd493a8002ccb8daf54e4e757f2c3133 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 01:15:28 -0700 Subject: [PATCH 04/18] docs(topics): refresh triage lane audit --- docs/topics/contributing/triage.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/topics/contributing/triage.md b/docs/topics/contributing/triage.md index 7c80d2f9..664f85a7 100644 --- a/docs/topics/contributing/triage.md +++ b/docs/topics/contributing/triage.md @@ -23,12 +23,15 @@ Current triage labels: | `triage:bad-code` | Debt intake. | | `triage:cool-ideas` | Exploratory idea intake. | -Release lane labels are created only for named releases: +Release lane labels are created only for named releases. Current named release +labels are: ```text v0.1.1 v0.2.0 v0.3.0 +v0.4.0 +v0.5.0 ``` Do not create generic `lane:*` labels. In particular, do not use @@ -101,3 +104,12 @@ replace them as follows: Release checks query concrete `vX.Y.Z` labels. Retired generic lane labels are migration residue only; they are not release gates. + +## Related Authority + +- [`docs/governance/labels.md`](../../governance/labels.md) defines the + repository label taxonomy. +- [`docs/governance/RELEASE_POLICY.md`](../../governance/RELEASE_POLICY.md) + defines release gates and version-lane behavior. +- [`docs/governance/RELEASE_CHECKLIST.md`](../../governance/RELEASE_CHECKLIST.md) + defines the human release sign-off items. From d95de82ac639d1611041b72d08263cb5ed52242b Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 01:35:07 -0700 Subject: [PATCH 05/18] docs(topics): add operator workflow coverage --- CONTRIBUTING.md | 1 + docs/README.md | 2 + docs/topics/README.md | 31 +++++++++++++++ docs/topics/compiler-boundary.md | 48 +++++++++++++++++++++++ docs/topics/holmes-ci.md | 48 +++++++++++++++++++++++ docs/topics/project-manifests.md | 57 +++++++++++++++++++++++++++ docs/topics/releases.md | 66 ++++++++++++++++++++++++++++++++ docs/topics/validation.md | 46 ++++++++++++++++++++++ docs/truth-manifest.json | 30 +++++++++++++++ 9 files changed, 329 insertions(+) create mode 100644 docs/topics/README.md create mode 100644 docs/topics/compiler-boundary.md create mode 100644 docs/topics/holmes-ci.md create mode 100644 docs/topics/project-manifests.md create mode 100644 docs/topics/releases.md create mode 100644 docs/topics/validation.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2daa611e..65a877bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,7 @@ Read these surfaces in order: - [docs/VISION.md](docs/VISION.md) for a bounded executive synthesis - [docs/design/README.md](docs/design/README.md) for active design packets and boundary doctrine - [docs/METHOD.md](docs/METHOD.md) for the workflow contract +- [docs/topics/README.md](docs/topics/README.md) for contributor and operator task topics - [docs/topics/contributing/triage.md](docs/topics/contributing/triage.md) for issue triage and release-lane scheduling - [docs/governance/labels.md](docs/governance/labels.md) for issue and PR label semantics - [AGENTS.md](AGENTS.md) for repository-specific automation rules diff --git a/docs/README.md b/docs/README.md index 735afd9f..408cf28f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ which signpost is supposed to answer which question. | [CLI Reference](./reference/cli.md) | Current Rust-native `wesley` command reference. | | [Project Manifest](./reference/project-manifest.md) | Current JSON/YAML manifest schema for schemas, rebuild selection, bundles, and target metadata. | | [Directive Truth Table](./reference/directives.md) | Current directive support levels, aliases, external families, and fixture boundaries. | +| [Topics](./topics/README.md) | Operator and contributor task pages that bridge references, governance, and workflows. | | [VISION](./VISION.md) | Bounded executive synthesis grounded in repo-visible truth. | | [Design Packets](./design/README.md) | Active design packets and doctrinal boundary notes. | | [METHOD Process](./method/process.md) | How cycles run, close, and reconcile in this repo. | @@ -111,6 +112,7 @@ It also now has a more explicit METHOD closeout surface under - [METHOD Process](./method/process.md) - [METHOD Guide](./method/guide.md) - [Documentation Standard](./governance/DOCUMENTATION_STANDARD.md) +- [Topics](./topics/README.md) - [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18) - [GitHub Milestones](https://github.com/flyingrobots/wesley/milestones) - [Legacy Backlog Signpost](./method/backlog/README.md) (historical only) diff --git a/docs/topics/README.md b/docs/topics/README.md new file mode 100644 index 00000000..bcfda10f --- /dev/null +++ b/docs/topics/README.md @@ -0,0 +1,31 @@ +# Topics + + + +Topic pages answer operator and contributor questions that cut across reference, +architecture, METHOD, and release docs. They are not backlog trackers. + +Use these pages when you know the task you are trying to perform and need the +short path to the authoritative surface. + +## Topic Map + +| Task | Start Here | Authority | +| ------------------------------------------------------------ | ------------------------------------------- | ------------------------------------------------------------ | +| Decide whether a change belongs in Wesley or an extension. | [Compiler Boundary](./compiler-boundary.md) | `docs/design/0014-domain-empty-core-boundary/` | +| Configure schema sets, changed-schema selection, or targets. | [Project Manifests](./project-manifests.md) | `docs/reference/project-manifest.md` | +| Choose local checks before a PR or release. | [Validation](./validation.md) | `cargo xtask preflight`, `docs/governance/RELEASE_POLICY.md` | +| Understand HOLMES CI and evidence artifacts. | [HOLMES CI](./holmes-ci.md) | `.github/workflows/wesley-holmes.yml`, `docs/architecture/` | +| Prepare or judge a release. | [Releases](./releases.md) | `docs/method/release-runbook.md`, `docs/governance/` | +| Triage issues into release lanes. | [Issue Triage](./contributing/triage.md) | GitHub Issues, Milestones, Projects, and labels | + +## Coverage Rule + +When a release changes a contributor or operator workflow, `docs/topics/` must +either cover that workflow directly or link clearly to the current +authoritative page. Topic pages may summarize, but they must not duplicate live +roadmap state, issue counts, or release progress. + +The release gate for this directory is defined in +[`docs/governance/RELEASE_POLICY.md`](../governance/RELEASE_POLICY.md) and the +execution runbook in [`docs/method/release-runbook.md`](../method/release-runbook.md). diff --git a/docs/topics/compiler-boundary.md b/docs/topics/compiler-boundary.md new file mode 100644 index 00000000..558704bd --- /dev/null +++ b/docs/topics/compiler-boundary.md @@ -0,0 +1,48 @@ +# Compiler Boundary + + + +Wesley's core job is narrow: + +```text +GraphQL SDL -> deterministic Wesley IR -> external owners assign meaning. +``` + +Use this topic when deciding whether a proposed change belongs in Wesley core, +an emitter, an external target module, or a sibling repo. + +## Ownership Rule + +| Change Type | Home | +| -------------------------------------------------------- | ------------------------------------------------------------ | +| GraphQL parsing, normalization, L1 IR shape, hashes | `wesley-core` | +| Generic schema diff, operation facts, directive data | `wesley-core` and `wesley-cli` | +| Generic Rust or TypeScript projection behavior | `crates/wesley-emit-rust` or `crates/wesley-emit-typescript` | +| Project schema-set metadata and changed-schema selection | Wesley project manifest APIs and CLI | +| Target semantics, runtime policy, renderer behavior | External module or sibling repo | +| Postgres, migrations, Supabase, DDL, pgTAP | `wesley-postgres` or another database-owned repo | +| Echo, Continuum, Geordi, Edict, product-specific law | The owning product or protocol repo | + +Wesley may carry hermetic fixtures that prove compiler behavior. Those fixtures +must not make the fixture's domain a Wesley product responsibility. + +## Before Editing + +Ask these questions: + +1. Does the change alter how GraphQL structure is lowered, hashed, diffed, or + exposed as generic compiler facts? +2. Does it add target-specific meaning that a downstream module should own? +3. Does it require a product, database, renderer, runtime, scheduler, or + deployment assumption? +4. Can the behavior be tested with domain-empty or descriptor-only fixtures? + +If the answer to question 3 is yes, stop before adding it to Wesley core and +move the behavior to an owning extension or repo. + +## Related Authority + +- [`docs/design/0014-domain-empty-core-boundary/domain-empty-core-boundary.md`](../design/0014-domain-empty-core-boundary/domain-empty-core-boundary.md) +- [`docs/guides/module-authoring.md`](../guides/module-authoring.md) +- [`docs/reference/project-manifest.md`](../reference/project-manifest.md) +- [`docs/GUIDE.md`](../GUIDE.md) diff --git a/docs/topics/holmes-ci.md b/docs/topics/holmes-ci.md new file mode 100644 index 00000000..b26f6de3 --- /dev/null +++ b/docs/topics/holmes-ci.md @@ -0,0 +1,48 @@ +# HOLMES CI + + + +HOLMES CI is Wesley's evidence sidecar in pull requests. It consumes explicit +artifacts and writes review evidence; it does not decide product semantics or +reinterpret GraphQL structure. + +## Current Flow + +```text +wesley.config.json + -> wesley config changed-schemas + -> schema-scoped HOLMES matrix jobs + -> grouped reports + -> one aggregate PR comment +``` + +The workflow uses the project manifest to decide which schema sets to analyze. +If no changed files are available, every schema set is selected. If no schema +set matches the changed files, the HOLMES matrix is skipped rather than +inventing work. + +## Operator Notes + +- CodeRabbit and HOLMES are separate review surfaces. +- HOLMES report artifacts are grouped by schema set. +- The dashboard artifact is an artifact viewer, not a Wesley website product. +- Missing or invalid report artifacts should be reported as unavailable or + diagnostic evidence, not hidden behind a passing workflow. +- Target-specific facts belong in external modules that generate their own + evidence. + +## Local Checks + +```bash +cargo wesley config validate --json +cargo wesley config changed-schemas --json +BATS_LIB_PATH=test/vendor bats -t test/ci-workflows.bats +node --test packages/wesley-holmes/test/pr-comment.test.mjs +``` + +## Related Authority + +- [`docs/architecture/holmes-integration.md`](../architecture/holmes-integration.md) +- [`docs/architecture/holmes-architecture.md`](../architecture/holmes-architecture.md) +- [`docs/reference/project-manifest.md`](../reference/project-manifest.md) +- [`.github/workflows/wesley-holmes.yml`](../../.github/workflows/wesley-holmes.yml) diff --git a/docs/topics/project-manifests.md b/docs/topics/project-manifests.md new file mode 100644 index 00000000..d4e8f15d --- /dev/null +++ b/docs/topics/project-manifests.md @@ -0,0 +1,57 @@ +# Project Manifests + + + +Use a Wesley project manifest when a checkout needs named schema sets, +changed-schema selection, bundle isolation, dashboard metadata, or generic +target descriptors. + +The manifest is domain-free. It says which GraphQL structures and generic +targets are selected; it does not define what a database, renderer, runtime, or +product target means. + +## Common Tasks + +Validate the current manifest: + +```bash +wesley config validate --json +``` + +Inspect resolved schema paths and targets: + +```bash +wesley config inspect --json +``` + +Select schema sets affected by changed files: + +```bash +wesley config changed-schemas \ + --changed test/fixtures/reference/schema.graphql \ + --json +``` + +Read changed files from a newline-delimited file: + +```bash +wesley config changed-schemas --changed-file changed-files.txt --json +``` + +## Rules Of Thumb + +- Single-schema manifests can be discovered by `schema lower`, `schema hash`, + and `schema operations` when `--schema` is omitted. +- Multi-schema manifests require explicit `--schema` for direct schema + commands. +- Top-level rebuild globs select every schema set. +- Schema-local rebuild globs select only that schema set. +- Selected multi-schema bundles are isolated under `bundleDir/`. +- Target names are metadata. External modules own target behavior. + +## Related Authority + +- [`docs/reference/project-manifest.md`](../reference/project-manifest.md) +- [`docs/reference/cli.md`](../reference/cli.md#config) +- [`docs/topics/compiler-boundary.md`](./compiler-boundary.md) +- [`wesley.config.json`](../../wesley.config.json) diff --git a/docs/topics/releases.md b/docs/topics/releases.md new file mode 100644 index 00000000..eae5ae55 --- /dev/null +++ b/docs/topics/releases.md @@ -0,0 +1,66 @@ +# Releases + + + +Use this topic when preparing, reviewing, or judging a Wesley release. + +Wesley releases are cut from synced `main` only. A feature branch or PR can +prepare release facts, but the tag must point at the merged `main` commit. + +## Release Shape + +Release state is split intentionally: + +| Surface | Purpose | +| --------------------------------------------- | ---------------------------------------------- | +| GitHub label `vX.Y.Z` | Scheduled implementation and release-gate work | +| GitHub milestone `Release: vX.Y.Z` | Release-gate issue only | +| GitHub goalpost milestones | Implementation issues | +| `CHANGELOG.md` | Historical ledger of merged behavior | +| `docs/method/releases/vX.Y.Z/release.md` | Internal release design and scope | +| `docs/method/releases/vX.Y.Z/verification.md` | Release witness after validation and publish | +| `docs/releases/vX.Y.Z.md` | User-facing release notes | + +Implementation issues stay in goalpost milestones. Release-gate issues link to +the selected goalposts and release-lane queries. + +## Required Human Checks + +Before tagging, a reviewer must complete the human checklist in +[`docs/governance/RELEASE_CHECKLIST.md`](../governance/RELEASE_CHECKLIST.md). +That includes: + +- CHANGELOG accuracy against the actual diff +- architecture doc currency +- guide claim accuracy +- `docs/topics/` accuracy and coverage +- no known issue being silently shipped +- confirmation that synced `main` is the release boundary + +The `docs/topics/` gate means this directory must cover release-relevant +contributor and operator workflows or link clearly to the current authority. + +## Command Sequence + +The exact abort-fast sequence lives in +[`docs/method/release-runbook.md`](../method/release-runbook.md). The major +guards are: + +```bash +git status --porcelain +git fetch origin --tags +cargo xtask release-prep-guard --version X.Y.Z +cargo xtask preflight +cargo xtask release-check +cargo xtask release-guard --tag vX.Y.Z +``` + +Run `release-guard` only after the signed tag exists locally and points at the +synced `main` release commit. + +## Related Authority + +- [`docs/method/release.md`](../method/release.md) +- [`docs/method/release-runbook.md`](../method/release-runbook.md) +- [`docs/governance/RELEASE_POLICY.md`](../governance/RELEASE_POLICY.md) +- [`docs/governance/RELEASE_CHECKLIST.md`](../governance/RELEASE_CHECKLIST.md) diff --git a/docs/topics/validation.md b/docs/topics/validation.md new file mode 100644 index 00000000..f0865339 --- /dev/null +++ b/docs/topics/validation.md @@ -0,0 +1,46 @@ +# Validation + + + +Use this topic to choose the right local validation before a PR, release, or +focused fix. The full gate is the default when in doubt. + +## Default Gate + +Run strict preflight before opening or updating a substantial PR: + +```bash +cargo xtask preflight +``` + +The gate runs formatting, clippy, JavaScript advisory audit, docs checks, +workspace tests, and a native CLI smoke test. + +## Focused Checks + +| Change Area | Useful Checks | +| ------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Markdown docs only | `cargo xtask docs-check`, `git diff --check` | +| Release governance docs | `BATS_LIB_PATH=test/vendor bats -t test/release-governance.bats` | +| Project manifest parser or CLI | `cargo test -p wesley-core --test project_manifest`, `cargo test -p wesley-cli --test cli config` | +| CLI command surface | `cargo test -p wesley-cli --test cli`, `node scripts/check-doc-cli-commands.mjs` | +| GitHub workflows | `BATS_LIB_PATH=test/vendor bats -t test/ci-workflows.bats` | +| Domain-empty boundary | `BATS_LIB_PATH=test/vendor bats -t test/domain-empty-boundary.bats` | +| HOLMES PR comments or reports | `node --test packages/wesley-holmes/test/pr-comment.test.mjs` | +| JavaScript package or lockfile policy | `cargo xtask legacy-preflight`, `pnpm lint` | + +The pre-push hook may select relevant checks, but do not use the hook as the +only plan for a risky change. Choose checks deliberately and record the +important ones in the PR. + +## Release Validation + +Release validation is stricter than PR validation. Use +[`docs/topics/releases.md`](./releases.md) and +[`docs/method/release-runbook.md`](../method/release-runbook.md) before tagging. + +## Related Authority + +- [`docs/governance/RELEASE_POLICY.md`](../governance/RELEASE_POLICY.md) +- [`docs/governance/DOCUMENTATION_STANDARD.md`](../governance/DOCUMENTATION_STANDARD.md) +- [`docs/reference/cli.md`](../reference/cli.md) diff --git a/docs/truth-manifest.json b/docs/truth-manifest.json index d9274073..4a08dd75 100644 --- a/docs/truth-manifest.json +++ b/docs/truth-manifest.json @@ -121,6 +121,36 @@ "status": "current", "owner": "@flyingrobots" }, + { + "path": "docs/topics/README.md", + "status": "current", + "owner": "@flyingrobots" + }, + { + "path": "docs/topics/compiler-boundary.md", + "status": "current", + "owner": "@flyingrobots" + }, + { + "path": "docs/topics/project-manifests.md", + "status": "current", + "owner": "@flyingrobots" + }, + { + "path": "docs/topics/validation.md", + "status": "current", + "owner": "@flyingrobots" + }, + { + "path": "docs/topics/holmes-ci.md", + "status": "current", + "owner": "@flyingrobots" + }, + { + "path": "docs/topics/releases.md", + "status": "current", + "owner": "@flyingrobots" + }, { "path": "docs/topics/contributing/triage.md", "status": "current", From a12c552a37e79fc16d45cf6868de26bdd754dc85 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 01:42:51 -0700 Subject: [PATCH 06/18] Fix: fail invalid Holmes manifests before fallback --- .github/workflows/wesley-holmes.yml | 12 ++++++++++-- test/ci-workflows.bats | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wesley-holmes.yml b/.github/workflows/wesley-holmes.yml index 4ff885bc..b32ad898 100644 --- a/.github/workflows/wesley-holmes.yml +++ b/.github/workflows/wesley-holmes.yml @@ -58,7 +58,10 @@ jobs: git diff --name-only "$BEFORE_SHA" "$HEAD_SHA" > "$changed_file" || true fi - if cargo run --bin wesley -- config inspect --json > "$RUNNER_TEMP/wesley-manifest.json" 2> "$RUNNER_TEMP/wesley-manifest.err"; then + manifest_status=0 + cargo run --bin wesley -- config inspect --json > "$RUNNER_TEMP/wesley-manifest.json" 2> "$RUNNER_TEMP/wesley-manifest.err" || manifest_status=$? + + if [ "$manifest_status" -eq 0 ]; then cargo run --bin wesley -- config changed-schemas \ --changed-file "$changed_file" \ --json > "$RUNNER_TEMP/wesley-selected.json" @@ -73,7 +76,12 @@ jobs: process.stdout.write(JSON.stringify(sets)); NODE else - echo "Manifest discovery failed; falling back to legacy schema detection" >&2 + if ! grep -Fq "no Wesley manifest found" "$RUNNER_TEMP/wesley-manifest.err"; then + echo "Wesley manifest discovery failed; refusing legacy fallback" >&2 + cat "$RUNNER_TEMP/wesley-manifest.err" >&2 || true + exit "$manifest_status" + fi + echo "No Wesley manifest found; falling back to legacy schema detection" >&2 cat "$RUNNER_TEMP/wesley-manifest.err" >&2 || true if [ -n "${WESLEY_SCHEMA:-}" ] && [ -f "$WESLEY_SCHEMA" ]; then schema="$WESLEY_SCHEMA" diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index a345c1a9..efe04f06 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -196,6 +196,22 @@ load 'bats-plugins/bats-assert/load' assert_success [ "$output" -eq 1 ] + run bash -lc "grep -F 'manifest_status=' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -ge 1 ] + + run bash -lc "grep -F 'No Wesley manifest found' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -ge 1 ] + + run bash -lc "grep -F 'Wesley manifest discovery failed; refusing legacy fallback' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 1 ] + + run bash -lc "grep -F 'exit \"\$manifest_status\"' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 1 ] + run bash -lc "grep -F 'cargo run --bin wesley -- config changed-schemas' .github/workflows/wesley-holmes.yml | wc -l" assert_success [ "$output" -eq 1 ] From 93aee12af4f3ce139e7764688a10212aa9fc9bcb Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 01:44:44 -0700 Subject: [PATCH 07/18] Fix: isolate Holmes cache by schema set --- .github/actions/holmes-setup/action.yml | 14 +++++++++----- .github/workflows/wesley-holmes.yml | 4 ++++ test/ci-workflows.bats | 12 ++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/actions/holmes-setup/action.yml b/.github/actions/holmes-setup/action.yml index a6fd5a04..0d8b8b78 100644 --- a/.github/actions/holmes-setup/action.yml +++ b/.github/actions/holmes-setup/action.yml @@ -17,6 +17,10 @@ inputs: description: Name of the artifact containing the HOLMES bundle required: false default: wesley-bundle + cache-namespace: + description: Namespace used to keep Moriarty history caches isolated + required: false + default: default always-generate: description: Force regeneration even if artifacts are available required: false @@ -50,11 +54,11 @@ runs: uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 with: path: ${{ inputs.bundle-dir }}/history.json - key: moriarty-${{ github.sha }} + key: moriarty-${{ inputs.cache-namespace }}-${{ github.sha }} restore-keys: | - moriarty-${{ github.ref }}- - moriarty-${{ github.base_ref }}- - moriarty-main- + moriarty-${{ inputs.cache-namespace }}-${{ github.ref }}- + moriarty-${{ inputs.cache-namespace }}-${{ github.base_ref }}- + moriarty-${{ inputs.cache-namespace }}-main- - name: Download bundle artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 @@ -105,4 +109,4 @@ runs: uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 with: path: ${{ inputs.bundle-dir }}/history.json - key: moriarty-${{ github.sha }} + key: moriarty-${{ inputs.cache-namespace }}-${{ github.sha }} diff --git a/.github/workflows/wesley-holmes.yml b/.github/workflows/wesley-holmes.yml index b32ad898..dfada095 100644 --- a/.github/workflows/wesley-holmes.yml +++ b/.github/workflows/wesley-holmes.yml @@ -153,6 +153,7 @@ jobs: schema: ${{ matrix.schema_set.schema }} out-dir: ${{ env.HOLMES_OUT_DIR }} artifact-name: ${{ env.HOLMES_ARTIFACT }}-${{ matrix.schema_set.id }} + cache-namespace: ${{ matrix.schema_set.id }} always-generate: 'true' - name: '๐Ÿ“Š Display Scores' @@ -206,6 +207,7 @@ jobs: schema: ${{ matrix.schema_set.schema }} out-dir: ${{ env.HOLMES_OUT_DIR }} artifact-name: ${{ env.HOLMES_ARTIFACT }}-${{ matrix.schema_set.id }} + cache-namespace: ${{ matrix.schema_set.id }} - name: '๐Ÿ” Run Investigation' id: holmes @@ -257,6 +259,7 @@ jobs: schema: ${{ matrix.schema_set.schema }} out-dir: ${{ env.HOLMES_OUT_DIR }} artifact-name: ${{ env.HOLMES_ARTIFACT }}-${{ matrix.schema_set.id }} + cache-namespace: ${{ matrix.schema_set.id }} - name: '๐Ÿฉบ Run Verification' id: watson @@ -310,6 +313,7 @@ jobs: schema: ${{ matrix.schema_set.schema }} out-dir: ${{ env.HOLMES_OUT_DIR }} artifact-name: ${{ env.HOLMES_ARTIFACT }}-${{ matrix.schema_set.id }} + cache-namespace: ${{ matrix.schema_set.id }} always-generate: 'true' - name: '๐Ÿ”„ Ensure history for MORIARTY' diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index efe04f06..71a43c57 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -224,6 +224,18 @@ load 'bats-plugins/bats-assert/load' assert_success [ "$output" -eq 6 ] + run bash -lc "grep -F 'cache-namespace:' .github/actions/holmes-setup/action.yml | wc -l" + assert_success + [ "$output" -ge 1 ] + + run bash -lc "grep -F 'moriarty-\${{ inputs.cache-namespace }}-\${{ github.sha }}' .github/actions/holmes-setup/action.yml | wc -l" + assert_success + [ "$output" -eq 2 ] + + run bash -lc "grep -F 'cache-namespace: \${{ matrix.schema_set.id }}' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 4 ] + run bash -lc "grep -F 'steps.detect.outputs.selected_count' .github/workflows/wesley-holmes.yml | wc -l" assert_success [ "$output" -ge 1 ] From f6a59f986728a539e90a37fe96cca059ea4c1ec0 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 01:46:31 -0700 Subject: [PATCH 08/18] Fix: honor silent Holmes comment mode --- .github/workflows/wesley-holmes.yml | 11 ++++++++++- test/ci-workflows.bats | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/wesley-holmes.yml b/.github/workflows/wesley-holmes.yml index dfada095..3ea889c5 100644 --- a/.github/workflows/wesley-holmes.yml +++ b/.github/workflows/wesley-holmes.yml @@ -22,6 +22,7 @@ jobs: outputs: schema_sets: ${{ steps.detect.outputs.schema_sets }} selected_count: ${{ steps.detect.outputs.selected_count }} + comment_mode: ${{ steps.detect.outputs.comment_mode }} steps: - name: Harden runner @@ -65,6 +66,12 @@ jobs: cargo run --bin wesley -- config changed-schemas \ --changed-file "$changed_file" \ --json > "$RUNNER_TEMP/wesley-selected.json" + comment_mode="$(node <<'NODE' + const fs = require('fs'); + const report = JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP + '/wesley-manifest.json', 'utf8')); + process.stdout.write(report.manifest.commentMode || 'update'); + NODE + )" node <<'NODE' > "$RUNNER_TEMP/wesley-schema-sets.json" const fs = require('fs'); const report = JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP + '/wesley-selected.json', 'utf8')); @@ -100,6 +107,7 @@ jobs: fi base_dir="$(dirname "$schema")" bundle_dir="$base_dir/.wesley-cache" + comment_mode="update" SCHEMA="$schema" BUNDLE_DIR="$bundle_dir" \ node -e 'process.stdout.write(JSON.stringify([{id:"default",schema:process.env.SCHEMA,bundle_dir:process.env.BUNDLE_DIR}]))' \ > "$RUNNER_TEMP/wesley-schema-sets.json" @@ -114,6 +122,7 @@ jobs: echo echo 'JSON' echo "selected_count=$selected_count" + echo "comment_mode=$comment_mode" } >> "$GITHUB_OUTPUT" - name: '๐Ÿ“ค Upload Dashboard Template' @@ -460,7 +469,7 @@ jobs: comment-report: name: '๐Ÿ“ Post Investigation Report' runs-on: ubuntu-latest - if: github.event_name == 'pull_request' && needs.detect-schema-sets.outputs.selected_count != '0' + if: github.event_name == 'pull_request' && needs.detect-schema-sets.outputs.selected_count != '0' && needs.detect-schema-sets.outputs.comment_mode != 'silent' needs: [detect-schema-sets, holmes-investigate, watson-verify, moriarty-predict] env: HOLMES_STATUS: ${{ needs.holmes-investigate.result }} diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index 71a43c57..46e5ee22 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -513,6 +513,18 @@ load 'bats-plugins/bats-assert/load' assert_success [ "$output" -eq 1 ] + run bash -lc "grep -F 'comment_mode: \${{ steps.detect.outputs.comment_mode }}' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 1 ] + + run bash -lc "grep -F 'commentMode ||' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 1 ] + + run bash -lc "grep -F \"comment_mode != 'silent'\" .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 1 ] + run bash -lc "grep -F 'matrix.schema_set.schema' .github/workflows/wesley-holmes.yml | wc -l" assert_success [ "$output" -ge 4 ] From 3aac1068157d4d1c990bb076ff198f682ec4db46 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 01:48:31 -0700 Subject: [PATCH 09/18] Fix: scope Holmes report artifacts by schema --- .github/workflows/wesley-holmes.yml | 6 +++--- test/ci-workflows.bats | 12 ++++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wesley-holmes.yml b/.github/workflows/wesley-holmes.yml index 3ea889c5..233ecf40 100644 --- a/.github/workflows/wesley-holmes.yml +++ b/.github/workflows/wesley-holmes.yml @@ -238,7 +238,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: holmes-report-${{ matrix.schema_set.id }} - path: reports-by-schema + path: reports-by-schema/${{ matrix.schema_set.id }} if-no-files-found: error watson-verify: @@ -290,7 +290,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: watson-report-${{ matrix.schema_set.id }} - path: reports-by-schema + path: reports-by-schema/${{ matrix.schema_set.id }} if-no-files-found: error moriarty-predict: @@ -463,7 +463,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: moriarty-report-${{ matrix.schema_set.id }} - path: reports-by-schema + path: reports-by-schema/${{ matrix.schema_set.id }} if-no-files-found: error comment-report: diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index 46e5ee22..63ccd7d7 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -220,9 +220,17 @@ load 'bats-plugins/bats-assert/load' assert_success [ "$output" -eq 4 ] - run bash -lc "grep -F 'reports-by-schema/\${{ matrix.schema_set.id }}' .github/workflows/wesley-holmes.yml | wc -l" + run bash -lc "grep -F 'mkdir -p \"reports-by-schema/\${{ matrix.schema_set.id }}\"' .github/workflows/wesley-holmes.yml | wc -l" assert_success - [ "$output" -eq 6 ] + [ "$output" -eq 3 ] + + run bash -lc "grep -F 'path: reports-by-schema/\${{ matrix.schema_set.id }}' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 3 ] + + run bash -lc "grep -E '^[[:space:]]+path: reports-by-schema$' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 0 ] run bash -lc "grep -F 'cache-namespace:' .github/actions/holmes-setup/action.yml | wc -l" assert_success From 12cd9bdca0423f3f6351e8260e957ed77868434c Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 01:51:24 -0700 Subject: [PATCH 10/18] Fix: preserve missing Holmes schema reports --- .github/workflows/wesley-holmes.yml | 2 + packages/wesley-holmes/src/pr-comment-cli.mjs | 38 ++++++++++- packages/wesley-holmes/src/pr-comment.mjs | 23 ++++++- .../wesley-holmes/test/pr-comment.test.mjs | 64 +++++++++++++++++++ test/ci-workflows.bats | 8 +++ 5 files changed, 132 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wesley-holmes.yml b/.github/workflows/wesley-holmes.yml index 233ecf40..6441ba82 100644 --- a/.github/workflows/wesley-holmes.yml +++ b/.github/workflows/wesley-holmes.yml @@ -538,10 +538,12 @@ jobs: - name: '๐Ÿงพ Build PR Comment' env: PR_NUMBER: ${{ github.event.pull_request.number }} + SCHEMA_SETS_JSON: ${{ needs.detect-schema-sets.outputs.schema_sets }} run: | set -euo pipefail node packages/wesley-holmes/src/pr-comment-cli.mjs \ --reports-dir reports \ + --schema-sets-json "$SCHEMA_SETS_JSON" \ --pr-number "$PR_NUMBER" \ --head-sha "$GITHUB_SHA" \ --holmes-status "$HOLMES_STATUS" \ diff --git a/packages/wesley-holmes/src/pr-comment-cli.mjs b/packages/wesley-holmes/src/pr-comment-cli.mjs index 17f10a23..68738d0a 100644 --- a/packages/wesley-holmes/src/pr-comment-cli.mjs +++ b/packages/wesley-holmes/src/pr-comment-cli.mjs @@ -20,7 +20,9 @@ function main(argv = process.argv.slice(2)) { watson: options.watsonStatus, moriarty: options.moriartyStatus }; - const reportSets = loadHolmesSuiteReportSets(options.reportsDir, statuses); + const reportSets = loadHolmesSuiteReportSets(options.reportsDir, statuses, { + schemaSetIds: options.schemaSetIds + }); const reports = loadHolmesSuiteReports(options.reportsDir, statuses); const body = @@ -49,7 +51,8 @@ function parseArgs(argv) { headSha: '', holmesStatus: 'unknown', watsonStatus: 'unknown', - moriartyStatus: 'unknown' + moriartyStatus: 'unknown', + schemaSetIds: [] }; for (let index = 0; index < argv.length; index += 1) { @@ -87,6 +90,9 @@ function parseArgs(argv) { case 'moriarty-status': options.moriartyStatus = value; break; + case 'schema-sets-json': + options.schemaSetIds = parseSchemaSetIds(value); + break; default: fail(`Unknown option: --${name}`); } @@ -107,6 +113,34 @@ function parseArgs(argv) { return options; } +function parseSchemaSetIds(value) { + let parsed; + try { + parsed = JSON.parse(value); + } catch (error) { + fail( + `Invalid --schema-sets-json value: ${error instanceof Error ? error.message : String(error)}` + ); + } + if (!Array.isArray(parsed)) { + fail('Invalid --schema-sets-json value: expected a JSON array'); + } + + const ids = []; + const seen = new Set(); + for (const entry of parsed) { + const id = typeof entry === 'string' ? entry : entry?.id; + if (typeof id !== 'string' || !id.trim()) { + fail('Invalid --schema-sets-json value: every entry must have a non-empty id'); + } + const normalized = id.trim(); + if (seen.has(normalized)) continue; + seen.add(normalized); + ids.push(normalized); + } + return ids; +} + function fail(message) { process.stderr.write(`${message}\n`); process.exit(1); diff --git a/packages/wesley-holmes/src/pr-comment.mjs b/packages/wesley-holmes/src/pr-comment.mjs index 27b4e5e4..d43c4b5f 100644 --- a/packages/wesley-holmes/src/pr-comment.mjs +++ b/packages/wesley-holmes/src/pr-comment.mjs @@ -224,8 +224,17 @@ export function loadHolmesSuiteReports(reportsDir, statuses = {}) { }; } -export function loadHolmesSuiteReportSets(reportsDir, statuses = {}) { +export function loadHolmesSuiteReportSets(reportsDir, statuses = {}, options = {}) { const schemaDirs = findSchemaReportDirs(reportsDir); + const expectedSchemaIds = normalizeSchemaSetIds(options.schemaSetIds || []); + if (expectedSchemaIds.length > 0) { + const dirsByName = new Map(schemaDirs.map((entry) => [entry.name, entry.path])); + return expectedSchemaIds.map((id) => ({ + id, + ...loadHolmesSuiteReports(dirsByName.get(id) || path.join(reportsDir, id), statuses) + })); + } + if (schemaDirs.length === 0) { return [ { @@ -241,6 +250,18 @@ export function loadHolmesSuiteReportSets(reportsDir, statuses = {}) { })); } +function normalizeSchemaSetIds(values) { + const ids = []; + const seen = new Set(); + for (const value of values) { + const id = normalizeOptionalString(value); + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids; +} + function findSchemaReportDirs(reportsDir) { if (!existsSync(reportsDir)) return []; return readdirSync(reportsDir, { withFileTypes: true }) diff --git a/packages/wesley-holmes/test/pr-comment.test.mjs b/packages/wesley-holmes/test/pr-comment.test.mjs index 53b1ee1b..d622aaa0 100644 --- a/packages/wesley-holmes/test/pr-comment.test.mjs +++ b/packages/wesley-holmes/test/pr-comment.test.mjs @@ -419,6 +419,70 @@ test('pr-comment CLI aggregates schema-scoped HOLMES report directories', () => } }); +test('pr-comment CLI preserves selected schema sets that failed before uploading reports', () => { + const reportsDir = mkdtempSync(path.join(os.tmpdir(), 'holmes-pr-comment-missing-schema-set-')); + try { + const schemaId = 'ecommerce'; + for (const reportName of ['holmes', 'watson', 'moriarty']) { + mkdirSync(path.join(reportsDir, schemaId, reportName), { recursive: true }); + } + writeFileSync( + path.join(reportsDir, schemaId, 'holmes', 'holmes-report.json'), + JSON.stringify(sampleHolmesReport()) + ); + writeFileSync( + path.join(reportsDir, schemaId, 'watson', 'watson-report.json'), + JSON.stringify(sampleWatsonReport()) + ); + writeFileSync( + path.join(reportsDir, schemaId, 'moriarty', 'moriarty-report.json'), + JSON.stringify(sampleMoriartyReport()) + ); + writeFileSync(path.join(reportsDir, schemaId, 'holmes', 'holmes-report.md'), '### holmes ok\n'); + writeFileSync(path.join(reportsDir, schemaId, 'watson', 'watson-report.md'), '### watson ok\n'); + writeFileSync( + path.join(reportsDir, schemaId, 'moriarty', 'moriarty-report.md'), + '### moriarty ok\n' + ); + + const result = spawnSync( + process.execPath, + [ + prCommentCliPath, + '--reports-dir', + reportsDir, + '--schema-sets-json', + JSON.stringify([{ id: 'ecommerce' }, { id: 'reference' }]), + '--pr-number', + '467', + '--head-sha', + 'feedfacedeadbeef', + '--holmes-status', + 'success', + '--watson-status', + 'success', + '--moriarty-status', + 'success' + ], + { + encoding: 'utf8' + } + ); + + assert.equal(result.status, 0, result.stderr); + assert.ok(result.stdout.includes('## Schema Set `ecommerce`')); + assert.ok(result.stdout.includes('## Schema Set `reference`')); + assert.ok(result.stdout.includes('### holmes ok')); + assert.ok( + result.stdout.includes( + 'The Holmes report is unavailable because the workflow finished without a readable holmes-report.json artifact.' + ) + ); + } finally { + rmSync(reportsDir, { recursive: true, force: true }); + } +}); + test('pr-comment CLI can be imported without executing the entrypoint', () => { const result = spawnSync( process.execPath, diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index 63ccd7d7..2663024c 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -563,6 +563,14 @@ load 'bats-plugins/bats-assert/load' assert_success [ "$output" -eq 1 ] + run bash -lc "grep -F 'SCHEMA_SETS_JSON: \${{ needs.detect-schema-sets.outputs.schema_sets }}' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 1 ] + + run bash -lc "grep -F -- '--schema-sets-json \"\$SCHEMA_SETS_JSON\"' .github/workflows/wesley-holmes.yml | wc -l" + assert_success + [ "$output" -eq 1 ] + run bash -lc "grep -F 'reports/pr-comment.md' .github/workflows/wesley-holmes.yml | wc -l" assert_success [ "$output" -ge 2 ] From 5bf1ac08f5e53090c6bba5e577c5f362a0fd0c22 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 01:55:31 -0700 Subject: [PATCH 11/18] Fix: resolve changed schemas relative to manifest --- crates/wesley-cli/src/main.rs | 64 +++++++++++++++++++++++++++++++--- crates/wesley-cli/tests/cli.rs | 62 ++++++++++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 6 deletions(-) diff --git a/crates/wesley-cli/src/main.rs b/crates/wesley-cli/src/main.rs index e9cfc54c..7d047379 100644 --- a/crates/wesley-cli/src/main.rs +++ b/crates/wesley-cli/src/main.rs @@ -12,9 +12,10 @@ use wesley_core::{ load_weslaw_yaml, lower_schema_sdl, lower_wes_channel_directives_to_law_ir_v1, normalize_schema_sdl, record_law_binding_error_v1, resolve_operation_selections, resolve_operation_selections_with_schema, select_changed_schema_paths, - ContractBundleManifestV1, FootprintLawV1, LawDiffReportV1, LawEntryBodyV1, LawIrV1, - OperationType, ProjectManifest, ProjectManifestError, ResolvedSchemaPath, ScalarSemanticsLawV1, - SchemaDelta, SelectedSchemaPath, TypeKind, WeslawError, WesleyError, WesleyIR, + ContractBundleManifestV1, DetailedSchemaPathConfig, FootprintLawV1, LawDiffReportV1, + LawEntryBodyV1, LawIrV1, OperationType, ProjectManifest, ProjectManifestError, + ResolvedSchemaPath, ScalarSemanticsLawV1, SchemaDelta, SchemaPathConfig, SelectedSchemaPath, + TypeKind, WeslawError, WesleyError, WesleyIR, }; use wesley_emit_rust::{ emit_le_binary_rust, emit_rust_with_operations, emit_rust_with_operations_and_law, @@ -458,7 +459,10 @@ fn run_config_command(args: &[String]) -> Result { let options = parse_options(&args[1..], "config changed-schemas")?; let (manifest_path, manifest) = load_manifest_from_options(&options)?; let changed_files = changed_files_from_options(&options)?; - let selected_schema_paths = select_changed_schema_paths(&manifest, &changed_files); + let selection_manifest = + manifest_with_paths_relative_to_cwd(&manifest_path, &manifest)?; + let selected_schema_paths = + select_changed_schema_paths(&selection_manifest, &changed_files); let report = ConfigChangedSchemasReport { manifest_path: manifest_path.display().to_string(), changed_files, @@ -1320,6 +1324,58 @@ fn resolve_manifest_relative_path(manifest_path: &Path, path: &str) -> PathBuf { } } +fn manifest_with_paths_relative_to_cwd( + manifest_path: &Path, + manifest: &ProjectManifest, +) -> Result { + let mut projected = manifest.clone(); + projected.bundle_dir = manifest_path_relative_string(manifest_path, &manifest.bundle_dir)?; + projected.rebuild_on_globs = manifest + .rebuild_on_globs + .iter() + .map(|glob| manifest_path_relative_string(manifest_path, glob)) + .collect::, _>>()?; + projected.schema_paths = manifest + .schema_paths + .iter() + .map(|schema| match schema { + SchemaPathConfig::Path(path) => { + manifest_path_relative_string(manifest_path, path).map(SchemaPathConfig::Path) + } + SchemaPathConfig::Detailed(config) => { + Ok(SchemaPathConfig::Detailed(DetailedSchemaPathConfig { + id: config.id.clone(), + path: manifest_path_relative_string(manifest_path, &config.path)?, + rebuild_on_globs: config + .rebuild_on_globs + .iter() + .map(|glob| manifest_path_relative_string(manifest_path, glob)) + .collect::, _>>()?, + })) + } + }) + .collect::, _>>()?; + Ok(projected) +} + +fn manifest_path_relative_string(manifest_path: &Path, path: &str) -> Result { + let resolved = resolve_manifest_relative_path(manifest_path, path); + let path = cwd_relative_path(&resolved)?; + Ok(path.to_string_lossy().replace('\\', "/")) +} + +fn cwd_relative_path(path: &Path) -> Result { + if path.is_absolute() { + let cwd = env::current_dir().map_err(|source| { + CliError::Git(format!("failed to read current directory: {source}")) + })?; + if let Ok(relative_path) = path.strip_prefix(cwd) { + return Ok(relative_path.to_path_buf()); + } + } + Ok(path.to_path_buf()) +} + fn changed_files_from_options(options: &ParsedOptions) -> Result, CliError> { let mut changed = options.changed.clone(); if let Some(path) = &options.changed_file { diff --git a/crates/wesley-cli/tests/cli.rs b/crates/wesley-cli/tests/cli.rs index b1e213d2..5a359f2d 100644 --- a/crates/wesley-cli/tests/cli.rs +++ b/crates/wesley-cli/tests/cli.rs @@ -77,8 +77,9 @@ fn config_validate_and_changed_schemas_emit_domain_free_manifest_reports() { .expect("config should write"); let output = wesley() + .current_dir(&dir) .args(["config", "validate", "--config"]) - .arg(&config) + .arg("wesley.config.json") .arg("--json") .output() .expect("wesley should run"); @@ -92,8 +93,9 @@ fn config_validate_and_changed_schemas_emit_domain_free_manifest_reports() { ); let output = wesley() + .current_dir(&dir) .args(["config", "changed-schemas", "--config"]) - .arg(&config) + .arg("wesley.config.json") .args(["--changed", "schemas/core/types.graphql", "--json"]) .output() .expect("wesley should run"); @@ -112,6 +114,62 @@ fn config_validate_and_changed_schemas_emit_domain_free_manifest_reports() { .contains("schemas/core/**")); } +#[test] +fn config_changed_schemas_resolves_manifest_relative_paths_before_selection() { + let dir = temp_dir("config-manifest-relative"); + let project_dir = dir.join("project"); + std::fs::create_dir_all(project_dir.join("schema")).expect("schema dir should create"); + std::fs::write( + project_dir.join("schema/schema.graphql"), + "type Query { nested: String }\n", + ) + .expect("schema should write"); + let config = project_dir.join("wesley.config.json"); + std::fs::write( + &config, + r#" + { + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": [ + { + "id": "nested", + "path": "schema/schema.graphql", + "rebuildOnGlobs": ["schema/**"] + } + ], + "bundleDir": ".cache" + } + "#, + ) + .expect("config should write"); + + let output = wesley() + .current_dir(&dir) + .args(["config", "changed-schemas", "--config"]) + .arg("project/wesley.config.json") + .args(["--changed", "project/schema/types.graphql", "--json"]) + .output() + .expect("wesley should run"); + + assert_success(&output); + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + let report: serde_json::Value = serde_json::from_str(&stdout).expect("stdout should be json"); + assert_eq!(report["selectedSchemaPaths"].as_array().unwrap().len(), 1); + assert_eq!(report["selectedSchemaPaths"][0]["id"], "nested"); + assert_eq!( + report["selectedSchemaPaths"][0]["path"], + "project/schema/schema.graphql" + ); + assert_eq!( + report["selectedSchemaPaths"][0]["bundleDir"], + "project/.cache" + ); + assert!(report["selectedSchemaPaths"][0]["reason"] + .as_str() + .unwrap() + .contains("project/schema/**")); +} + #[test] fn schema_commands_discover_single_schema_manifest_when_schema_flag_is_omitted() { let dir = temp_dir("config-discovery"); From 51856e1dceaadf99a24bba9b7c24f197926a984c Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 01:56:54 -0700 Subject: [PATCH 12/18] Fix: reject dot-only manifest schema ids --- .../src/domain/project_manifest.rs | 11 ++++++++++- crates/wesley-core/tests/project_manifest.rs | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/wesley-core/src/domain/project_manifest.rs b/crates/wesley-core/src/domain/project_manifest.rs index b50f7a0b..e26ccad8 100644 --- a/crates/wesley-core/src/domain/project_manifest.rs +++ b/crates/wesley-core/src/domain/project_manifest.rs @@ -237,7 +237,12 @@ pub fn validate_project_manifest(manifest: &ProjectManifest) -> Result<(), Proje if schema.id.trim().is_empty() { diagnostics.push("schemaPaths contains a blank id".to_owned()); } - if !schema_id_is_path_safe(&schema.id) { + if schema_id_is_dot_only(&schema.id) { + diagnostics.push(format!( + "schemaPath id '{}' must not be '.', '..', or only dots", + schema.id + )); + } else if !schema_id_is_path_safe(&schema.id) { diagnostics.push(format!( "schemaPath id '{}' must contain only ASCII letters, digits, '.', '_', or '-'", schema.id @@ -405,6 +410,10 @@ fn schema_id_is_path_safe(id: &str) -> bool { .all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')) } +fn schema_id_is_dot_only(id: &str) -> bool { + !id.is_empty() && id.chars().all(|character| character == '.') +} + fn default_api_version() -> String { PROJECT_MANIFEST_API_VERSION.to_owned() } diff --git a/crates/wesley-core/tests/project_manifest.rs b/crates/wesley-core/tests/project_manifest.rs index 1f31ac85..51d7db9f 100644 --- a/crates/wesley-core/tests/project_manifest.rs +++ b/crates/wesley-core/tests/project_manifest.rs @@ -151,6 +151,25 @@ fn project_manifest_rejects_schema_ids_that_are_not_path_safe() { .contains("schemaPath id '../bad' must contain only ASCII letters")); } +#[test] +fn project_manifest_rejects_dot_only_schema_ids() { + let error = load_project_manifest( + r#" +{ + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": [ + { "id": "..", "path": "schema.graphql" } + ] +} +"#, + ) + .expect_err("dot-only schema ids should fail validation"); + + assert!(error + .to_string() + .contains("schemaPath id '..' must not be '.', '..', or only dots")); +} + #[test] fn project_manifest_rejects_unknown_schema_path_fields() { let error = load_project_manifest( From c18b69f175012a9367afb6761cb54a76f7cabff2 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 01:59:05 -0700 Subject: [PATCH 13/18] Fix: preserve manifest bundle path roots --- .../src/domain/project_manifest.rs | 33 ++++++++++++--- crates/wesley-core/tests/project_manifest.rs | 40 +++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/crates/wesley-core/src/domain/project_manifest.rs b/crates/wesley-core/src/domain/project_manifest.rs index e26ccad8..b892e5a0 100644 --- a/crates/wesley-core/src/domain/project_manifest.rs +++ b/crates/wesley-core/src/domain/project_manifest.rs @@ -402,7 +402,15 @@ fn schema_bundle_dir(base: &str, schema_id: &str, schema_count: usize) -> String if schema_count <= 1 { return base; } - format!("{base}/{schema_id}") + join_normalized_path(&base, schema_id) +} + +fn join_normalized_path(base: &str, child: &str) -> String { + match base { + "." => format!("./{child}"), + "/" => format!("/{child}"), + _ => format!("{base}/{child}"), + } } fn schema_id_is_path_safe(id: &str) -> bool { @@ -500,13 +508,28 @@ fn slug_from_path(path: &str, index: usize) -> String { } fn normalize_path(path: &str) -> String { - path.trim() - .trim_start_matches("./") - .replace('\\', "/") + let path = path.trim().replace('\\', "/"); + let is_absolute = path.starts_with('/'); + let is_current = path == "." || path == "./"; + let normalized = path .split('/') .filter(|segment| !segment.is_empty() && *segment != ".") .collect::>() - .join("/") + .join("/"); + + if normalized.is_empty() { + if is_absolute { + "/".to_owned() + } else if is_current { + ".".to_owned() + } else { + String::new() + } + } else if is_absolute { + format!("/{normalized}") + } else { + normalized + } } fn glob_matches(pattern: &str, path: &str) -> bool { diff --git a/crates/wesley-core/tests/project_manifest.rs b/crates/wesley-core/tests/project_manifest.rs index 51d7db9f..9a880258 100644 --- a/crates/wesley-core/tests/project_manifest.rs +++ b/crates/wesley-core/tests/project_manifest.rs @@ -132,6 +132,46 @@ fn changed_schema_selection_uses_schema_and_global_globs() { .all(|schema| schema.reason == "no changed files provided")); } +#[test] +fn changed_schema_selection_preserves_current_and_absolute_bundle_dirs() { + let current_dir_manifest = load_project_manifest( + r#" +{ + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": [ + { "id": "core", "path": "schemas/core/schema.graphql" }, + { "id": "audit", "path": "schemas/audit/schema.graphql" } + ], + "bundleDir": "." +} +"#, + ) + .expect("current-dir bundle manifest should load"); + + let selected = + select_changed_schema_paths(¤t_dir_manifest, ["schemas/core/schema.graphql"]); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].bundle_dir, "./core"); + + let absolute_manifest = load_project_manifest( + r#" +{ + "apiVersion": "wesley.project-manifest/v1", + "schemaPaths": [ + { "id": "core", "path": "schemas/core/schema.graphql" }, + { "id": "audit", "path": "schemas/audit/schema.graphql" } + ], + "bundleDir": "/tmp/wesley-cache" +} +"#, + ) + .expect("absolute bundle manifest should load"); + + let selected = select_changed_schema_paths(&absolute_manifest, ["schemas/core/schema.graphql"]); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].bundle_dir, "/tmp/wesley-cache/core"); +} + #[test] fn project_manifest_rejects_schema_ids_that_are_not_path_safe() { let error = load_project_manifest( From c5baf525807a510a208214ad0532ae399cc4c8c5 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 02:00:34 -0700 Subject: [PATCH 14/18] Fix: reject multi-document manifests --- .../src/domain/project_manifest.rs | 11 +++++++--- crates/wesley-core/tests/project_manifest.rs | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/wesley-core/src/domain/project_manifest.rs b/crates/wesley-core/src/domain/project_manifest.rs index b892e5a0..ee73c490 100644 --- a/crates/wesley-core/src/domain/project_manifest.rs +++ b/crates/wesley-core/src/domain/project_manifest.rs @@ -439,12 +439,17 @@ fn parse_manifest_value(source: &str) -> Result "not valid JSON ({json_error}); not valid YAML ({yaml_error})" )) })?; - let Some(document) = documents.first() else { + if documents.is_empty() { return Err(ProjectManifestError::Parse( "YAML source contained no document".to_owned(), )); - }; - yaml_to_json(document) + } + if documents.len() != 1 { + return Err(ProjectManifestError::Parse( + "YAML manifests must contain exactly one document".to_owned(), + )); + } + yaml_to_json(&documents[0]) } } } diff --git a/crates/wesley-core/tests/project_manifest.rs b/crates/wesley-core/tests/project_manifest.rs index 9a880258..2f1781bf 100644 --- a/crates/wesley-core/tests/project_manifest.rs +++ b/crates/wesley-core/tests/project_manifest.rs @@ -62,6 +62,26 @@ dashboard: ); } +#[test] +fn project_manifest_rejects_multi_document_yaml() { + let error = load_project_manifest( + r#" +apiVersion: wesley.project-manifest/v1 +schemaPaths: + - schema.graphql +--- +apiVersion: wesley.project-manifest/v1 +schemaPaths: + - ignored.graphql +"#, + ) + .expect_err("multi-document YAML should fail validation"); + + assert!(error + .to_string() + .contains("YAML manifests must contain exactly one document")); +} + #[test] fn project_manifest_rejects_mutually_exclusive_targets() { let error = load_project_manifest( From 13021cde1b2ec7c0385278d659ebfdc8c4727481 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 02:01:46 -0700 Subject: [PATCH 15/18] Fix: assert fixture descriptors individually --- test/domain-empty-boundary.bats | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/domain-empty-boundary.bats b/test/domain-empty-boundary.bats index b5f06638..078d710a 100644 --- a/test/domain-empty-boundary.bats +++ b/test/domain-empty-boundary.bats @@ -117,9 +117,14 @@ load 'bats-plugins/bats-assert/load' assert_success [ "$output" -eq 0 ] - run bash -lc "grep -R '\"descriptorOnly\": true' test/fixtures/extensions/fixture-zoo | wc -l" - assert_success - [ "$output" -eq 3 ] + for fixture in \ + test/fixtures/extensions/fixture-zoo/compiler-heavy/fixture-extension.json \ + test/fixtures/extensions/fixture-zoo/evidence-heavy/fixture-extension.json \ + test/fixtures/extensions/fixture-zoo/blade-heavy/fixture-extension.json + do + run grep -F '"descriptorOnly": true' "$fixture" + assert_success + done run rg -n "Postgres|Supabase|Continuum|Echo|Vite|Vue|runtime execution.*true" test/fixtures/extensions/fixture-zoo assert_failure From f9fa298c3b777f87b57957c4cf8592c9b4ea04c3 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 02:02:46 -0700 Subject: [PATCH 16/18] Fix: clarify default build artifact paths --- docs/build-artifacts.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/build-artifacts.md b/docs/build-artifacts.md index 69a2bea9..e5ce15f8 100644 --- a/docs/build-artifacts.md +++ b/docs/build-artifacts.md @@ -4,9 +4,9 @@ Wesley generates several directories and files as part of its compile and valida | Artifact | Produced By | Purpose / Contents | Safe to Delete? | | --------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| `.wesley-cache/` | Wesley evidence fixtures, HOLMES/Moriarty analysis, and external targets | Generated evidence bundle, score reports, SHIPME certificate, HOLMES inputs, ledger state, and counterfactual cache. Delete when you no longer need the latest local run state. | โœ… Generated each run. | -| `out/` | Native emit commands or external target modules | Generic artifacts emitted by explicit CLI commands such as `wesley emit rust` / `wesley emit typescript`, or by owning external targets. | โœ… Generated from the current schema. | -| `out/zod/` | External target modules | JavaScript validation schemas are no longer a core Wesley command; reintroduce them through an owning external target when needed. | โœ… Regenerated when commands run. | +| `.wesley-cache/` | Wesley evidence fixtures and HOLMES/Moriarty analysis by default | Default generated evidence bundle location for Wesley-owned workflows: score reports, SHIPME certificate inputs, HOLMES inputs, ledger state, and counterfactual cache. External targets may choose their own cache paths. | โœ… Generated each run. | +| `out/` | Native emit commands by default; external targets only when configured | Default generic output location for explicit CLI commands such as `wesley emit rust` / `wesley emit typescript`. External target modules own any additional `out/` layout they choose to emit. | โœ… Generated from the current schema. | +| `out/zod/` | External target modules, if configured | JavaScript validation schemas are no longer a core Wesley command; this path appears only when an owning external target elects to emit it. | โœ… Regenerated when commands run. | | `test/fixtures/examples/out/` | `pnpm generate:example`, direct CLI runs using the bundled fixtures | Generated artifacts for the ecommerce demo schema (follows the same subdirectory layout). | โœ… Regenerated on next demo run. | | `test/fixtures/examples/.wesley-cache/` | `pnpm generate:example`, demo rehearsals | Evidence bundle for example schema; mirrors root `.wesley-cache/`. | โœ… Regenerated with demo commands. | | `coverage/` | `pnpm test:coverage` | Coverage reports from Jest/Vitest suites. | โœ… Pure test output. | From 3b3ead22649a41e5a966c89733185b8173a54073 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 02:03:20 -0700 Subject: [PATCH 17/18] Fix: document Holmes workflow count assertions --- test/ci-workflows.bats | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index 2663024c..c5a42c10 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -188,6 +188,8 @@ load 'bats-plugins/bats-assert/load' } @test "HOLMES workflow uses Wesley project manifest for selective schema sets" { + # Exact counts below pin the four matrix consumers and three report uploaders. + # If a job is added or removed, this contract should be reviewed deliberately. run bash -lc "grep -F 'detect-schema-sets:' .github/workflows/wesley-holmes.yml | wc -l" assert_success [ "$output" -eq 1 ] From a9dc2dbd6847c9152440388f69ff49fddbba8ab5 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 26 Jun 2026 02:04:46 -0700 Subject: [PATCH 18/18] Fix: remove stray Holmes comment fetch --- .github/workflows/wesley-holmes.yml | 9 --------- test/ci-workflows.bats | 4 ++++ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/wesley-holmes.yml b/.github/workflows/wesley-holmes.yml index 6441ba82..1ab44d6c 100644 --- a/.github/workflows/wesley-holmes.yml +++ b/.github/workflows/wesley-holmes.yml @@ -595,12 +595,3 @@ jobs: body }); } - - name: '๐Ÿ”„ Ensure history for MORIARTY' - shell: bash - run: | - set -euo pipefail - # In case fetch-depth wasn't honored or repo is shallow, try to unshallow - if git rev-parse --is-shallow-repository >/dev/null 2>&1; then - git fetch --prune --unshallow --tags || true - fi - git fetch --prune origin '+refs/heads/*:refs/remotes/origin/*' || true diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index c5a42c10..e394916a 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -604,4 +604,8 @@ load 'bats-plugins/bats-assert/load' run bash -lc 'grep -F -- '\''--head-sha "$GITHUB_SHA"'\'' .github/workflows/wesley-holmes.yml | wc -l' assert_success [ "$output" -eq 1 ] + + run bash -lc "grep -A140 '^ comment-report:' .github/workflows/wesley-holmes.yml | grep -F 'Ensure history for MORIARTY' | wc -l" + assert_success + [ "$output" -eq 0 ] }