Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b3bfa62
fix(finishingbot,glambot): accept estate canon, stop reporting retire…
HoTT-stuff Nov 12, 2026
ff2ebc3
fix(finishingbot,glambot): resolve Codex review findings on #669
HoTT-stuff Nov 13, 2026
ab3fb5c
fix(glambot): a stripped JSONC block comment leaves whitespace, not n…
HoTT-stuff Nov 13, 2026
ee5464d
fix(finishingbot): AWS secret pattern terminates on any non-key char …
HoTT-stuff Nov 13, 2026
383dab9
fix(finishingbot): a mise manifest must pin the project's tools, like…
HoTT-stuff Nov 13, 2026
efa407d
fix(finishingbot): a /* inside a // comment or string does not open a…
HoTT-stuff Nov 13, 2026
86e5853
docs(glambot): MACH-006 is not auto-fixed; guard it with a test
HoTT-stuff Nov 13, 2026
7c17322
fix(finishingbot): REL-008 detects unarmored private keys and JSON-qu…
HoTT-stuff Nov 13, 2026
07d6582
fix(glambot): JSONC lone commas are invalid; detect JSONC from repo-r…
HoTT-stuff Nov 13, 2026
4e3cf60
test(finishingbot): assemble REL-008 fixtures from short fragments
HoTT-stuff Nov 13, 2026
8339d21
fix(finishingbot): workflow fallback only for workflow-qualified refe…
HoTT-stuff Nov 13, 2026
f1bfab4
fix(finishingbot): TEST-RU-003 requires a recognisable fuzz harness
HoTT-stuff Nov 13, 2026
fb96576
fix(glambot): report an unavailable git-seo as skipped, not as a clea…
HoTT-stuff Nov 13, 2026
deaca1f
fix(finishingbot): read .hypatia-baseline.json, not .hypatia-ignore, …
HoTT-stuff Nov 13, 2026
1ae0bd5
fix(finishingbot): baseline file_pattern follows gitignore anchoring
HoTT-stuff Nov 13, 2026
e3d1c09
fix(finishingbot): TEST-RU-003 needs real fuzz harness evidence, not …
HoTT-stuff Nov 13, 2026
c56bc43
docs(finishingbot,glambot): SCM files are validated only if present
HoTT-stuff Nov 13, 2026
a724b6c
style(finishingbot,glambot): cargo fmt
HoTT-stuff Nov 13, 2026
2fad4f1
Merge origin/main into fix/finishingbot-estate-canon
HoTT-stuff Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion bots/finishingbot/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ the gitbot-fleet shared context layer and makes the final release-readiness dete
|No

|**SCM Files**
|All 6 `.machine_readable/` files (STATE.scm, META.scm, ECOSYSTEM.scm, PLAYBOOK.scm, AGENTIC.scm, NEUROSYM.scm), SPDX headers, required sections, content completeness
|Legacy `.machine_readable/` SCM files (STATE.scm, META.scm, ECOSYSTEM.scm, PLAYBOOK.scm, AGENTIC.scm, NEUROSYM.scm) are validated only if present (SPDX headers, required sections, content completeness); their absence is canonical, since those descriptiles are now clauses of `<name>_chora.deed`
|No

|**Testing**
Expand Down Expand Up @@ -171,6 +171,19 @@ finishing-bot release
finishing-bot security --panic-attacker-path /path/to/panic-attacker
----

=== Estate canon and exemptions

The analyzers accept the estate's canonical locations, not only one spelling:

* `ROADMAP.adoc` at `docs/status/ROADMAP.adoc` (or the root); `SECURITY`, `CONTRIBUTING` and `CODE_OF_CONDUCT` as `.md` or `.adoc`, at the root or in `.github/` (V1-DOC, V1-SEC-001, V1-COM, REL-004).
* `mise.toml` in place of `.tool-versions` (TOOL-001).
* `tests/fuzz/` as well as `fuzz/` and `.clusterfuzzlite/` (TEST-RU-003); a crate with no code yet (no `.rs` line that is not blank or a comment) is exempt from TEST-RU-005 and TEST-RU-006.
* A workflow named by file name in prose resolves under `.github/workflows/` (CLM-006).
* SCM-001 is retired: STATE, META, ECOSYSTEM, PLAYBOOK, AGENTIC and NEUROSYM are clauses of `<name>_chora.deed`, not files.
Comment thread
hyperpolymath marked this conversation as resolved.
Resolved
* REL-008 matches key material (PEM headers, AWS key ids, vendor token shapes, long key literals), not the words "private key".

A repository can acknowledge a finding in its `.hypatia-baseline.json`, the estate's documented exemption mechanism (`docs/upstream-proposals/standards--hypatia-baseline-consumer/docs/EXEMPTION-MECHANISMS.adoc`, "Layer 2"). `.hypatia-ignore` is not read: that document rejects it. An entry uses this bot's `rule_module`, `finishing_bot`, and the finding id as `type`, with the estate severity word (error = `high`, warning = `medium`, info = `info`, suggestion = `low`) and exactly one of `file` or `file_pattern`: `{"severity": "medium", "rule_module": "finishing_bot", "type": "PH-001", "file_pattern": "build/just/**", "note": "scanner names the tokens"}`. Matching is exact on severity, module and type, so a rule family needs one entry per id. Entries past `expires_at`, or carrying `severity_override` (this bot does not downgrade), acknowledge nothing; other modules' entries (`cicd_rules`, ...) are Hypatia's and are not read. Currently only the placeholder analyzer (PH-nnn, hence PH-100) honours it. See `src/baseline.rs`.

=== Security Analysis

finishing-bot integrates with `panic-attacker` for security validation:
Expand Down
16 changes: 15 additions & 1 deletion bots/finishingbot/src/analyzers/claims.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ static PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
.expect("Cargo workspace member-path regex is a valid constant pattern")
});

/// The line of `content` containing byte offset `at` (prose context for a reference).
fn line_of(content: &str, at: usize) -> &str {
let start = content[..at].rfind('\n').map_or(0, |i| i + 1);
let end = content[at..].find('\n').map_or(content.len(), |i| at + i);
&content[start..end]
}

/// Claim verification analyzer
pub struct ClaimsAnalyzer;

Expand Down Expand Up @@ -297,7 +304,14 @@ impl ClaimsAnalyzer {
for pattern in &file_ref_patterns {
for caps in pattern.captures_iter(content) {
let file_ref = &caps[1];
if resolve_local_reference(repo_root, doc_path, file_ref).is_none() {
if resolve_local_reference(
repo_root,
doc_path,
file_ref,
line_of(content, caps.get(0).map_or(0, |m| m.start())),
)
.is_none()
{
debug!(doc = %doc_path.display(), reference = file_ref, "Broken file reference");
result.add(
Finding::new(
Expand Down
27 changes: 18 additions & 9 deletions bots/finishingbot/src/analyzers/license.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ use crate::config::Config;
use crate::error::Result;
use regex::Regex;
use std::path::Path;
use std::sync::LazyLock;
use tracing::debug;
use walkdir::WalkDir;
use std::sync::LazyLock;

// Compiled once. These were rebuilt on every call (hypatia expect_in_hot_path).
static SPDX_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)SPDX-License-Identifier:\s*[A-Za-z0-9.-]+").expect("SPDX header regex is a valid constant pattern"));
static SPDX_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)SPDX-License-Identifier:\s*[A-Za-z0-9.-]+")
.expect("SPDX header regex is a valid constant pattern")
});

/// Repositories that deliberately use AGPL-3.0-or-later instead of PMPL.
/// These are co-developed projects with the user's son and use AGPL by deliberate choice.
Expand Down Expand Up @@ -100,9 +102,7 @@ impl LicenseAnalyzer {
"COPYING.md",
];

let found_license = license_files
.iter()
.find(|name| path.join(name).exists());
let found_license = license_files.iter().find(|name| path.join(name).exists());

match found_license {
None => {
Expand Down Expand Up @@ -207,15 +207,19 @@ impl LicenseAnalyzer {
return Some("GPL-3.0-or-later".to_string());
}

if content_lower.contains("mit license") || content_lower.contains("permission is hereby granted, free of charge") {
if content_lower.contains("mit license")
|| content_lower.contains("permission is hereby granted, free of charge")
{
return Some("MIT".to_string());
}

if content_lower.contains("apache license") && content_lower.contains("version 2.0") {
return Some("Apache-2.0".to_string());
}

if content_lower.contains("bsd 3-clause") || content_lower.contains("redistribution and use in source and binary forms") {
if content_lower.contains("bsd 3-clause")
|| content_lower.contains("redistribution and use in source and binary forms")
{
return Some("BSD-3-Clause".to_string());
}

Expand Down Expand Up @@ -331,7 +335,12 @@ impl LicenseAnalyzer {
let new_content = if content.starts_with("#!") {
let mut lines = content.lines();
let shebang = lines.next().unwrap_or("");
format!("{}\n{}{}", shebang, header, lines.collect::<Vec<_>>().join("\n"))
format!(
"{}\n{}{}",
shebang,
header,
lines.collect::<Vec<_>>().join("\n")
)
} else {
format!("{}{}", header, content)
};
Expand Down
43 changes: 33 additions & 10 deletions bots/finishingbot/src/analyzers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

/// First of `candidates` (repo-relative, in preference order) that exists
/// under `root`. Estate canon allows a document in more than one place
/// (`.adoc` or `.md`, repo root or `.github/`), and a rule that names exactly
/// one spelling reports a false positive for the others.
pub(crate) fn first_existing(root: &Path, candidates: &[&str]) -> Option<PathBuf> {
candidates.iter().map(|c| root.join(c)).find(|p| p.exists())
}

/// Accepted spellings and locations of a security policy.
pub(crate) const SECURITY_POLICY: &[&str] = &[
"SECURITY.md",
"SECURITY.adoc",
".github/SECURITY.md",
".github/SECURITY.adoc",
];

/// Severity levels for findings
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
Expand Down Expand Up @@ -282,14 +298,14 @@ mod scope_tests {
let cfg = Config::default();
let mut out = Vec::new();
let analyzers: Vec<Box<dyn Analyzer>> = vec![
Box::new(license::LicenseAnalyzer::default()),
Box::new(placeholder::PlaceholderAnalyzer::default()),
Box::new(claims::ClaimsAnalyzer::default()),
Box::new(release::ReleaseAnalyzer::default()),
Box::new(scm_files::ScmFilesAnalyzer::default()),
Box::new(testing::TestingAnalyzer::default()),
Box::new(tooling::ToolingAnalyzer::default()),
Box::new(v1_readiness::V1ReadinessAnalyzer::default()),
Box::new(license::LicenseAnalyzer),
Box::new(placeholder::PlaceholderAnalyzer),
Box::new(claims::ClaimsAnalyzer),
Box::new(release::ReleaseAnalyzer),
Box::new(scm_files::ScmFilesAnalyzer),
Box::new(testing::TestingAnalyzer),
Box::new(tooling::ToolingAnalyzer),
Box::new(v1_readiness::V1ReadinessAnalyzer),
];
for a in &analyzers {
out.extend(a.analyze(dir, &cfg).unwrap().findings);
Expand All @@ -304,7 +320,11 @@ mod scope_tests {
.filter(|f| f.file.is_none() && !NO_MEANINGFUL_FILE.contains(&f.id.as_str()))
.map(|f| f.id.as_str())
.collect();
assert!(unpathed.is_empty(), "findings without a path: {:?}", unpathed);
assert!(
unpathed.is_empty(),
"findings without a path: {:?}",
unpathed
);
findings.len()
}

Expand Down Expand Up @@ -340,6 +360,9 @@ mod scope_tests {
.into_iter()
.find(|f| f.id == "LIC-001")
.expect("LIC-001 on an empty repo");
assert_eq!(f.file.as_deref(), Some(dir.path().join("LICENSE").as_path()));
assert_eq!(
f.file.as_deref(),
Some(dir.path().join("LICENSE").as_path())
);
}
}
Loading
Loading