From 2f759daf1dbb76bffd4b6a4db46decc3c5bb4822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tyrese=20Luo=20=28=E7=BE=85=E5=81=A5=E5=B3=AF=29?= Date: Mon, 12 Oct 2026 20:58:57 +0800 Subject: [PATCH] feat(hub): stable error codes and one-line fixes in the gate report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every refusal and warning the gate prints now carries a stable machine code and a one-line fix, so a tool (an agent repairing its own bundle, a CI job) can act on a failure without parsing English: - Finding gains optional code and hint, serialized in --json when set; the text report adds a "fix [code]: …" line under each coded finding. The human messages are unchanged, and uncoded findings (notes for a reviewer, such as what a component reaches) print as before. - Codes cover the whole gate: structural admission, digest and signing, identity, contents, external references, secrets, storage, the listing, the agent and tools review, grants, history, the app's own functions, shared components and component releases. - Codes from the first printing keep their meaning; hub-host-undeclared is retired (HTTPS destinations are declarations, not refusals) and stays reserved. - docs/ERROR-CODES.md is the table, linked from PUBLISHING.md's gate rules, the READMEs and hub's usage. A code, once printed, never changes what it means; new codes may be added. --- README.md | 1 + README.zh-CN.md | 1 + crates/app-hub/src/admission.rs | 18 +- crates/app-hub/src/advisories.rs | 22 +- crates/app-hub/src/bin/hub-usage.txt | 3 +- crates/app-hub/src/bin/hub.rs | 4 +- crates/app-hub/src/gate.rs | 461 ++++++++++++++++++-------- crates/app-hub/tests/component_cli.rs | 2 +- docs/ERROR-CODES.md | 131 ++++++++ docs/PUBLISHING.md | 13 +- docs/PUBLISHING.zh-CN.md | 4 +- 11 files changed, 506 insertions(+), 154 deletions(-) create mode 100644 docs/ERROR-CODES.md diff --git a/README.md b/README.md index 9bfef31..e8f90d2 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ engines are not bundled; see the [browser requirements](https://github.com/OctoS | `docs/FIRST-APP.md` | A first-app walkthrough for a card app or a script app: create, run, capture and check. | | `docs/SUBMITTING.md` | The submission's four stages, who runs each `hub` command, and every step from repository layout to publication. | | `docs/PUBLISHING.md` | The reference: gate rules; capabilities and who serves them; manifest, listing and tool fields; host services; `hub` commands; signing. | +| `docs/ERROR-CODES.md` | The gate's stable error codes and one-line fixes, in its text report and `--json`, for tools that repair bundles. | | `docs/GITHUB-PUBLISHING.md` | Admin-authorized GitHub signing, exact candidate review and v2 migration. | | `docs/HOST-API.md` | Declaring the host APIs an app needs, `runtime` discovery and what today's hosts implement. | | `docs/ICONS.md` | Canonical icon ownership, export constraints and visual review. | diff --git a/README.zh-CN.md b/README.zh-CN.md index e971952..569d78f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -82,6 +82,7 @@ Linux 需要 GTK 3/WebKitGTK 和 X11/XWayland,Windows 需要 WebView2。这些 | `docs/FIRST-APP.md` | 第一个应用(卡片应用或脚本应用)的分步教程:创建、运行、截图与检查。 | | `docs/SUBMITTING.md` | 提交流程的四个阶段、每个 `hub` 命令由谁运行,以及从仓库结构到发布的每一步。 | | `docs/PUBLISHING.md` | 参考文档:准入规则;能力及其提供方;清单、商店信息和工具的字段;宿主服务;`hub` 命令;签名。 | +| `docs/ERROR-CODES.md` | 准入检查的稳定错误码与一行修复建议(英文),出现在文本报告与 `--json` 中,供修复应用包的工具读取。 | | `docs/GITHUB-PUBLISHING.md` | 管理员授权的 GitHub 签名、确切候选审核及 v2 迁移。 | | `docs/HOST-API.md` | 声明应用需要的宿主 API、用 `runtime` 发现宿主 API,以及目前各宿主实现了哪些 API。 | | `docs/ICONS.md` | 规范图标的归属、导出约束与视觉评审。 | diff --git a/crates/app-hub/src/admission.rs b/crates/app-hub/src/admission.rs index 7f5d430..c57721c 100644 --- a/crates/app-hub/src/admission.rs +++ b/crates/app-hub/src/admission.rs @@ -177,7 +177,8 @@ fn font_resource(value: &Value, path: &str, tokens: &Value, out: &mut Vec {}, Some(Value::String(target)) if target.is_empty() => {}, _ => findings.push(Finding::at("resource-invalid", path, - "font_src must be a bundled font path, a supported built-in font, or one token resolving to a string")), + "font_src must be a bundled font path, a supported built-in font, or one token resolving to a string") + .coded("hub-font-invalid", "set font_src to a bundled font path, a supported built-in font, or one token resolving to a string")), } } @@ -229,11 +230,17 @@ pub fn validate(root: &Path, files: &[BundleFile]) -> (Vec, Vec Ok(()), }; if let Err(error) = result { - findings.push(Finding::at("contents-invalid", name.as_str(), error)); + findings.push(Finding::at("contents-invalid", name.as_str(), error).coded( + "hub-contents-invalid", + "the file is not what its extension says: repair or re-export it (UTF-8 text within the size limit, a decodable image, a sized scriptless SVG, an admissible wasm)", + )); } } if let Err((path, error)) = validate_entry(root) { - findings.push(Finding::at("entry", path, error)); + findings.push(Finding::at("entry", path, error).coded( + "hub-entry-invalid", + "add an entry the runtime can start: main.splash (a script app) or page.card with its kit (a card)", + )); } for reference in &refs { if matches!(reference.kind, ResourceKind::DisplayUrl) { @@ -258,7 +265,10 @@ pub fn validate(root: &Path, files: &[BundleFile]) -> (Vec, Vec Result, String> { } else { format!("fixed in {}", advisory.patched.join(" or ")) }; - findings.push(Finding { - severity: Severity::Warning, - check: "advisories", - detail: format!( - "{name} includes {} {}, which {} reports as {what}: {}; {fixed}", - listed.name, listed.version, advisory.id, advisory.title - ), - path: Some(name.clone()), - }); + findings.push( + Finding::warn_at( + "advisories", + name.clone(), + format!( + "{name} includes {} {}, which {} reports as {what}: {}; {fixed}", + listed.name, listed.version, advisory.id, advisory.title + ), + ) + .coded("hub-crate-advisory", "upgrade the crate to a fixed version and rebuild the component, then re-run with --advisory-db to confirm"), + ); } } } diff --git a/crates/app-hub/src/bin/hub-usage.txt b/crates/app-hub/src/bin/hub-usage.txt index 9230ce8..57f46c3 100644 --- a/crates/app-hub/src/bin/hub-usage.txt +++ b/crates/app-hub/src/bin/hub-usage.txt @@ -61,7 +61,8 @@ publish, human-review stops it until --reviewed) and writes check is the gate: a publisher runs it before submitting, the hub runs the same code after. publish re-runs it, copies the bundle into the artifact -store and signs the catalog. +store and signs the catalog. Each finding carries a stable code and a one-line +fix (docs/ERROR-CODES.md), in the text report and in --json. Shared components (App Hub ADR 0003) are published like apps: the release's GitHub workflow runs component-prepare, attests the subject and runs diff --git a/crates/app-hub/src/bin/hub.rs b/crates/app-hub/src/bin/hub.rs index 439172f..5051d92 100644 --- a/crates/app-hub/src/bin/hub.rs +++ b/crates/app-hub/src/bin/hub.rs @@ -309,7 +309,9 @@ fn run() -> Result<(), String> { // the same shape, for tools that read --json. if has("json") { println!("{}", serde_json::json!({"schema": 1, "stage": "structural", "passed": false, - "findings": [{"severity": "refusal", "check": "bundle-invalid", "detail": error}]})); + "findings": [{"severity": "refusal", "check": "bundle-invalid", "detail": error, + "code": "hub-bundle-invalid", + "hint": "the bundle is unreadable: manifest.json must be present, valid JSON the manifest schema accepts (schema, id, version, name)"}]})); } return Err(error); } diff --git a/crates/app-hub/src/gate.rs b/crates/app-hub/src/gate.rs index 27e4f64..92f7166 100644 --- a/crates/app-hub/src/gate.rs +++ b/crates/app-hub/src/gate.rs @@ -38,20 +38,48 @@ pub struct Finding { /// Where in the bundle, when one file or property is at fault. #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, + /// A stable machine code, for tools that repair bundles + /// (docs/ERROR-CODES.md). Once printed, a code never changes what it + /// means; new codes may be added. + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option<&'static str>, + /// One line: how to make the gate pass. Present exactly when `code` is. + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option<&'static str>, } impl Finding { fn refuse(check: &'static str, detail: impl Into) -> Self { - Finding { severity: Severity::Refusal, check, detail: detail.into(), path: None } + Finding { severity: Severity::Refusal, check, detail: detail.into(), path: None, code: None, hint: None } } fn warn(check: &'static str, detail: impl Into) -> Self { - Finding { severity: Severity::Warning, check, detail: detail.into(), path: None } + Finding { severity: Severity::Warning, check, detail: detail.into(), path: None, code: None, hint: None } } pub(crate) fn at(check: &'static str, path: impl Into, detail: impl Into) -> Self { - Finding { severity: Severity::Refusal, check, detail: detail.into(), path: Some(path.into()) } + Finding { severity: Severity::Refusal, check, detail: detail.into(), path: Some(path.into()), code: None, hint: None } + } + pub(crate) fn warn_at(check: &'static str, path: impl Into, detail: impl Into) -> Self { + Finding { severity: Severity::Warning, check, detail: detail.into(), path: Some(path.into()), code: None, hint: None } } - fn warn_at(check: &'static str, path: impl Into, detail: impl Into) -> Self { - Finding { severity: Severity::Warning, check, detail: detail.into(), path: Some(path.into()) } + /// Attaches the stable code and its one-line fix (docs/ERROR-CODES.md). + pub(crate) fn coded(mut self, code: &'static str, hint: &'static str) -> Self { + self.code = Some(code); + self.hint = Some(hint); + self + } + + /// The finding as the text report prints it: its own line, then the + /// `fix [code]: …` line a tool reads when it carries a code. + fn render_into(&self, out: &mut String) { + let mark = match self.severity { + Severity::Refusal => "refused", + Severity::Warning => "warning", + }; + let path = self.path.as_ref().map(|p| format!(" ({p})")).unwrap_or_default(); + out.push_str(&format!(" [{mark}] {}{path}: {}\n", self.check, self.detail)); + if let (Some(code), Some(hint)) = (self.code, self.hint) { + out.push_str(&format!(" fix [{code}]: {hint}\n")); + } } } @@ -83,16 +111,13 @@ impl GateReport { !self.findings.iter().any(|f| f.severity == Severity::Refusal) } - /// One line per finding, for a pull-request comment or a terminal. + /// One line per finding, for a pull-request comment or a terminal. A + /// coded finding's `fix [code]: …` line follows its message, so tools + /// that repair bundles can read the code without --json. pub fn render(&self) -> String { let mut out = format!("{} {} — {}\n", self.app_id, self.version, if self.passed() { "PASSED" } else { "REFUSED" }); for finding in &self.findings { - let mark = match finding.severity { - Severity::Refusal => "refused", - Severity::Warning => "warning", - }; - let path = finding.path.as_ref().map(|p| format!(" ({p})")).unwrap_or_default(); - out.push_str(&format!(" [{mark}] {}{path}: {}\n", finding.check, finding.detail)); + finding.render_into(&mut out); } if let Some(policy) = &self.policy { let storage = format!("{} bytes", policy.storage_bytes); @@ -169,19 +194,31 @@ fn check_bundle_for( // ---- integrity ------------------------------------------------------ if !digest.eq_ignore_ascii_case(&manifest.integrity.bundle_blake3) { - findings.push(Finding::refuse( - "digest", - format!("the bundle hashes to {digest}, the manifest claims {}", manifest.integrity.bundle_blake3), - )); + findings.push( + Finding::refuse( + "digest", + format!("the bundle hashes to {digest}, the manifest claims {}", manifest.integrity.bundle_blake3), + ) + .coded("hub-digest-mismatch", "run `hub stamp ` after every edit (tools/octo check stamps first), then check again"), + ); } if let Err(e) = octosense_app_policy::verify::verify_manifest(&manifest, verifier) { - findings.push(Finding::refuse("publisher-signature", e)); + findings.push( + Finding::refuse("publisher-signature", e) + .coded("hub-signature-invalid", "the signature or GitHub attestation no longer matches the manifest: restamp (`hub stamp`), then sign again (`hub sign-manifest`) or re-attach the attestation (`hub publisher-attach`)"), + ); } if manifest.integrity.signature.is_none() && manifest.integrity.github.is_none() { if limits.require_signature { - findings.push(Finding::refuse("publisher-signature", "this hub requires a signed manifest")); + findings.push( + Finding::refuse("publisher-signature", "this hub requires a signed manifest") + .coded("hub-signature-required", "sign the manifest (`hub keygen` once, then `hub sign-manifest`) or attach GitHub provenance, or check with --allow-unsigned while developing"), + ); } else { - findings.push(Finding::warn("publisher-signature", "unsigned: accountability rests on the hub alone")); + findings.push( + Finding::warn("publisher-signature", "unsigned: accountability rests on the hub alone") + .coded("hub-unsigned", "sign before submitting: an unsigned release asks the person to trust the hub alone"), + ); } } @@ -189,16 +226,22 @@ fn check_bundle_for( // Ids under `os.` are the system apps' (appstore::system): every device // refuses to install one from a store, so the hub refuses to offer one. if manifest.id.starts_with("os.") && !system_development { - findings.push(Finding::refuse( - "identity", - format!("{} is under os., which is reserved for system apps that ship with the device", manifest.id), - )); + findings.push( + Finding::refuse( + "identity", + format!("{} is under os., which is reserved for system apps that ship with the device", manifest.id), + ) + .coded("hub-id-system", "os.* ids ship with the device: pick your own reverse-domain id, e.g. dev.."), + ); } if system_development && !manifest.id.starts_with("os.") { - findings.push(Finding::refuse( - "identity", - "--system-app checks only shipped os.* apps; use ordinary check/scan for store apps", - )); + findings.push( + Finding::refuse( + "identity", + "--system-app checks only shipped os.* apps; use ordinary check/scan for store apps", + ) + .coded("hub-id-not-system", "drop --system-app for a store app: it checks a shipped os.* bundle during development, nothing else"), + ); } // Native apps' ids and the host's own names (app-policy's // RESERVED_NAMES) are refused by the agent review below (as "identity") @@ -220,14 +263,20 @@ fn check_bundle_for( } else if extension == "wasm" { function_files.push(file.clone()); } else if !ALLOWED_EXTENSIONS.contains(&extension.as_str()) { - findings.push(Finding::refuse( - "contents", - format!("{} has extension {extension:?}, which a bundle may not hold", file.display()), - )); + findings.push( + Finding::refuse( + "contents", + format!("{} has extension {extension:?}, which a bundle may not hold", file.display()), + ) + .coded("hub-file-type", "remove the file, or convert it to a type a bundle may hold (.card .json .l0 .octoscript .splash .svg .png .jpg .jpeg .webp .ttf .otf .txt .md, and fns/*.wasm)"), + ); } } if total > MAX_BUNDLE_BYTES { - findings.push(Finding::refuse("size", format!("the bundle is {total} bytes, over the {MAX_BUNDLE_BYTES} ceiling"))); + findings.push( + Finding::refuse("size", format!("the bundle is {total} bytes, over the {MAX_BUNDLE_BYTES} ceiling")) + .coded("hub-too-big", "shrink the artwork (compress the images) or split the app; a bundle is cards, data and artwork only"), + ); } // ---- the app's own functions ---------------------------------------- @@ -238,19 +287,28 @@ fn check_bundle_for( let declares_wasm = manifest.capabilities.iter().any(|c| c == "wasm"); let modules = function_files.len(); if modules > 0 && !declares_wasm { - findings.push(Finding::warn( - "functions", - format!("the bundle carries {modules} WebAssembly module(s); add wasm to its usage disclosures"), - )); + findings.push( + Finding::warn( + "functions", + format!("the bundle carries {modules} WebAssembly module(s); add wasm to its usage disclosures"), + ) + .coded("hub-wasm-undeclared", "add wasm to the manifest's capabilities, or remove the fns/*.wasm modules"), + ); } if modules > MAX_FUNCTION_MODULES { - findings.push(Finding::refuse( - "functions", - format!("the bundle carries {modules} WebAssembly modules, over the {MAX_FUNCTION_MODULES} it may"), - )); + findings.push( + Finding::refuse( + "functions", + format!("the bundle carries {modules} WebAssembly modules, over the {MAX_FUNCTION_MODULES} it may"), + ) + .coded("hub-too-many-functions", "keep at most 8 fns/*.wasm modules; split the app, or move shared logic into a shared component"), + ); } if declares_wasm && modules == 0 && manifest.components.is_empty() { - findings.push(Finding::warn("functions", "the bundle declares the wasm capability but carries no fns/*.wasm")); + findings.push( + Finding::warn("functions", "the bundle declares the wasm capability but carries no fns/*.wasm") + .coded("hub-wasm-missing", "ship the fns/*.wasm the capability discloses, or drop wasm from the capabilities"), + ); } // A component also reaches supported WASI interfaces. Admission validated // its imports; required ABIs keep unsupported hosts from installing it. @@ -273,45 +331,65 @@ fn check_bundle_for( continue; } if !requires_components { - findings.push(Finding::refuse( - "functions", - format!("{name} is a WebAssembly component; the manifest must require {}", crate::functions::COMPONENTS_FEATURE), - )); + findings.push( + Finding::refuse( + "functions", + format!("{name} is a WebAssembly component; the manifest must require {}", crate::functions::COMPONENTS_FEATURE), + ) + .coded("hub-component-requires", "add wasm-components-v1 to the manifest's requires, so hosts without components never install the app"), + ); } if let Some(missing) = info.missing_host_api(&manifest) { - findings.push(Finding::refuse("functions", format!("{name} {missing}"))); + findings.push( + Finding::refuse("functions", format!("{name} {missing}")) + .coded("hub-component-host-api", "require the host API the component imports (host_api.required, with requires host-api-v1), or rebuild it without that import"), + ); } if info.uses_sockets() && !declares_net { - findings.push(Finding::warn("functions", format!("{name} imports wasi:sockets; disclose net usage"))); + findings.push( + Finding::warn("functions", format!("{name} imports wasi:sockets; disclose net usage")) + .coded("hub-wasi-net-undeclared", "disclose net usage in the manifest's capabilities"), + ); } if info.uses_files() && !declares_storage { - findings.push(Finding::warn( - "functions", - format!("{name} imports wasi:filesystem; disclose storage usage (the app jail and quota still apply)"), - )); + findings.push( + Finding::warn( + "functions", + format!("{name} imports wasi:filesystem; disclose storage usage (the app jail and quota still apply)"), + ) + .coded("hub-wasi-storage-undeclared", "disclose storage usage in the manifest's capabilities (the app jail and quota still apply)"), + ); } if info.uses_http() && !declares_net { - findings.push(Finding::warn( - "functions", - format!("{name} imports wasi:http; disclose net usage"), - )); + findings.push( + Finding::warn("functions", format!("{name} imports wasi:http; disclose net usage")) + .coded("hub-wasi-net-undeclared", "disclose net usage in the manifest's capabilities"), + ); } findings.push(Finding::warn_at("functions", name.clone(), format!("{name} is a component that reaches {}", info.reach()))); // What it says it is built from, for reviewers and for // `hub check --advisory-db` (the gate cannot check it against the code). - findings.push(Finding::warn_at("functions", name.clone(), match &info.crates { - Some(crates) => format!("{name} is built from {}", crate::functions::crate_line(crates)), - None => format!( - "{name} does not list the crates it is built from (its {} section); tools/octo wasm build and App Flow's release workflow add it", - crate::functions::CRATES_SECTION - ), - })); + findings.push(match &info.crates { + Some(crates) => Finding::warn_at("functions", name.clone(), format!("{name} is built from {}", crate::functions::crate_line(crates))), + None => Finding::warn_at( + "functions", + name.clone(), + format!( + "{name} does not list the crates it is built from (its {} section); tools/octo wasm build and App Flow's release workflow add it", + crate::functions::CRATES_SECTION + ), + ) + .coded("hub-component-crates", "rebuild with tools/octo wasm build (or App Flow's release workflow) so the component lists the crates it is built from"), + }); } if requires_components && components == 0 { - findings.push(Finding::warn( - "functions", - format!("the manifest requires {} but fns/ holds no component; hosts without it refuse the app", crate::functions::COMPONENTS_FEATURE), - )); + findings.push( + Finding::warn( + "functions", + format!("the manifest requires {} but fns/ holds no component; hosts without it refuse the app", crate::functions::COMPONENTS_FEATURE), + ) + .coded("hub-component-requires-unused", "build the component into fns/ (tools/octo wasm build), or drop the requirement"), + ); } // ---- shared components (App Hub ADR 0003) ----------------------------- @@ -328,10 +406,13 @@ fn check_bundle_for( // the grant gates. Until that is closed in the runtime, the gate is what // keeps a bundle from reaching outside itself. for reference in external_references(bundle, &manifest)? { - findings.push(Finding::refuse( - "assets", - format!("{reference} points outside the bundle; ship the asset with the app"), - )); + findings.push( + Finding::refuse( + "assets", + format!("{reference} points outside the bundle; ship the asset with the app"), + ) + .coded("hub-asset-external", "ship the asset inside the bundle and reference it bundle-relatively ({{assets}} in a script)"), + ); } // ---- secrets are the host's ---------------------------------------- @@ -340,10 +421,13 @@ fn check_bundle_for( // runtime makes such a field inert; the gate refuses the bundle, so a // publisher learns it before a person meets a dead field. for field in secret_fields(bundle)? { - findings.push(Finding::refuse( - "secrets", - format!("{field}: apps may not ask for passwords or codes; a host service collects them on its own sheet"), - )); + findings.push( + Finding::refuse( + "secrets", + format!("{field}: apps may not ask for passwords or codes; a host service collects them on its own sheet"), + ) + .coded("hub-secret-field", "remove the password or code field; the host's sheet collects secrets for the service that needs one"), + ); } // ---- storage is asked for ------------------------------------------ @@ -352,30 +436,48 @@ fn check_bundle_for( // refused: the publisher may handle the error, and a camera preview // needs no storage. for warning in storage_warnings(bundle, &manifest)? { - findings.push(Finding::warn("storage", warning)); + findings.push( + Finding::warn("storage", warning) + .coded("hub-storage-undeclared", "declare the storage capability in the manifest, or handle the fs errors and keep nothing"), + ); } // ---- the listing ---------------------------------------------------- // A store shows nothing it has not reviewed: the listing ships in the // bundle, under the same digest, and its assets must be there too. match std::fs::read_to_string(bundle.join(LISTING_FILE)) { - Err(_) => findings.push(Finding::refuse("listing", format!("no {LISTING_FILE}: a store needs a description, a category, screenshots, a publisher and the platforms it runs on"))), + Err(_) => findings.push( + Finding::refuse("listing", format!("no {LISTING_FILE}: a store needs a description, a category, screenshots, a publisher and the platforms it runs on")) + .coded("hub-listing-missing", "add listing.json with description, category, screenshots, publisher and platforms (docs/PUBLISHING.md#the-listing)"), + ), Ok(text) => match Listing::parse(&text) { - Err(e) => findings.push(Finding::refuse("listing", e)), + Err(e) => findings.push( + Finding::refuse("listing", e) + .coded("hub-listing-invalid", "fix listing.json against the listing rules in docs/PUBLISHING.md#the-listing"), + ), Ok(listing) => { // A screenshot is the one listing claim a reviewer can check // against the rendered card, and the icon is what the // launcher shows once installed: both are required, as on // every store people know. if listing.screenshots.is_empty() { - findings.push(Finding::refuse("listing", "no screenshots: at least one PNG in the bundle, named in the listing, is required")); + findings.push( + Finding::refuse("listing", "no screenshots: at least one PNG in the bundle, named in the listing, is required") + .coded("hub-listing-no-screenshots", "capture a PNG of the running app (tools/octo shot) and name it in listing.json's screenshots"), + ); } if listing.icon.is_none() { - findings.push(Finding::refuse("listing", "no icon: a square PNG or SVG in the bundle, named in the listing, is required")); + findings.push( + Finding::refuse("listing", "no icon: a square PNG or SVG in the bundle, named in the listing, is required") + .coded("hub-listing-no-icon", "add a square PNG or SVG to the bundle and name it in listing.json's icon"), + ); } for asset in listing.screenshots.iter().chain(listing.icon.iter()) { if !bundle.join(asset).is_file() { - findings.push(Finding::refuse("listing", format!("{asset} is named by the listing but is not in the bundle"))); + findings.push( + Finding::refuse("listing", format!("{asset} is named by the listing but is not in the bundle")) + .coded("hub-listing-asset-missing", "put the named file in the bundle at that path, or drop it from the listing"), + ); } } } @@ -388,10 +490,18 @@ fn check_bundle_for( // the same checks from the app-peers broker (ToolManifest::load). let agent_review = octosense_app_policy::agent::review(bundle, &manifest); for issue in &agent_review.issues { + // The review's own words stand; the gate adds the code a tool reads. + let (code, hint) = match issue.check { + "identity" => ("hub-id-reserved", "this id (or its last segment) is a native app's or a host's name: rename it"), + "tools" => ("hub-tools-invalid", "fix tools.json against the tool-manifest rules in docs/PUBLISHING.md (namespace, risk, implemented_by, schema)"), + "agent" => ("hub-agent-files", "name every agent file in agent.instructions, and declare `agent` in the manifest for any of them"), + "skills" => ("hub-skills-invalid", "a skill is data only: .md/.json/.txt files, its manifest named as its directory, no executable fields"), + _ => ("hub-agent-review", "fix what the line names against the agent rules in docs/PUBLISHING.md"), + }; findings.push(if issue.refusal { - Finding::refuse(issue.check, issue.detail.clone()) + Finding::refuse(issue.check, issue.detail.clone()).coded(code, hint) } else { - Finding::warn(issue.check, issue.detail.clone()) + Finding::warn(issue.check, issue.detail.clone()).coded(code, hint) }); } if let Some(agent) = &agent_review.bundle { @@ -418,7 +528,10 @@ fn check_bundle_for( let policy = match policy::resolve(&manifest, limits) { Ok(policy) => Some(policy), Err(e) => { - findings.push(Finding::refuse("policy", e)); + findings.push( + Finding::refuse("policy", e) + .coded("hub-grants-invalid", "the manifest asks for something the contract does not offer: check capabilities, hosts, agent and research against docs/PUBLISHING.md"), + ); None } }; @@ -427,10 +540,13 @@ fn check_bundle_for( if let Some(catalog) = previous { for entry in &catalog.entries { if entry.app_id() == manifest.id && entry.version() == manifest.version { - findings.push(Finding::refuse( - "version", - format!("version {} of {} is already published; publish a new version", manifest.version, manifest.id), - )); + findings.push( + Finding::refuse( + "version", + format!("version {} of {} is already published; publish a new version", manifest.version, manifest.id), + ) + .coded("hub-version-published", "bump version in manifest.json; a published version is immutable"), + ); } } let continuity = match registry { @@ -439,7 +555,10 @@ fn check_bundle_for( .and_then(|registry| crate::publishers::verify_continuity(&manifest, ®istry)), }; if let Err(e) = continuity { - findings.push(Finding::refuse("continuity", e)); + findings.push( + Finding::refuse("continuity", e) + .coded("hub-publisher-continuity", "sign with the key the catalog records for this publisher"), + ); } } @@ -461,10 +580,13 @@ fn shared_component_findings( ) { use crate::functions::SHARED_COMPONENTS_FEATURE; if manifest.requires.iter().any(|f| f == SHARED_COMPONENTS_FEATURE) && manifest.components.is_empty() { - findings.push(Finding::warn( - "components", - format!("the manifest requires {SHARED_COMPONENTS_FEATURE} but names no components; hosts without it refuse the app"), - )); + findings.push( + Finding::warn( + "components", + format!("the manifest requires {SHARED_COMPONENTS_FEATURE} but names no components; hosts without it refuse the app"), + ) + .coded("hub-components-unnamed", "name each component in the manifest's components, or drop the requirement"), + ); } let named = |file: &std::path::Path| { manifest.components.iter().any(|c| file == std::path::Path::new(crate::components::BUNDLED_DIR).join(format!("{}.wasm", c.blake3))) @@ -476,9 +598,11 @@ fn shared_component_findings( "components", name, "components/ ships only in a system app's bundle; a store app's components come from the catalog", - )); + ) + .coded("hub-component-bundled", "remove components/ from the bundle; a store app's components come from the catalog")); } else if !named(file) { - findings.push(Finding::at("components", name, "this file is not one of the manifest's components")); + findings.push(Finding::at("components", name, "this file is not one of the manifest's components") + .coded("hub-component-not-manifest", "name the file's BLAKE3 digest in the manifest's components, or remove the file")); } } for dependency in &manifest.components { @@ -486,10 +610,13 @@ fn shared_component_findings( let relative = format!("{}/{}.wasm", crate::components::BUNDLED_DIR, dependency.blake3); let path = bundle.join(&relative); if std::fs::symlink_metadata(&path).is_err() { - findings.push(Finding::refuse( - "components", - format!("component {} is not in the bundle: a system app ships it as {relative}", dependency.describe()), - )); + findings.push( + Finding::refuse( + "components", + format!("component {} is not in the bundle: a system app ships it as {relative}", dependency.describe()), + ) + .coded("hub-component-not-bundled", "ship the component in the bundle as components/.wasm"), + ); continue; } // Admission validated the file, its imports and its name. @@ -501,25 +628,37 @@ fn shared_component_findings( match crate::components::resolve(catalog, dependency) { Ok(entry) => crate::components::info_of(&entry.component), Err(e) => { - findings.push(Finding::refuse("components", e)); + findings.push( + Finding::refuse("components", e) + .coded("hub-component-unresolved", "fix the component's id, version and pinned digest against the catalog"), + ); continue; } } } else { - findings.push(Finding::warn( - "components", - format!( - "component {} is not resolved: check with --catalog to see that it is offered, matches its digest and what it reaches", - dependency.describe() - ), - )); + findings.push( + Finding::warn( + "components", + format!( + "component {} is not resolved: check with --catalog to see that it is offered, matches its digest and what it reaches", + dependency.describe() + ), + ) + .coded("hub-component-unverified", "re-run check with --catalog so the gate can confirm the component is offered and matches its digest"), + ); continue; }; if let Some(missing) = info.missing_host_api(manifest) { - findings.push(Finding::refuse("components", format!("component {} {missing}", dependency.describe()))); + findings.push( + Finding::refuse("components", format!("component {} {missing}", dependency.describe())) + .coded("hub-component-host-api", "require the host API the component imports (host_api.required, with requires host-api-v1), or rebuild it without that import"), + ); } if info.uses_sockets() && !manifest.capabilities.iter().any(|capability| capability == "net") { - findings.push(Finding::warn("components", format!("component {} imports wasi:sockets; disclose net usage", dependency.describe()))); + findings.push( + Finding::warn("components", format!("component {} imports wasi:sockets; disclose net usage", dependency.describe())) + .coded("hub-wasi-net-undeclared", "disclose net usage in the manifest's capabilities"), + ); } findings.push(Finding::warn("components", format!("component {} reaches {}", dependency.describe(), info.reach()))); } @@ -552,12 +691,7 @@ impl ComponentReport { if self.passed() { "PASSED" } else { "REFUSED" } ); for finding in &self.findings { - let mark = match finding.severity { - Severity::Refusal => "refused", - Severity::Warning => "warning", - }; - let path = finding.path.as_ref().map(|p| format!(" ({p})")).unwrap_or_default(); - out.push_str(&format!(" [{mark}] {}{path}: {}\n", finding.check, finding.detail)); + finding.render_into(&mut out); } out } @@ -592,20 +726,32 @@ pub fn check_component( let digest = blake3::hash(wasm).to_hex().to_string(); let mut findings = Vec::new(); for problem in release.problems() { - findings.push(Finding::refuse("component", problem)); + findings.push( + Finding::refuse("component", problem) + .coded("hub-component-release-invalid", "fix the release's fields against the component rules in docs/PUBLISHING.md, or prepare it again (hub component-prepare)"), + ); } let file = format!("{}-{}.wasm", c.id, c.version); if wasm.len() as u64 > crate::components::MAX_COMPONENT_BYTES { - findings.push(Finding::refuse( - "size", - format!("the component is {} bytes, over the {} ceiling", wasm.len(), crate::components::MAX_COMPONENT_BYTES), - )); + findings.push( + Finding::refuse( + "size", + format!("the component is {} bytes, over the {} ceiling", wasm.len(), crate::components::MAX_COMPONENT_BYTES), + ) + .coded("hub-component-too-big", "shrink the component under the 8 MB ceiling: trim dependencies, or split it"), + ); } if digest != c.wasm_blake3 { - findings.push(Finding::refuse("digest", format!("the file hashes to {digest}, the release says {}", c.wasm_blake3))); + findings.push( + Finding::refuse("digest", format!("the file hashes to {digest}, the release says {}", c.wasm_blake3)) + .coded("hub-component-digest-mismatch", "prepare the release again from this exact .wasm (hub component-prepare); its digest and size are pinned in it"), + ); } if wasm.len() as u64 != c.bytes { - findings.push(Finding::refuse("digest", format!("the file is {} bytes, the release says {}", wasm.len(), c.bytes))); + findings.push( + Finding::refuse("digest", format!("the file is {} bytes, the release says {}", wasm.len(), c.bytes)) + .coded("hub-component-digest-mismatch", "prepare the release again from this exact .wasm (hub component-prepare); its digest and size are pinned in it"), + ); } let admissible = if crate::functions::is_component(wasm) { crate::functions::admissible_component(wasm) @@ -613,35 +759,56 @@ pub fn check_component( Err("not a WebAssembly component".into()) }; match &admissible { - Err(e) => findings.push(Finding::at("contents-invalid", file.clone(), e.clone())), + Err(e) => findings.push(Finding::at("contents-invalid", file.clone(), e.clone()) + .coded("hub-component-invalid", "rebuild the file as a WebAssembly component with only the imports a host allows (hub component-info lists them)")), Ok(info) => { if info.imports != c.imports || info.exports != c.exports { - findings.push(Finding::refuse( - "component", - "the release's imports or exports are not the file's; prepare the release again from this file", - )); + findings.push( + Finding::refuse( + "component", + "the release's imports or exports are not the file's; prepare the release again from this file", + ) + .coded("hub-component-release-stale", "prepare the release again from this file (hub component-prepare); its imports and exports are pinned in the release"), + ); } } } match &c.integrity.github { Some(github) => { if let Err(e) = release.subject_bytes().and_then(|bytes| crate::github_publisher::verify_component(github, &bytes)) { - findings.push(Finding::refuse("publisher-signature", e)); + findings.push( + Finding::refuse("publisher-signature", e) + .coded("hub-component-provenance-invalid", "re-attest the exact file from its GitHub workflow and attach the new attestation"), + ); } } None if require_provenance => { - findings.push(Finding::refuse("publisher-signature", "App Hub accepts only GitHub-attested component releases")); + findings.push( + Finding::refuse("publisher-signature", "App Hub accepts only GitHub-attested component releases") + .coded("hub-component-provenance-required", "attest the release from its GitHub workflow; App Hub accepts only GitHub-attested component releases"), + ); } - None => findings.push(Finding::warn("publisher-signature", "unsigned: accountability rests on the hub alone")), + None => findings.push( + Finding::warn("publisher-signature", "unsigned: accountability rests on the hub alone") + .coded("hub-unsigned", "sign before submitting: an unsigned release asks the person to trust the hub alone"), + ), } if let Some(catalog) = previous { if catalog.entries.iter().any(|e| e.app_id() == c.id) { - findings.push(Finding::refuse("identity", format!("{} is an app's id in the catalog; a component needs an id of its own", c.id))); + findings.push( + Finding::refuse("identity", format!("{} is an app's id in the catalog; a component needs an id of its own", c.id)) + .coded("hub-component-id-taken", "pick a component id no app in the catalog uses"), + ); } let continuity = crate::components::ComponentPublishers::from_catalog(catalog).and_then(|registry| registry.authorize(release)); if let Err(e) = continuity { let check = if e.contains("is already published") { "version" } else { "continuity" }; - findings.push(Finding::refuse(check, e)); + let (code, hint) = if check == "version" { + ("hub-version-published", "bump version in the release; a published version is immutable") + } else { + ("hub-publisher-continuity", "attest from the repository the catalog records for this publisher") + }; + findings.push(Finding::refuse(check, e).coded(code, hint)); } } if let Ok(info) = &admissible { @@ -1017,4 +1184,36 @@ mod tests { assert!(found[0].contains("camera") && found[0].contains("capture"), "{found:?}"); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn a_refusal_names_its_code_and_one_line_fix() { + let dir = std::env::temp_dir().join(format!("gate-codes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("main.splash"), "Label{text: \"hi\"}").unwrap(); + // A stale digest and no listing: two of the commonest refusals. + std::fs::write( + dir.join(octosense_app_policy::MANIFEST_FILE), + serde_json::json!({"schema": 1, "id": "dev.example.app", "version": "1.0.0", "name": "App", "integrity": {"bundle_blake3": ""}}).to_string(), + ) + .unwrap(); + let limits = HostLimits::default().with_require_signature(false); + let report = check_bundle(&dir, &limits, &octosense_app_policy::RefuseAllSignatures, None).unwrap(); + let code = |check: &str| report.findings.iter().find(|f| f.check == check).and_then(|f| f.code); + assert_eq!(code("digest"), Some("hub-digest-mismatch")); + assert_eq!(code("listing"), Some("hub-listing-missing")); + assert_eq!(code("publisher-signature"), Some("hub-unsigned")); + assert!(report.findings.iter().all(|f| f.hint.is_some() == f.code.is_some()), "a hint exactly when a code"); + // The text report keeps its old lines and adds the fix under each. + let rendered = report.render(); + assert!(rendered.contains("[refused] digest: the bundle hashes to"), "{rendered}"); + assert!(rendered.contains("fix [hub-digest-mismatch]: run `hub stamp"), "{rendered}"); + assert!(rendered.contains("fix [hub-listing-missing]:"), "{rendered}"); + // And --json carries both fields for tools that read it. + let findings = report.json()["findings"].as_array().unwrap().clone(); + let digest = findings.iter().find(|f| f["check"] == "digest").unwrap(); + assert_eq!(digest["code"], "hub-digest-mismatch"); + assert!(digest["hint"].as_str().unwrap().contains("hub stamp")); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/app-hub/tests/component_cli.rs b/crates/app-hub/tests/component_cli.rs index 3f62f7d..9c907fc 100644 --- a/crates/app-hub/tests/component_cli.rs +++ b/crates/app-hub/tests/component_cli.rs @@ -93,7 +93,7 @@ fn check_and_pack_hold_a_release_to_its_file() { assert_eq!( report, format!( - "org.example.markdown 1.0.0 (component {}) — PASSED\n [warning] publisher-signature: unsigned: accountability rests on the hub alone\n [warning] functions (org.example.markdown-1.0.0.wasm): org.example.markdown 1.0.0 is a component that reaches the clock, random numbers and files in its app folder, but no network or other app\n", + "org.example.markdown 1.0.0 (component {}) — PASSED\n [warning] publisher-signature: unsigned: accountability rests on the hub alone\n fix [hub-unsigned]: sign before submitting: an unsigned release asks the person to trust the hub alone\n [warning] functions (org.example.markdown-1.0.0.wasm): org.example.markdown 1.0.0 is a component that reaches the clock, random numbers and files in its app folder, but no network or other app\n", blake3_hex(NOTES) ) ); diff --git a/docs/ERROR-CODES.md b/docs/ERROR-CODES.md new file mode 100644 index 0000000..e6530c7 --- /dev/null +++ b/docs/ERROR-CODES.md @@ -0,0 +1,131 @@ +# Gate error codes + +Every refusal and warning the gate prints carries a stable machine code and a +one-line fix, so a tool — an agent repairing its own bundle, a CI job — can +act on a failure without parsing English. The human message is unchanged; the +code follows it in the text report and in `--json`: + +``` +dev.example.app 1.0.0 — REFUSED + [refused] digest: the bundle hashes to 4d6f…, the manifest claims 9c1e… + fix [hub-digest-mismatch]: run `hub stamp ` after every edit (tools/octo check stamps first), then check again +``` + +```json +{"severity": "refusal", "check": "digest", "detail": "the bundle hashes to …", + "code": "hub-digest-mismatch", "hint": "run `hub stamp ` after every edit …"} +``` + +A code, once printed, never changes what it means; new codes may be added. +Match on `code`, never on `detail`. The `check` field stays as the coarser +grouping it always was. + +A few warnings are notes for a reviewer rather than a fault to repair — what +a component reaches, what it is built from, a tool that always waits for a +person. Those print as they always did, with no code. + +## Structural + +| Code | The gate means | Fix | +| --- | --- | --- | +| `hub-bundle-invalid` | The bundle cannot be read at all (`--json` only) | `manifest.json` must be present and valid JSON the manifest schema accepts (schema, id, version, name) | +| `hub-entry-invalid` | No entry the runtime can start | Add `main.splash` (a script app) or `page.card` with its kit (a card) | +| `hub-contents-invalid` | A file is not what its extension says | Repair or re-export it: UTF-8 text within the size limit, a decodable image, a sized scriptless SVG, an admissible wasm | +| `hub-resource-missing` | A card's image or font, or an SVG `href`, is not in the bundle | Ship the referenced file at that bundle-relative path, or drop the reference | +| `hub-font-invalid` | A `font_src` is not a font path, a built-in font, or one token | Set it to a bundled font path, a supported built-in font, or one token resolving to a string | +| `hub-file-type` | An extension a bundle may not hold | Remove the file, or convert it to `.card .json .l0 .octoscript .splash .svg .png .jpg .jpeg .webp .ttf .otf .txt .md` (or `fns/*.wasm`) | +| `hub-too-big` | Over the 8 MB ceiling | Compress the artwork or split the app; a bundle is cards, data and artwork only | + +## Integrity and signing + +| Code | The gate means | Fix | +| --- | --- | --- | +| `hub-digest-mismatch` | The manifest's digest is stale | `hub stamp ` after every edit (`tools/octo check` stamps first), then check again | +| `hub-signature-invalid` | The signature or GitHub attestation no longer matches the manifest | Restamp (`hub stamp`), then sign again (`hub sign-manifest`) or re-attach the attestation (`hub publisher-attach`) | +| `hub-signature-required` | This hub admits signed manifests only | Sign (`hub keygen` once, then `hub sign-manifest`) or attach GitHub provenance, or check with `--allow-unsigned` while developing | +| `hub-unsigned` | Warning: unsigned, accountability rests on the hub | Sign before submitting | + +## Identity and history + +| Code | The gate means | Fix | +| --- | --- | --- | +| `hub-id-system` | The id is under the reserved `os.` | Pick your own reverse-domain id, e.g. `dev..` | +| `hub-id-not-system` | `--system-app` with an id that is not `os.*` | Drop `--system-app` for a store app; it checks a shipped `os.*` bundle during development, nothing else | +| `hub-id-reserved` | The id (or its last segment) is a native app's or a host's name | Rename it | +| `hub-version-published` | That version is already in the catalog | Bump `version`; a published version is immutable | +| `hub-publisher-continuity` | Not signed by the publisher on record | Sign (or attest) with the identity the catalog records for this publisher | + +## Reaching outside the bundle + +| Code | The gate means | Fix | +| --- | --- | --- | +| `hub-asset-external` | A data file or script names `http://`, `file://` or `../` | Ship the asset inside the bundle and reference it bundle-relatively (`{{assets}}` in a script) | +| `hub-secret-field` | A password or one-time-code field | Remove it; the host's sheet collects secrets for the service that needs one | + +`hub-host-undeclared` is retired and stays reserved: HTTPS destinations are +declarations for reviewers, not refusals, so the gate no longer prints it. + +## Grants + +| Code | The gate means | Fix | +| --- | --- | --- | +| `hub-grants-invalid` | The manifest asks for something the contract does not offer | Check capabilities, hosts, agent and research against [PUBLISHING.md](PUBLISHING.md) | +| `hub-storage-undeclared` | Warning: `fs.*` (or a camera capture) without the `storage` capability | Declare `storage` in the manifest, or handle the fs errors and keep nothing | + +## The listing + +| Code | The gate means | Fix | +| --- | --- | --- | +| `hub-listing-missing` | No `listing.json` | Add one: description, category, screenshots, publisher, platforms ([PUBLISHING.md](PUBLISHING.md#the-listing)) | +| `hub-listing-invalid` | `listing.json` does not parse against the listing rules | Fix it against [PUBLISHING.md](PUBLISHING.md#the-listing) | +| `hub-listing-no-screenshots` | No screenshots named | Capture a PNG of the running app (`tools/octo shot`) and name it in `screenshots` | +| `hub-listing-no-icon` | No icon named | Add a square PNG or SVG and name it in `icon` | +| `hub-listing-asset-missing` | A named screenshot or icon is not in the bundle | Put the file at that path, or drop it from the listing | + +## The app's agent and tools + +| Code | The gate means | Fix | +| --- | --- | --- | +| `hub-tools-invalid` | `tools.json` breaks the tool-manifest rules | Fix it against [PUBLISHING.md](PUBLISHING.md#toolsjson-the-apps-tools): namespace, `risk`, `implemented_by`, schema | +| `hub-agent-files` | An agent file the manifest does not name, or agent files without an `agent` | Name every file in `agent.instructions` / `agent.skills`, and declare `agent` | +| `hub-skills-invalid` | A skill holds more than data | Only `.md`/`.json`/`.txt`, the manifest named as its directory, no executable fields | +| `hub-agent-review` | Any other agent-review finding | Fix what the line names against [PUBLISHING.md](PUBLISHING.md#the-apps-agent-and-tools) | + +## The app's own functions (WebAssembly) + +| Code | The gate means | Fix | +| --- | --- | --- | +| `hub-wasm-undeclared` | Warning: `fns/*.wasm` without the `wasm` capability | Add `wasm` to the capabilities, or remove the modules | +| `hub-too-many-functions` | Over the 8 modules a bundle may carry | Keep at most 8; split the app, or move shared logic into a shared component | +| `hub-wasm-missing` | Warning: `wasm` declared, no `fns/*.wasm` carried | Ship the modules the capability discloses, or drop `wasm` | +| `hub-component-requires` | A component, but the manifest does not require `wasm-components-v1` | Add it to `requires`, so hosts without components never install the app | +| `hub-component-host-api` | The component imports a host API the manifest does not require | Require it (`host_api.required`, with `requires host-api-v1`), or rebuild without that import | +| `hub-wasi-net-undeclared` | Warning: `wasi:http` or `wasi:sockets` without `net` | Disclose net usage in the capabilities | +| `hub-wasi-storage-undeclared` | Warning: `wasi:filesystem` without `storage` | Disclose storage usage in the capabilities (the jail and quota still apply) | +| `hub-component-crates` | Warning: the component does not list the crates it is built from | Rebuild with `tools/octo wasm build` (or App Flow's release workflow) | +| `hub-component-requires-unused` | Warning: requires `wasm-components-v1`, but `fns/` holds no component | Build the component into `fns/`, or drop the requirement | +| `hub-crate-advisory` | Warning (`--advisory-db`): a listed crate has a RustSec advisory | Upgrade to a fixed version and rebuild; re-run with `--advisory-db` to confirm | + +## Shared components + +| Code | The gate means | Fix | +| --- | --- | --- | +| `hub-components-unnamed` | Warning: requires `wasm-shared-components-v1`, but names no components | Name each in the manifest's `components`, or drop the requirement | +| `hub-component-bundled` | A store bundle ships `components/` | Remove it; a store app's components come from the catalog | +| `hub-component-not-manifest` | A bundled `components/*.wasm` the manifest does not name | Name the file's BLAKE3 digest in `components`, or remove the file | +| `hub-component-not-bundled` | A system app's component is not in the bundle | Ship it as `components/.wasm` | +| `hub-component-unresolved` | The catalog has no matching offered component | Fix the id, version and pinned digest against the catalog | +| `hub-component-unverified` | Warning: no `--catalog`, so the component is not resolved | Re-run check with `--catalog` to confirm it is offered and matches its digest | + +## Component releases (`hub component-check`) + +| Code | The gate means | Fix | +| --- | --- | --- | +| `hub-component-release-invalid` | The release's fields break the component rules | Fix them against [PUBLISHING.md](PUBLISHING.md), or prepare the release again (`hub component-prepare`) | +| `hub-component-too-big` | Over the 8 MB component ceiling | Trim dependencies, or split the component | +| `hub-component-digest-mismatch` | The file's digest or size is not the release's | Prepare the release again from this exact `.wasm`; both are pinned in it | +| `hub-component-invalid` | Not an admissible WebAssembly component | Rebuild with only the imports a host allows (`hub component-info` lists them) | +| `hub-component-release-stale` | The release's imports or exports are not the file's | Prepare the release again from this file | +| `hub-component-provenance-invalid` | The GitHub attestation does not match the file | Re-attest the exact file from its GitHub workflow and attach the new attestation | +| `hub-component-provenance-required` | No GitHub attestation where one is required | Attest from the release's GitHub workflow; App Hub accepts only GitHub-attested component releases | +| `hub-component-id-taken` | The component's id is an app's id in the catalog | Pick a component id no app uses | diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 3fafc7b..9b39b8a 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -12,9 +12,10 @@ English | [简体中文](PUBLISHING.zh-CN.md) | Prepare the icon | [App icons and bundled artwork](ICONS.md) | The gate is the Hub's admission check. Run it yourself with `hub check`: it -reports each broken rule as a refusal or a warning. A pass does not show that -the app renders, that its icon reads at small sizes or that its listing is -true. Reviewers check those +reports each broken rule as a refusal or a warning, each with a stable +[error code](ERROR-CODES.md) and a one-line fix a tool can act on. A pass does +not show that the app renders, that its icon reads at small sizes or that its +listing is true. Reviewers check those ([What reviewers check](SUBMITTING.md#8-what-reviewers-check)). ## Current source policy: declarations and authorization @@ -134,7 +135,11 @@ reason as one refusal under the check name `bundle-invalid`. Every other broken rule adds a report line, a **finding**, that names its check: `[refused] (): ` or `[warning] : `. `()` appears only when one file or property is at fault. A refusal stops -admission. A warning does not. +admission. A warning does not. Each finding carries a stable [error +code](ERROR-CODES.md) and a one-line fix, printed as `fix []: …` under +its line and serialized in `--json`, so a tool that repairs bundles can act on +it without parsing English. A code, once printed, never changes what it +means; new codes may be added. | Check | Refused when | Warned when | | --- | --- | --- | diff --git a/docs/PUBLISHING.zh-CN.md b/docs/PUBLISHING.zh-CN.md index 5d80231..63fa3bd 100644 --- a/docs/PUBLISHING.zh-CN.md +++ b/docs/PUBLISHING.zh-CN.md @@ -11,7 +11,7 @@ | 查询哪个 Shell 提供哪个宿主服务 | App Flow 的[宿主服务](https://github.com/OctoSense-org/OctoSense-App-Flow/blob/main/docs/HOST-SERVICES.zh-CN.md) | | 准备图标 | [应用图标与随包素材](ICONS.zh-CN.md) | -准入检查决定应用包能否进入 Hub。用 `hub check` 自行运行准入检查:每违反一条规则,它都会报告一条拒绝或警告。通过准入检查,并不能说明应用能正常渲染、图标在小尺寸下依然清晰,或商店信息属实。这些由审核人员检查([审核检查什么](SUBMITTING.zh-CN.md#8-审核检查什么))。 +准入检查决定应用包能否进入 Hub。用 `hub check` 自行运行准入检查:每违反一条规则,它都会报告一条拒绝或警告,每条都带一个稳定的[错误码](ERROR-CODES.md)(英文)和一行修复建议,供工具直接处理。通过准入检查,并不能说明应用能正常渲染、图标在小尺寸下依然清晰,或商店信息属实。这些由审核人员检查([审核检查什么](SUBMITTING.zh-CN.md#8-审核检查什么))。 ## 当前源码策略:声明与授权 @@ -99,7 +99,7 @@ my-app/ ### 检查结果 -其余规则每违反一条,报告中就多一行**检查结果**,注明所属的检查项:`[refused] (): ` 或 `[warning] : `。只有问题出在单个文件或属性上时,才会出现 `()`。拒绝会终止准入,警告不会。 +其余规则每违反一条,报告中就多一行**检查结果**,注明所属的检查项:`[refused] (): ` 或 `[warning] : `。只有问题出在单个文件或属性上时,才会出现 `()`。拒绝会终止准入,警告不会。每条检查结果还带一个稳定的[错误码](ERROR-CODES.md)(英文)和一行修复建议:文本报告中印在其下方一行 `fix []: …`,`--json` 中序列化为字段,修复应用包的工具无需解析英文即可处理。错误码一经发布,含义永不改变;只会新增错误码。 | 检查项 | 拒绝条件 | 警告条件 | | --- | --- | --- |