diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index ee8dcfb..86d383d 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -37,7 +37,7 @@ jobs: # Only the scan touches health.json, so rebasing onto the latest master # and retrying is conflict-free and makes the race unable to fail the scan. for attempt in 1 2 3 4 5; do - if git pull --rebase origin master && git push; then + if git pull --rebase origin "${GITHUB_REF_NAME}" && git push; then exit 0 fi echo "push race (attempt ${attempt}/5) — rebased onto master, retrying…" diff --git a/plugins/rain-org-health-check/roh-scan/src/graph.rs b/plugins/rain-org-health-check/roh-scan/src/graph.rs index 00fb142..0fe5841 100644 --- a/plugins/rain-org-health-check/roh-scan/src/graph.rs +++ b/plugins/rain-org-health-check/roh-scan/src/graph.rs @@ -50,12 +50,20 @@ pub struct Node { /// returns it — not `owner/name`. The scan is org-scoped, so the org is /// implicit and the bare name is the identity everything else keys on. pub repo: String, - /// The soldeer package name this repo publishes, if any — read by - /// `signals::foundry_package_name`, which accepts either table the org's - /// manifests keep release metadata in. This is what consumers name it by, so - /// it is the graph's join key: `None` drops the repo from `package_index`, - /// and every edge into it with it. + /// The soldeer package name this repo publishes, if any — resolved by + /// `signals::resolve_package_name` from the manifest's release-metadata + /// table (either spelling) or, once rainix#335 drops that table, from the + /// release workflow's `soldeer-package:` input. This is what consumers name + /// it by, so it is the graph's join key: `None` drops the repo from + /// `package_index`, and every edge into it with it. pub package: Option, + /// False when this repo evidently publishes a package (it has a + /// package-release workflow) whose name could not be read. There is then no + /// key to join on — the edges into this node are MISSING, not absent — so + /// nothing standing above it may read as standing on clear ground. The + /// package analogue of `deps_known`. `package == None` with this true is a + /// repo that genuinely publishes nothing. + pub package_known: bool, /// The newest revision of this repo's package PUBLISHED to the soldeer /// registry — the newest version a consumer can pin, and so the "latest" a /// pin is judged stale against. `None` when the repo publishes no versioned @@ -424,6 +432,7 @@ mod tests { Node { repo: repo.to_string(), package: package.map(str::to_string), + package_known: true, version: None, deps: deps .iter() @@ -442,6 +451,7 @@ mod tests { Node { repo: repo.to_string(), package: Some(package.to_string()), + package_known: true, version: Some(version.to_string()), deps: deps .iter() diff --git a/plugins/rain-org-health-check/roh-scan/src/main.rs b/plugins/rain-org-health-check/roh-scan/src/main.rs index 1f81ac3..f12055f 100644 --- a/plugins/rain-org-health-check/roh-scan/src/main.rs +++ b/plugins/rain-org-health-check/roh-scan/src/main.rs @@ -27,7 +27,7 @@ use protofire::{ counts_as_source_drift, days_between, is_stale, newest_pdf_index, source_drift, AuditAnchor, AuditPdf, CompareFile, }; -use signals::{detect_signals, foundry_package_name, RepoInputs}; +use signals::{detect_signals, RepoInputs}; use serde_json::json; use std::process::Command; @@ -349,8 +349,12 @@ fn supports_interface(session: Session, address: &str, interface_id: [u8; 4]) -> } fn fetch_inputs(org: &str, repo: &str) -> RepoInputs { - // workflows: list, then concat every *.yml/*.yaml body + // workflows: list, then concat every *.yml/*.yaml body. The package-release + // workflow's body is ALSO kept apart from the concatenation: once rainix#335 + // drops the manifest's release metadata, its `soldeer-package:` input is the + // canonical home of the package name (`signals::resolve_package_name`). let mut workflows = String::new(); + let mut release_workflow: Option = None; if let Some(names) = gh_stdout(&[ "api", &format!("repos/{org}/{repo}/contents/.github/workflows"), @@ -360,28 +364,37 @@ fn fetch_inputs(org: &str, repo: &str) -> RepoInputs { for name in names.lines() { let name = name.trim(); if name.ends_with(".yml") || name.ends_with(".yaml") { + let body = gh_file(org, repo, &format!(".github/workflows/{name}")); + if name == "package-release.yaml" || name == "package-release.yml" { + // The listing named the file, so from here on an unreadable + // body is Some("") — package UNKNOWN — never "no release + // workflow", which would read as "publishes nothing". + release_workflow = Some(body.clone()); + } workflows.push('\n'); - workflows.push_str(&gh_file(org, repo, &format!(".github/workflows/{name}"))); + workflows.push_str(&body); } } } let foundry = gh_file(org, repo, "foundry.toml"); - // One soldeer registry lookup, only when a package name exists. It answers both - // questions the scan has about the package: whether it is published at all (a - // signal), and the newest revision that exists (the ceiling the graph judges a - // dependant's pin against). Derived together from the one query so the two can - // never disagree. - let revision = foundry_package_name(&foundry).and_then(|pkg| soldeer_latest_revision(&pkg)); - let soldeer_published = revision.as_ref().map(|r| r.is_some()); - let soldeer_version = revision.flatten(); - - RepoInputs { + let mut inputs = RepoInputs { workflows, foundry, - soldeer_published, - soldeer_version, - } + release_workflow, + soldeer_published: None, + soldeer_version: None, + }; + // One soldeer registry lookup, only when a package name resolved. It answers + // both questions the scan has about the package: whether it is published at + // all (a signal), and the newest revision that exists (the ceiling the graph + // judges a dependant's pin against). Derived together from the one query so + // the two can never disagree. + let package = inputs.package(); + let revision = package.name().and_then(soldeer_latest_revision); + inputs.soldeer_published = revision.as_ref().map(|r| r.is_some()); + inputs.soldeer_version = revision.flatten(); + inputs } /// Read the audit skill's run stamp and return the whole-repo audit if present. @@ -1290,9 +1303,17 @@ struct RepoResult { /// failed (or not a Foundry repo): UNKNOWN, which the report must keep /// apart from "analyzed and clean". untested: Option, - /// This repo's soldeer package name (`signals::foundry_package_name`) — what + /// This repo's soldeer package name (`signals::resolve_package_name`: the + /// manifest's release-metadata table, or the release workflow's + /// `soldeer-package:` input once rainix#335 drops that table) — what /// consumers name it by, so it is the audit graph's join key (#71). package: Option, + /// False when the repo evidently publishes a package — it has a + /// package-release workflow — whose name could not be read: UNKNOWN, kept + /// apart from "publishes nothing" exactly as `deps_known` keeps unknown + /// deps apart from zero deps. `package == None` with this true is a real + /// non-publisher. + package_known: bool, /// The newest revision of this repo's package published to the soldeer /// registry — the newest version a consumer can pin, and so what a dependant's /// pin is judged stale against (#79). `None` when unpublished or unknown, which @@ -1330,7 +1351,10 @@ fn soldeer_latest_revision(pkg: &str) -> Option> { // the newest version a consumer could pin. let url = format!("https://api.soldeer.xyz/api/v1/revision?project_name={pkg}&offset=0&limit=1"); - let out = Command::new("curl").args(["-fsSL", &url]).output().ok()?; + let out = Command::new("curl") + .args(["-fsSL", "-m", "25", &url]) + .output() + .ok()?; if !out.status.success() { return None; } @@ -1569,10 +1593,16 @@ fn main() { (Vec::new(), false) } }; + // The graph's join key, resolved from the manifest's release-metadata + // table or — once rainix#335 drops that table — from the release + // workflow's `soldeer-package:` input. `Unknown` (a release workflow + // whose name could not be read) is kept apart from "publishes nothing". + let package = inputs.package(); RepoResult { name: repo.to_string(), org: org.clone(), - package: foundry_package_name(&inputs.foundry), + package: package.name().map(str::to_string), + package_known: package.known(), // The published revision, NOT `[package].version` from HEAD: that field // is the next, unreleased version under the org's release lifecycle, so // judging pins against it marks every consumer stale for not pinning a @@ -1784,6 +1814,7 @@ fn main() { .map(|r| graph::Node { repo: r.name.clone(), package: r.package.clone(), + package_known: r.package_known, version: r.version.clone(), deps: r.deps.clone(), deps_known: r.deps_known, @@ -1818,6 +1849,7 @@ fn main() { "repo": n.repo, "org": r.org, "package": n.package, + "packageKnown": n.package_known, "audit": n.audit.as_str(), "depsKnown": n.deps_known, // When the audit skill last ran whole-repo here, and how diff --git a/plugins/rain-org-health-check/roh-scan/src/signals.rs b/plugins/rain-org-health-check/roh-scan/src/signals.rs index df66483..3d889cc 100644 --- a/plugins/rain-org-health-check/roh-scan/src/signals.rs +++ b/plugins/rain-org-health-check/roh-scan/src/signals.rs @@ -13,10 +13,19 @@ pub struct RepoInputs { pub workflows: String, /// `foundry.toml` content ("" if absent). pub foundry: String, - /// Registry lookup for `foundry_package_name`: Some(true) published, + /// `.github/workflows/package-release.yaml` (or `.yml`) content, kept apart + /// from the concatenation: its `soldeer-package:` input is the canonical + /// home of the package name once rainix#335 drops the manifest's release + /// metadata (`resolve_package_name`). `None` when the workflow listing has + /// no such file — a repo with no release workflow publishes nothing. + /// `Some("")` when the file is listed but its body could not be read, which + /// is UNKNOWN, not absence. + pub release_workflow: Option, + /// Registry lookup for the resolved package name (`RepoInputs::package`): + /// Some(true) published, /// Some(false) unpublished, None if there is no package name or it wasn't queried. pub soldeer_published: Option, - /// The newest revision the registry has for `foundry_package_name` — the + /// The newest revision the registry has for the resolved package name — the /// newest version a consumer can actually pin, and so the ceiling a dependant's /// pin is judged stale against (#79). `None` when unpublished, unqueried, or /// the query failed; an unknown ceiling flags nobody. @@ -42,10 +51,13 @@ pub fn foundry_package_name(foundry: &str) -> Option { /// /// Both spellings are live in the org and BOTH must read, because this scanner /// reads repos it does not control and the rename is landing repo by repo. The -/// package name is the dependency graph's join key: a `None` here does not +/// package name is the dependency graph's join key: a name lost here does not /// degrade the node, it deletes it — the repo drops out of `package_index`, and /// every edge INTO it disappears with it, so its consumers read as standing on -/// clear ground. +/// clear ground. A `None` from this reader alone no longer decides that: +/// `resolve_package_name` falls through to the release workflow's +/// `soldeer-package:` input, because rainix#335 removes this table from +/// migrated manifests entirely. /// /// `[external.*]` is the tree foundry reserves for other tools' config and /// ignores, which is why the release metadata belongs there: a bare `[package]` @@ -86,6 +98,98 @@ fn foundry_package_field(foundry: &str, key: &str) -> Option { found } +/// How a repo's soldeer package name resolved. The name is the dependency +/// graph's join key, so the two ways of not having one are DIFFERENT answers +/// and must not collapse: a repo with no release lifecycle genuinely publishes +/// nothing, while a repo whose name merely could not be read still publishes a +/// package its consumers pin — dropping it from `graph::package_index` deletes +/// every edge into it and its consumers read as standing on clear ground. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PackageResolution { + /// The name, from the manifest's release-metadata table or, failing that, + /// the release workflow's `soldeer-package:` input. + Named(String), + /// A release workflow exists — the repo publishes SOMETHING — but no name + /// could be read from the manifest or the workflow. Per the same rule that + /// keeps a missing `[dependencies]` from reading as zero deps + /// (`deps_known`): this renders as UNKNOWN, never as "no package". + Unknown, + /// No release metadata and no release workflow: the repo publishes + /// nothing, and drawing no edges into it is the truth rather than a hole. + NoPackage, +} + +impl PackageResolution { + /// The resolved name, if any. + pub fn name(&self) -> Option<&str> { + match self { + PackageResolution::Named(name) => Some(name), + PackageResolution::Unknown | PackageResolution::NoPackage => None, + } + } + + /// False only for `Unknown`: a `NoPackage` repo's absence is a KNOWN fact, + /// exactly as an empty dependency list with `deps_known == true` is. + pub fn known(&self) -> bool { + !matches!(self, PackageResolution::Unknown) + } +} + +impl RepoInputs { + /// This repo's package resolution, from the content the scan fetched. + pub fn package(&self) -> PackageResolution { + resolve_package_name(&self.foundry, self.release_workflow.as_deref()) + } +} + +/// Resolve the repo's soldeer package name: the manifest's release-metadata +/// table first (both spellings, `foundry_package_name`), then the release +/// workflow's `soldeer-package:` input. +/// +/// rainix#335 drops the release-metadata table from `foundry.toml` entirely — +/// version intent moves to `next-v*` tags — so a migrated repo's manifest has +/// nothing to say about the package and the workflow input is the canonical +/// name. Both release lifecycles pass it (`rainix-autopublish` on merge to +/// main, `rainix-tag-release` on `sol-v*` tags), so the fallback covers every +/// publishing repo, migrated or not. +pub fn resolve_package_name(foundry: &str, release_workflow: Option<&str>) -> PackageResolution { + if let Some(name) = foundry_package_name(foundry) { + return PackageResolution::Named(name); + } + match release_workflow { + None => PackageResolution::NoPackage, + Some(workflow) => match release_workflow_package_name(workflow) { + Some(name) => PackageResolution::Named(name), + None => PackageResolution::Unknown, + }, + } +} + +/// Extract the `soldeer-package:` input value from a release workflow, if one +/// can be read. +/// +/// Line-anchored like the rest of the workflow readers (`detect_signals`), not +/// a YAML parse: the input is a `with:` scalar both reusable lifecycles take, +/// and the shapes that occur are a bare name, a quoted name, and a trailing +/// comment. The guards carry meaning: a commented-out line is not an input, +/// and a `${{ … }}` expression is a reference the scan cannot evaluate — +/// joining on its literal text would join nothing. Both read as "no name +/// here", which the caller keeps apart from "no workflow at all". +pub fn release_workflow_package_name(workflow: &str) -> Option { + static RE_SOLDEER_PKG: OnceLock = OnceLock::new(); + // `[ \t]` rather than `\s` around the separator: `\s` matches newlines, + // and a key with no value on its line must not swallow the next line. + let re_pkg = RE_SOLDEER_PKG.get_or_init(|| { + re(r#"(?m)^[ \t]*["']?soldeer-package["']?[ \t]*:[ \t]*("[^"\n]*"|'[^'\n]*'|[^#\s]+)"#) + }); + let raw = re_pkg.captures(workflow)?.get(1)?.as_str(); + let name = raw.trim_matches(|c| c == '"' || c == '\'').trim(); + if name.is_empty() || name.contains("${{") { + return None; + } + Some(name.to_string()) +} + /// Detect every signal present in `inputs`, in the canonical (scan.sh) order. pub fn detect_signals(inputs: &RepoInputs) -> Vec<&'static str> { static RE_REMOVED: OnceLock = OnceLock::new(); @@ -401,6 +505,7 @@ recursive_deps = false let clean = RepoInputs { workflows: "uses: rainlanguage/rainix/.github/workflows/rainix-sol-test.yaml@main\nuses: actions/checkout@v4".into(), foundry: "[profile.default]\nsrc = \"src\"".into(), + release_workflow: None, soldeer_published: Some(true), soldeer_version: Some("0.1.3".into()), }; @@ -426,4 +531,314 @@ recursive_deps = false .unwrap(); assert!(dead < installer && installer < checkout); } + + /// The real rain.factory files after rainlanguage/rain.factory#54 + /// (rainix#335): the manifest carries NO release-metadata table in either + /// spelling, and the release workflow (merge lifecycle, + /// `rainix-autopublish`) is the only thing that names the package. + const FACTORY_FOUNDRY: &str = r#" +[profile.default] +src = 'src' +test = 'test' +out = 'out' + +# See more config options https://github.com/foundry-rs/foundry/tree/master/config + +solc = "0.8.25" +optimizer = true +optimizer_runs = 100000 + +evm_version = "cancun" + +bytecode_hash = "none" +cbor_metadata = false + +libs = ["dependencies"] + +[fuzz] +runs = 2048 + +# Library repo: the ICloneable* interface surface only. The concrete CloneFactory, +# its deploy-pin snapshots and deploy scripts live in rain.factory.deploy +# (rainlanguage/rain.factory#46). Interfaces import nothing, so there are no +# Solidity dependencies beyond the test harness. +[dependencies] +forge-std = "1.16.1" + +[soldeer] +recursive_deps = false +"#; + + const FACTORY_RELEASE: &str = r#" +name: Package Release +on: + push: + branches: + - main +jobs: + release: + uses: rainlanguage/rainix/.github/workflows/rainix-autopublish.yaml@main + with: + # Library repo: the ICloneable* interface surface. `foundry.toml` version is + # the NEXT (in-development) release; on a content change on merge, autopublish + # publishes it and bumps to the next. The deploy half (concrete + deploy-pin + # snapshots) lives in rain.factory.deploy (rainlanguage/rain.factory#46). + soldeer-package: rain-factory + secrets: inherit +"#; + + /// The real rain.factory.deploy release workflow: the OTHER lifecycle + /// (`rainix-tag-release` on `sol-v*` tags), passing the same input. + const DEPLOY_RELEASE: &str = r#" +name: Package Release +# Deploy repo: a manual `sol-v*` tag is the sole release trigger. The tag names +# the version; rainix-tag-release verifies prod exists at the regenerated pins, +# publishes rain-factory-deploy to Soldeer, and commits the frozen snapshot back +# to main. The on-chain deploy is separate and manual (rainix-manual-sol-artifacts +# dispatch), run before tagging. Nothing publishes on merge, so [package].version +# and the frozen src/generated// snapshot it names only ever move together. +on: + push: + tags: + - sol-v* +jobs: + release: + uses: rainlanguage/rainix/.github/workflows/rainix-tag-release.yaml@main + with: + soldeer-package: rain-factory-deploy + snapshot-generate-cmd: forge script ./script/BuildPointers.sol && forge fmt + secrets: inherit +"#; + + /// The real rain.factory.deploy manifest: unmigrated (still carrying + /// `[package]`), pinning `rain-factory = "0.1.5"` — the consumer side of + /// the edge the 2026-08-19 scan lost. + const DEPLOY_FOUNDRY: &str = r#" +[package] +name = "rain-factory-deploy" +version = "0.1.5" + +[profile.default] +src = 'src' +test = 'test' +out = 'out' + +# See more config options https://github.com/foundry-rs/foundry/tree/master/config + +solc = "0.8.25" +optimizer = true +optimizer_runs = 100000 + +evm_version = "cancun" + +bytecode_hash = "none" +cbor_metadata = false + +# BuildPointers reads the version from foundry.toml and writes the generated +# per-tag snapshots + the current-pin lib under src/. Nothing else in this repo +# touches the filesystem. +fs_permissions = [ + { access = "read", path = "./foundry.toml" }, + { access = "read-write", path = "./src" }, +] +libs = ["dependencies"] + +[fuzz] +runs = 2048 + +[dependencies] +forge-std = "1.16.1" +"@openzeppelin-contracts" = "5.6.1" +"rain-extrospection" = "0.1.1" +"rain-deploy" = "0.1.3" +"rain-sol-codegen" = "0.1.3" +"rain-factory" = "0.1.5" + +[soldeer] +recursive_deps = false + +[rpc_endpoints] +arbitrum = "${ARBITRUM_RPC_URL}" +base = "${BASE_RPC_URL}" +base_sepolia = "${BASE_SEPOLIA_RPC_URL}" +flare = "${FLARE_RPC_URL}" +polygon = "${POLYGON_RPC_URL}" + +[etherscan] +arbitrum = { key = "${CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY}" } +base = { key = "${CI_DEPLOY_BASE_ETHERSCAN_API_KEY}" } +base_sepolia = { key = "${CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY}" } +flare = { key = "${CI_DEPLOY_FLARE_ETHERSCAN_API_KEY}" } +polygon = { key = "${CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY}" } +"#; + + /// Both live release lifecycles name the package the same way, so one + /// reader covers the org: `rainix-autopublish` on merge (rain.factory) and + /// `rainix-tag-release` on `sol-v*` tags (rain.factory.deploy), each + /// passing `soldeer-package:` to the reusable workflow. + #[test] + fn release_workflow_package_name_reads_both_lifecycles() { + assert_eq!( + release_workflow_package_name(FACTORY_RELEASE), + Some("rain-factory".to_string()) + ); + assert_eq!( + release_workflow_package_name(DEPLOY_RELEASE), + Some("rain-factory-deploy".to_string()) + ); + } + + /// What must and must not read as the input: quoting is YAML's business, + /// a trailing comment is not part of the name, a commented-out line is not + /// an input at all, and a `${{ … }}` expression is a reference the scan + /// cannot evaluate — resolving it to the literal would join nothing. + #[test] + fn release_workflow_package_name_guards() { + assert_eq!( + release_workflow_package_name(" soldeer-package: \"rain-factory\""), + Some("rain-factory".to_string()) + ); + assert_eq!( + release_workflow_package_name(" soldeer-package: 'rain-factory'"), + Some("rain-factory".to_string()) + ); + assert_eq!( + release_workflow_package_name(" soldeer-package: rain-factory # the name"), + Some("rain-factory".to_string()) + ); + assert_eq!( + release_workflow_package_name(" # soldeer-package: rain-factory"), + None + ); + assert_eq!( + release_workflow_package_name(" soldeer-package: ${{ inputs.pkg }}"), + None + ); + // a key with no value on its line must not swallow the NEXT line + assert_eq!( + release_workflow_package_name(" soldeer-package:\n other: value"), + None + ); + assert_eq!(release_workflow_package_name(""), None); + assert_eq!( + release_workflow_package_name(" soldeer-package: \"\""), + None + ); + } + + /// The regression itself, on the real post-#335 rain.factory pair: the + /// manifest alone yields no name (that is rainix#335's whole point), and + /// the resolution must come through the workflow instead of deleting the + /// node from the graph. + #[test] + fn migrated_repo_resolves_package_from_release_workflow() { + // premise: the migrated manifest genuinely has no release metadata + assert_eq!(foundry_package_name(FACTORY_FOUNDRY), None); + assert_eq!( + resolve_package_name(FACTORY_FOUNDRY, Some(FACTORY_RELEASE)), + PackageResolution::Named("rain-factory".to_string()) + ); + let inputs = RepoInputs { + foundry: FACTORY_FOUNDRY.into(), + release_workflow: Some(FACTORY_RELEASE.into()), + ..Default::default() + }; + assert_eq!( + inputs.package(), + PackageResolution::Named("rain-factory".to_string()) + ); + } + + /// An unmigrated repo keeps resolving from the manifest — the workflow is + /// the fallback, not a second authority — and an unparseable manifest does + /// not block the workflow answer. + #[test] + fn manifest_name_resolves_first_when_present() { + assert_eq!( + resolve_package_name( + "[package]\nname = \"rain-factory-deploy\"\nversion = \"0.1.5\"", + Some(DEPLOY_RELEASE) + ), + PackageResolution::Named("rain-factory-deploy".to_string()) + ); + assert_eq!( + resolve_package_name("[package]\nname = ", Some(FACTORY_RELEASE)), + PackageResolution::Named("rain-factory".to_string()) + ); + // sources that DISAGREE pin the ruled order: the manifest speaks first, + // and the workflow only when the manifest lacks the name (rainix#335 + // ruling: "resolve from there when foundry.toml lacks the name"). + assert_eq!( + resolve_package_name("[package]\nname = \"manifest-name\"", Some(FACTORY_RELEASE)), + PackageResolution::Named("manifest-name".to_string()) + ); + } + + /// Per the repo's own rule that a missing `[dependencies]` must not read + /// as zero deps: a release workflow that exists but yields no name is + /// UNKNOWN, never "publishes nothing". Only a repo with no release + /// workflow at all is a known non-publisher. + #[test] + fn unreadable_release_workflow_is_unknown_never_no_package() { + // listed but unreadable body + assert_eq!( + resolve_package_name("", Some("")), + PackageResolution::Unknown + ); + // readable but passing only an expression the scan cannot evaluate + assert_eq!( + resolve_package_name( + "", + Some("jobs:\n release:\n with:\n soldeer-package: ${{ inputs.pkg }}\n") + ), + PackageResolution::Unknown + ); + assert!(!resolve_package_name("", Some("")).known()); + assert_eq!(resolve_package_name("", Some("")).name(), None); + // no release workflow, no metadata: a real non-publisher, and KNOWN + assert_eq!(resolve_package_name("", None), PackageResolution::NoPackage); + assert!(resolve_package_name("", None).known()); + assert_eq!(resolve_package_name("", None).name(), None); + } + + /// End to end over the real file shapes on both sides: rain.factory's + /// package resolves through its release workflow, so the + /// `rain.factory.deploy -> rain.factory` edge that vanished from the + /// 2026-08-19 scan (the manifest's release metadata was dropped by + /// rainlanguage/rain.factory#54) is drawn again. + #[test] + fn workflow_resolved_package_restores_the_consumer_edge() { + use crate::graph::{graph_edges, Node}; + use crate::protofire::ExternalAudit; + + let resolved = resolve_package_name(FACTORY_FOUNDRY, Some(FACTORY_RELEASE)); + let factory = Node { + repo: "rain.factory".to_string(), + package: resolved.name().map(str::to_string), + package_known: resolved.known(), + version: None, + deps: crate::graph::foundry_dependencies(FACTORY_FOUNDRY).unwrap(), + deps_known: true, + audit: ExternalAudit::Never, + }; + let deploy = Node { + repo: "rain.factory.deploy".to_string(), + package: resolve_package_name(DEPLOY_FOUNDRY, Some(DEPLOY_RELEASE)) + .name() + .map(str::to_string), + package_known: true, + version: None, + deps: crate::graph::foundry_dependencies(DEPLOY_FOUNDRY).unwrap(), + deps_known: true, + audit: ExternalAudit::Never, + }; + let edges = graph_edges(&[factory, deploy]).unwrap(); + assert!( + edges + .iter() + .any(|e| e.from == "rain.factory.deploy" && e.to == "rain.factory"), + "the rain.factory.deploy -> rain.factory edge must survive the manifest \ + losing its release metadata: {edges:?}" + ); + } } diff --git a/plugins/rain-org-health-check/skills/rain-org-health-check/SKILL.md b/plugins/rain-org-health-check/skills/rain-org-health-check/SKILL.md index 438a2db..25e72cb 100644 --- a/plugins/rain-org-health-check/skills/rain-org-health-check/SKILL.md +++ b/plugins/rain-org-health-check/skills/rain-org-health-check/SKILL.md @@ -160,7 +160,7 @@ the issue's filing date.** | `per-chain-etherscan-key` | foundry.toml/workflow uses `CI_DEPLOY__ETHERSCAN_API_KEY` | Etherscan V2 is one multichain key — consolidate to `EXPLORER_VERIFICATION_KEY`. Keep flare/songbird separate (Routescan/Blockscout, not Etherscan). | | `telegram-secret-drift` | uses `TG_TOKEN`/`TG_CHAT_ID` | standardize on `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` (the org convention). | | `old-actions-checkout` / `old-nix-installer` | pinned to deprecated action versions | bump `actions/checkout` to v4+, prefer `nixbuild/nix-quick-install-action`. | -| `soldeer-unpublished` | foundry.toml has a `[package]` but no revision on the soldeer registry | a publishable package never got pushed — wire `rainix-autopublish` (+ `[package].version`), add a `.soldeerignore` (publish only `src/` + license/readme; soldeer's sensitive-file prompt otherwise hangs CI), and have an org admin create the project on soldeer.xyz before the first push. | +| `soldeer-unpublished` | the repo names a soldeer package (release-metadata table, or `package-release.yaml`/`.yml`'s `soldeer-package:` input after rainix#335) but no revision is on the registry | a publishable package never got pushed — wire `rainix-autopublish` (+ `[package].version`), add a `.soldeerignore` (publish only `src/` + license/readme; soldeer's sensitive-file prompt otherwise hangs CI), and have an org admin create the project on soldeer.xyz before the first push. | | `deprecated-interface` | Solidity imports a deprecated rain interpreter interface (V2/V3-era) — `IInterpreterV2`, `IInterpreterCallerV2`, `IInterpreterStoreV2`, `IExpressionDeployerV3`, `EvaluableConfigV3`/`EvaluableV2`, `LibEncodedDispatch`, `.eval2(`, `deployExpression2`, or any `rain.interpreter.interface/.../deprecated/` path | migrate to the current V4 API: `IInterpreterV4.eval4(EvalV4{...})` with `EvaluableV4{interpreter,store,bytecode}` (no expression deployment / encoded dispatch), `StackItem`/`bytes32[]`, eval-time validation. Follow the upstream `RaindexV6`/`LibRaindex` caller pattern. Worked example: flow#474. | | `soldeer-skip-warnings` | a workflow runs `forge soldeer push` with `--skip-warnings` | **Never** skip soldeer publish warnings — they're the guard that catches accidentally publishing sensitive files (`.env`, keys, `.git`, build dirs) into the package. Remove `--skip-warnings` and scope the publish with a `.soldeerignore` (publish only `src/` + license/readme) so the push succeeds in CI _without_ suppressing the warning. | | `untested-externals` | a concrete contract declares external/public function(s) whose name appears in NO test source (see "Untested external surface" below) | write tests exercising each flagged function directly (the flagged list is per contract/function in `health.json`'s `untestedExternals` and the text report). Worked example: rain.math.float#156 → #169. Confirm each is a real gap first — the grep already suppresses any test that so much as names the function. | diff --git a/site/audit.html b/site/audit.html index ce30150..199c79f 100644 --- a/site/audit.html +++ b/site/audit.html @@ -770,6 +770,14 @@

External audit coverage · Protofire

// look like it does. box.append(el("span", "gn-warn", "deps unknown — manifest or tree unreadable")); } + if (n.packageKnown === false) { + // The repo publishes a package — it has a package-release workflow — whose + // NAME could not be read (rainix#335 moved the name out of foundry.toml + // into the workflow's soldeer-package input). With no name to join on, + // every edge INTO this node is missing, so its consumers may read as + // standing on clear ground. UNKNOWN, never "publishes nothing". + box.append(el("span", "gn-warn", "package unknown — release metadata unavailable; edges into this repo may be missing")); + } const stale = n.staleDeps ?? []; if (stale.length) { // Pins a dependency below its current version, so this repo's real closure diff --git a/site/health.json b/site/health.json index 4bff197..375acfb 100644 --- a/site/health.json +++ b/site/health.json @@ -1,11 +1,12 @@ { - "generatedAt": "2026-08-20T13:14:44Z", + "generatedAt": "2026-08-20T12:22:47Z", "auditGraph": { "nodes": [ { "repo": "cyclo.sol", "org": "cyclofinance", "package": null, + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, @@ -18,6 +19,7 @@ "repo": "flow", "org": "rainlanguage", "package": null, + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, @@ -29,7 +31,8 @@ { "repo": "rain.datacontract", "org": "rainlanguage", - "package": null, + "package": "rain-datacontract", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": null, @@ -49,13 +52,16 @@ "repo": "rain.dia", "org": "rainlanguage", "package": null, + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, "openAuditIssues": 6, "lastMutation": null, "blockedBy": [ + "rain.datacontract", "rain.deploy", + "rain.extrospection", "rain.lib.memkv", "rain.math.float", "rain.metadata", @@ -97,6 +103,7 @@ "repo": "rain.erc1820", "org": "rainlanguage", "package": null, + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, @@ -109,13 +116,16 @@ "repo": "rain.erc4626.words", "org": "rainlanguage", "package": null, + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, "openAuditIssues": 11, "lastMutation": null, "blockedBy": [ + "rain.datacontract", "rain.deploy", + "rain.extrospection", "rain.lib.memkv", "rain.math.float", "rain.metadata", @@ -146,7 +156,8 @@ { "repo": "rain.extrospection", "org": "rainlanguage", - "package": null, + "package": "rain-extrospection", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": null, @@ -166,6 +177,7 @@ "repo": "rain.extrospection.deploy", "org": "rainlanguage", "package": "rain-extrospection-deploy", + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, @@ -173,14 +185,23 @@ "lastMutation": null, "blockedBy": [ "rain.deploy", - "rain.sol.codegen" + "rain.extrospection", + "rain.sol.codegen", + "rain.solmem" ], - "staleDeps": [] + "staleDeps": [ + { + "repo": "rain.extrospection", + "pinned": "0.1.6", + "latest": "0.1.13" + } + ] }, { "repo": "rain.factory", "org": "rainlanguage", - "package": null, + "package": "rain-factory", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": null, @@ -193,6 +214,7 @@ "repo": "rain.factory.deploy", "org": "rainlanguage", "package": "rain-factory-deploy", + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, @@ -200,7 +222,10 @@ "lastMutation": null, "blockedBy": [ "rain.deploy", - "rain.sol.codegen" + "rain.extrospection", + "rain.factory", + "rain.sol.codegen", + "rain.solmem" ], "staleDeps": [ { @@ -208,6 +233,16 @@ "pinned": "0.1.3", "latest": "0.1.7" }, + { + "repo": "rain.extrospection", + "pinned": "0.1.1", + "latest": "0.1.13" + }, + { + "repo": "rain.factory", + "pinned": "0.1.5", + "latest": "0.1.6" + }, { "repo": "rain.sol.codegen", "pinned": "0.1.3", @@ -219,13 +254,16 @@ "repo": "rain.flare", "org": "rainlanguage", "package": "rain-flare", + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, "openAuditIssues": 14, "lastMutation": null, "blockedBy": [ + "rain.datacontract", "rain.deploy", + "rain.extrospection", "rain.lib.memkv", "rain.math.float", "rain.metadata", @@ -257,6 +295,7 @@ "repo": "rain.intorastring", "org": "rainlanguage", "package": "rain-intorastring", + "packageKnown": true, "audit": "current", "depsKnown": true, "lastAudit": null, @@ -277,6 +316,7 @@ "repo": "rain.lib.hash", "org": "rainlanguage", "package": "rain-lib-hash", + "packageKnown": true, "audit": "current", "depsKnown": true, "lastAudit": null, @@ -289,6 +329,7 @@ "repo": "rain.lib.memkv", "org": "rainlanguage", "package": "rain-lib-memkv", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": null, @@ -309,6 +350,7 @@ "repo": "rain.lib.typecast", "org": "rainlanguage", "package": "rain-lib-typecast", + "packageKnown": true, "audit": "current", "depsKnown": true, "lastAudit": null, @@ -321,6 +363,7 @@ "repo": "rain.math.fixedpoint", "org": "rainlanguage", "package": "rain-math-fixedpoint", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": null, @@ -342,18 +385,25 @@ "repo": "rain.math.float", "org": "rainlanguage", "package": "rain-math-float", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": null, "openAuditIssues": 2, "lastMutation": null, "blockedBy": [ + "rain.datacontract", "rain.deploy", "rain.sol.codegen", "rain.solmem", "rain.string" ], "staleDeps": [ + { + "repo": "rain.datacontract", + "pinned": "0.1.0", + "latest": "0.1.3" + }, { "repo": "rain.deploy", "pinned": "0.1.3", @@ -375,6 +425,7 @@ "repo": "rain.math.saturating", "org": "rainlanguage", "package": "rain-math-saturating", + "packageKnown": true, "audit": "current", "depsKnown": true, "lastAudit": null, @@ -392,13 +443,16 @@ "repo": "rain.merkle", "org": "rainlanguage", "package": null, + "packageKnown": true, "audit": "na", "depsKnown": true, "lastAudit": null, "openAuditIssues": 0, "lastMutation": null, "blockedBy": [ + "rain.datacontract", "rain.deploy", + "rain.extrospection", "rain.lib.memkv", "rain.math.float", "rain.metadata", @@ -425,6 +479,7 @@ "repo": "rain.metadata", "org": "rainlanguage", "package": "rain-metadata", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": null, @@ -446,13 +501,16 @@ "repo": "rain.pyth", "org": "rainlanguage", "package": "rain-pyth", + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, "openAuditIssues": 0, "lastMutation": null, "blockedBy": [ + "rain.datacontract", "rain.deploy", + "rain.extrospection", "rain.lib.memkv", "rain.math.float", "rain.metadata", @@ -484,6 +542,7 @@ "repo": "rain.sol.binmaskflag", "org": "rainlanguage", "package": "rain-sol-binmaskflag", + "packageKnown": true, "audit": "current", "depsKnown": true, "lastAudit": null, @@ -496,6 +555,7 @@ "repo": "rain.solver", "org": "rainlanguage", "package": null, + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, @@ -508,6 +568,7 @@ "repo": "rain.string", "org": "rainlanguage", "package": "rain-string", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": null, @@ -522,6 +583,7 @@ "repo": "rain.tier.interface", "org": "rainlanguage", "package": null, + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, @@ -534,6 +596,7 @@ "repo": "rain.tofu.erc20-decimals", "org": "rainlanguage", "package": "rain-tofu-erc20-decimals", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": null, @@ -541,13 +604,20 @@ "lastMutation": null, "blockedBy": [ "rain.deploy", - "rain.sol.codegen" + "rain.extrospection", + "rain.sol.codegen", + "rain.solmem" ], "staleDeps": [ { "repo": "rain.deploy", "pinned": "0.1.3", "latest": "0.1.7" + }, + { + "repo": "rain.extrospection", + "pinned": "0.1.1", + "latest": "0.1.13" } ] }, @@ -555,13 +625,17 @@ "repo": "rain.vats", "org": "rainlanguage", "package": "rain-vats", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": null, "openAuditIssues": 0, "lastMutation": null, "blockedBy": [ + "rain.datacontract", "rain.deploy", + "rain.extrospection", + "rain.factory", "rain.math.fixedpoint", "rain.math.float", "rain.sol.codegen", @@ -569,6 +643,16 @@ "rain.string" ], "staleDeps": [ + { + "repo": "rain.extrospection", + "pinned": "0.1.1", + "latest": "0.1.13" + }, + { + "repo": "rain.factory", + "pinned": "0.1.5", + "latest": "0.1.6" + }, { "repo": "rain.math.fixedpoint", "pinned": "0.2.0", @@ -585,13 +669,17 @@ "repo": "rain.vats.flare", "org": "rainlanguage", "package": "rain-vats-flare", + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, "openAuditIssues": 0, "lastMutation": null, "blockedBy": [ + "rain.datacontract", "rain.deploy", + "rain.extrospection", + "rain.factory", "rain.flare", "rain.lib.memkv", "rain.math.fixedpoint", @@ -621,6 +709,7 @@ "repo": "rain.verify", "org": "rainlanguage", "package": null, + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, @@ -628,6 +717,7 @@ "lastMutation": null, "blockedBy": [ "rain.deploy", + "rain.factory", "rain.sol.codegen", "rain.solmem" ], @@ -637,6 +727,11 @@ "pinned": "0.1.2", "latest": "0.1.7" }, + { + "repo": "rain.factory", + "pinned": "0.1.1", + "latest": "0.1.6" + }, { "repo": "rain.sol.codegen", "pinned": "0.1.0", @@ -653,6 +748,7 @@ "repo": "rain.verify.interface", "org": "rainlanguage", "package": "rain-verify-interface", + "packageKnown": true, "audit": "current", "depsKnown": true, "lastAudit": null, @@ -665,6 +761,7 @@ "repo": "rain.will-overflow", "org": "rainlanguage", "package": null, + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, @@ -677,6 +774,7 @@ "repo": "raindex", "org": "rainlanguage", "package": "raindex", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": null, @@ -688,7 +786,9 @@ "scope": "RaindexV6.clear3 + calculateClearStateChange + calculateClearStateAlice (bounty, NegativeBounty guard, record ordering, bounty crediting, ClearZeroAmount, AfterClearV2, ClearV3, same-owner/token/self-trade guards, dead-order)" }, "blockedBy": [ + "rain.datacontract", "rain.deploy", + "rain.extrospection", "rain.lib.memkv", "rain.math.float", "rain.metadata", @@ -737,12 +837,14 @@ "repo": "raindex.governance", "org": "rainlanguage", "package": "raindex-governance", + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, "openAuditIssues": 2, "lastMutation": null, "blockedBy": [ + "rain.datacontract", "rain.deploy", "rain.math.float", "rain.sol.codegen", @@ -768,12 +870,14 @@ "repo": "raindex.interface", "org": "rainlanguage", "package": "raindex-interface", + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, "openAuditIssues": 0, "lastMutation": null, "blockedBy": [ + "rain.datacontract", "rain.deploy", "rain.math.float", "rain.sol.codegen", @@ -793,13 +897,16 @@ "repo": "rainlang", "org": "rainlanguage", "package": "rainlang", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": null, "openAuditIssues": 0, "lastMutation": null, "blockedBy": [ + "rain.datacontract", "rain.deploy", + "rain.extrospection", "rain.lib.memkv", "rain.math.float", "rain.metadata", @@ -814,6 +921,11 @@ "pinned": "0.1.3", "latest": "0.1.7" }, + { + "repo": "rain.extrospection", + "pinned": "0.1.1", + "latest": "0.1.13" + }, { "repo": "rain.lib.memkv", "pinned": "0.1.0", @@ -845,6 +957,7 @@ "repo": "rainlang.interface", "org": "rainlanguage", "package": "rainlang-interface", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": null, @@ -871,6 +984,7 @@ "repo": "st0x-timelock-deploy", "org": "S01-Issuer", "package": null, + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": null, @@ -883,13 +997,17 @@ "repo": "st0x.deploy", "org": "S01-Issuer", "package": "st0x-deploy", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": null, "openAuditIssues": 0, "lastMutation": null, "blockedBy": [ + "rain.datacontract", "rain.deploy", + "rain.extrospection", + "rain.factory", "rain.math.fixedpoint", "rain.math.float", "rain.sol.codegen", @@ -904,6 +1022,16 @@ "pinned": "0.1.4", "latest": "0.1.7" }, + { + "repo": "rain.extrospection", + "pinned": "0.1.1", + "latest": "0.1.13" + }, + { + "repo": "rain.factory", + "pinned": "0.1.1", + "latest": "0.1.6" + }, { "repo": "rain.math.float", "pinned": "0.1.1", @@ -925,6 +1053,7 @@ "repo": "rain.math.binary", "org": "rainlanguage", "package": "rain-math-binary", + "packageKnown": true, "audit": "current", "depsKnown": true, "lastAudit": { @@ -947,6 +1076,7 @@ "repo": "rain.solmem", "org": "rainlanguage", "package": "rain-solmem", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": { @@ -968,7 +1098,8 @@ { "repo": "st0x.oracle", "org": "ST0x-Technology", - "package": "st0x-oracle", + "package": null, + "packageKnown": true, "audit": "never", "depsKnown": true, "lastAudit": { @@ -985,7 +1116,10 @@ "scope": "whole repo (src/ + script/)" }, "blockedBy": [ + "rain.datacontract", "rain.deploy", + "rain.extrospection", + "rain.factory", "rain.math.fixedpoint", "rain.math.float", "rain.sol.codegen", @@ -996,6 +1130,11 @@ "st0x.deploy" ], "staleDeps": [ + { + "repo": "rain.factory", + "pinned": "0.1.1", + "latest": "0.1.6" + }, { "repo": "rain.math.float", "pinned": "0.1.1", @@ -1017,6 +1156,7 @@ "repo": "rain.deploy", "org": "rainlanguage", "package": "rain-deploy", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": { @@ -1041,6 +1181,7 @@ "repo": "rain.sol.codegen", "org": "rainlanguage", "package": "rain-sol-codegen", + "packageKnown": true, "audit": "stale", "depsKnown": true, "lastAudit": { @@ -1187,6 +1328,13 @@ "pinned": "0.1.7", "latest": "0.1.7" }, + { + "from": "rain.extrospection.deploy", + "to": "rain.extrospection", + "stale": true, + "pinned": "0.1.6", + "latest": "0.1.13" + }, { "from": "rain.factory.deploy", "to": "rain.deploy", @@ -1194,6 +1342,20 @@ "pinned": "0.1.3", "latest": "0.1.7" }, + { + "from": "rain.factory.deploy", + "to": "rain.extrospection", + "stale": true, + "pinned": "0.1.1", + "latest": "0.1.13" + }, + { + "from": "rain.factory.deploy", + "to": "rain.factory", + "stale": true, + "pinned": "0.1.5", + "latest": "0.1.6" + }, { "from": "rain.factory.deploy", "to": "rain.sol.codegen", @@ -1278,6 +1440,13 @@ "pinned": "0.2.0", "latest": "0.2.0" }, + { + "from": "rain.math.float", + "to": "rain.datacontract", + "stale": true, + "pinned": "0.1.0", + "latest": "0.1.3" + }, { "from": "rain.math.float", "to": "rain.deploy", @@ -1397,6 +1566,27 @@ "pinned": "0.1.3", "latest": "0.1.7" }, + { + "from": "rain.tofu.erc20-decimals", + "to": "rain.extrospection", + "stale": true, + "pinned": "0.1.1", + "latest": "0.1.13" + }, + { + "from": "rain.vats", + "to": "rain.extrospection", + "stale": true, + "pinned": "0.1.1", + "latest": "0.1.13" + }, + { + "from": "rain.vats", + "to": "rain.factory", + "stale": true, + "pinned": "0.1.5", + "latest": "0.1.6" + }, { "from": "rain.vats", "to": "rain.math.fixedpoint", @@ -1453,6 +1643,13 @@ "pinned": "0.1.2", "latest": "0.1.7" }, + { + "from": "rain.verify", + "to": "rain.factory", + "stale": true, + "pinned": "0.1.1", + "latest": "0.1.6" + }, { "from": "rain.verify", "to": "rain.sol.codegen", @@ -1572,6 +1769,13 @@ "pinned": "0.1.3", "latest": "0.1.7" }, + { + "from": "rainlang", + "to": "rain.extrospection", + "stale": true, + "pinned": "0.1.1", + "latest": "0.1.13" + }, { "from": "rainlang", "to": "rain.intorastring", @@ -1684,6 +1888,20 @@ "pinned": "0.1.4", "latest": "0.1.7" }, + { + "from": "st0x.deploy", + "to": "rain.extrospection", + "stale": true, + "pinned": "0.1.1", + "latest": "0.1.13" + }, + { + "from": "st0x.deploy", + "to": "rain.factory", + "stale": true, + "pinned": "0.1.1", + "latest": "0.1.6" + }, { "from": "st0x.deploy", "to": "rain.math.float", @@ -1719,6 +1937,13 @@ "pinned": "0.1.0", "latest": "0.1.0" }, + { + "from": "st0x.oracle", + "to": "rain.factory", + "stale": true, + "pinned": "0.1.1", + "latest": "0.1.6" + }, { "from": "st0x.oracle", "to": "rain.math.float", @@ -4850,7 +5075,7 @@ "latestTagIso": null, "isStale": null, "sourceLocChangedSinceAudit": null, - "fullSourceLoc": 2203, + "fullSourceLoc": 2076, "sourceLocAddedSinceAudit": null, "sourceLocRemovedSinceAudit": null, "filesChangedSinceAudit": null, @@ -6251,7 +6476,7 @@ "org": "ST0x-Technology", "state": "analyzed", "externalFunctions": 31, - "testFiles": 32, + "testFiles": 30, "sourcesUnparsed": 0, "untested": [] } diff --git a/test/dashboard.test.js b/test/dashboard.test.js index 33a786c..ea48ade 100644 --- a/test/dashboard.test.js +++ b/test/dashboard.test.js @@ -519,6 +519,38 @@ Deno.test("graph node: a never-run skill says so, and an unknown backlog is not ); }); +Deno.test("graph node: an unresolvable package renders as UNKNOWN, not as no-package", () => { + // rainix#335 moves the soldeer package name out of foundry.toml into the + // release workflow's `soldeer-package:` input. A workflow that exists but + // yields no name means edges INTO this node may be missing — its consumers + // can read as standing on clear ground — and the node must say so, the same + // way depsKnown keeps unknown deps apart from zero deps. + const box = graphNode( + { repo: "migrated", org: "o", audit: "never", depsKnown: true, packageKnown: false }, + null, + ); + const t = textOf(box); + assert(t.includes("package unknown"), "must flag the unreadable package: " + t); + // a resolved (or genuinely absent) package renders no flag… + const known = graphNode( + { repo: "plain", org: "o", audit: "never", depsKnown: true, packageKnown: true }, + null, + ); + assert( + !textOf(known).includes("package unknown"), + "a known package renders no flag: " + textOf(known), + ); + // …and neither does old health.json data without the field. + const oldData = graphNode( + { repo: "old", org: "o", audit: "never", depsKnown: true }, + null, + ); + assert( + !textOf(oldData).includes("package unknown"), + "pre-field data renders no flag: " + textOf(oldData), + ); +}); + // A backlog of open audit findings is a DEFECT the repo is still carrying, not a // status line, so the count renders in the dashboard's semantic critical token — // the same `--crit` the pipeline's rising-WIP flag uses. And never on colour alone.