Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 27 additions & 12 deletions bots/finishingbot/src/analyzers/claims.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,26 @@
// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
//! Claim verification analyzer

use super::references::resolve_local_reference;
use super::{AnalysisResult, Analyzer, Finding, Severity};
use crate::config::Config;
use crate::error::Result;
use pulldown_cmark::{Event, Parser, Tag};
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 MEMBER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"members\s*=\s*\[([\s\S]*?)\]"#).expect("Cargo workspace members regex is a valid constant pattern"));
static PATH_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#""([^"]+)""#).expect("Cargo workspace member-path regex is a valid constant pattern"));
static MEMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"members\s*=\s*\[([\s\S]*?)\]"#)
.expect("Cargo workspace members regex is a valid constant pattern")
});
static PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#""([^"]+)""#)
.expect("Cargo workspace member-path regex is a valid constant pattern")
});

/// Claim verification analyzer
pub struct ClaimsAnalyzer;
Expand Down Expand Up @@ -123,7 +128,9 @@ impl ClaimsAnalyzer {
&format!("README is very short ({} words)", word_count),
)
.with_file(path.to_path_buf())
.with_suggestion("Add more documentation including installation, usage, and examples"),
.with_suggestion(
"Add more documentation including installation, usage, and examples",
),
);
}

Expand Down Expand Up @@ -168,7 +175,10 @@ impl ClaimsAnalyzer {
"changelog.md",
];

if !changelog_variants.iter().any(|name| path.join(name).exists()) {
if !changelog_variants
.iter()
.any(|name| path.join(name).exists())
{
result.add(
Finding::new(
"CLM-004",
Expand Down Expand Up @@ -219,7 +229,12 @@ impl ClaimsAnalyzer {
}

/// Verify documentation claims match reality
fn verify_documentation_claims(&self, path: &Path, config: &Config, result: &mut AnalysisResult) {
fn verify_documentation_claims(
&self,
path: &Path,
config: &Config,
result: &mut AnalysisResult,
) {
// Find all documentation files
for entry in WalkDir::new(path)
.follow_links(false)
Expand Down Expand Up @@ -280,9 +295,7 @@ impl ClaimsAnalyzer {
for pattern in &file_ref_patterns {
for caps in pattern.captures_iter(content) {
let file_ref = &caps[1];
let ref_path = repo_root.join(file_ref);

if !ref_path.exists() {
if resolve_local_reference(repo_root, doc_path, file_ref).is_none() {
debug!(doc = %doc_path.display(), reference = file_ref, "Broken file reference");
result.add(
Finding::new(
Expand Down Expand Up @@ -339,7 +352,9 @@ impl ClaimsAnalyzer {
fn verify_test_claims(&self, path: &Path, config: &Config, result: &mut AnalysisResult) {
// Look for test directories and files
let test_indicators = ["tests", "test", "spec", "__tests__"];
let test_file_patterns = ["_test.rs", "_test.go", "_test.py", ".test.js", ".test.ts", ".spec.js", ".spec.ts"];
let test_file_patterns = [
"_test.rs", "_test.go", "_test.py", ".test.js", ".test.ts", ".spec.js", ".spec.ts",
];

let mut has_tests = false;
let mut test_file_count = 0;
Expand Down
3 changes: 2 additions & 1 deletion bots/finishingbot/src/analyzers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
//! Analyzers for release readiness validation

pub mod claims;
pub mod license;
pub mod placeholder;
pub mod claims;
mod references;
pub mod release;
pub mod scm_files;
pub mod testing;
Expand Down
158 changes: 158 additions & 0 deletions bots/finishingbot/src/analyzers/references.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
//! Resolve local prose references without using generated basename fallbacks.

use std::path::{Path, PathBuf};

const GENERATED_DIRS: &[&str] = &[
".git",
"target",
"node_modules",
"build",
"_build",
"_site",
".ddraig-ssg",
".echo-proof-tools",
".echo-standards-references",
"vendor",
"deps",
];
Comment on lines +17 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude dist artifacts from basename resolution

In repositories that retain generated bundles under the conventional dist/ directory, this exclusion list still traverses those artifacts. Consequently, a prose reference such as see app.js is accepted when only dist/app.js exists, or is reported as ambiguous when both the real source and its generated copy exist, contradicting the resolver's stated rule that generated basename fallbacks must not satisfy references. Add dist and equivalent configured output directories to the excluded traversal set.

Useful? React with 👍 / 👎.


fn local_file(root: &Path, candidate: &Path) -> Option<PathBuf> {
let resolved = candidate.canonicalize().ok()?;
(resolved.starts_with(root) && resolved.is_file()).then_some(resolved)
}

/// Prose can name a document-relative path, a repository-relative path, or an
/// unanchored module basename. The last form is accepted only when unique.
/// This resolves a file's identity; it does not verify its contents or claims.
pub(super) fn resolve_local_reference(
repo: &Path,
document: &Path,
reference: &str,
) -> Option<PathBuf> {
let root = repo.canonicalize().ok()?;
let path = Path::new(reference);
if path.is_absolute() {
return None;
}
if let Some(parent) = document.parent() {
if let Some(found) = local_file(&root, &parent.join(path)) {
return Some(found);
}
}
if let Some(found) = local_file(&root, &root.join(path)) {
return Some(found);
}
// A qualified missing path must not be "repaired" by a same-named file
// elsewhere. Nor may generated output stand in for a source module.
if path.components().count() != 1 {
return None;
}
let name = path.file_name()?;
let mut pending = vec![root.clone()];
let mut found = None;
while let Some(directory) = pending.pop() {
for entry in std::fs::read_dir(directory).ok()? {
let entry = entry.ok()?;
let kind = entry.file_type().ok()?;
if kind.is_dir() {
if !GENERATED_DIRS
.iter()
.any(|excluded| entry.file_name() == *excluded)
{
pending.push(entry.path());
}
} else if kind.is_file() && entry.file_name() == name {
let candidate = local_file(&root, &entry.path())?;
Comment on lines +66 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Follow local symlinks during basename resolution

When an unqualified prose reference names a tracked symlink to a source elsewhere in the repository, DirEntry::file_type() identifies the entry as a symlink, so this branch ignores it and emits CLM-006 even though the reference resolves to a valid local file. The direct-path branches already canonicalize and accept such links while rejecting targets outside the repository; the basename scan should apply the same local_file validation to symlink entries.

Useful? React with 👍 / 👎.

if found.is_some() {
return None; // Ambiguous basenames still need an explicit path.
}
found = Some(candidate);
}
}
}
found
}

#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT: AtomicUsize = AtomicUsize::new(0);

struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"finishing-reference-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
fn file(&self, path: &str) -> PathBuf {
let path = self.0.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "source fixture").unwrap();
path
}
fn resolves(&self, document: &str, reference: &str) -> bool {
resolve_local_reference(&self.0, &self.0.join(document), reference).is_some()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}

#[test]
fn accepts_document_relative_and_repository_relative_paths() {
let s = Scratch::new();
s.file("docs/theory/Example.agda");
s.file("proofs/agda/Echo.agda");
assert!(s.resolves("docs/theory/note.adoc", "Example.agda"));
assert!(s.resolves("docs/theory/note.adoc", "../../proofs/agda/Echo.agda"));
assert!(s.resolves("docs/theory/note.adoc", "proofs/agda/Echo.agda"));
}

#[test]
fn accepts_only_unique_unqualified_module_names() {
let s = Scratch::new();
s.file("proofs/agda/Echo.agda");
assert!(s.resolves("docs/note.adoc", "Echo.agda"));
s.file("examples/Echo.agda");
assert!(!s.resolves("docs/note.adoc", "Echo.agda"));
}

#[test]
fn a_missing_qualified_path_is_not_satisfied_by_another_basename() {
let s = Scratch::new();
s.file("proofs/agda/Echo.agda");
assert!(!s.resolves("docs/note.adoc", "missing/Echo.agda"));
assert!(!s.resolves("docs/note.adoc", "NeverExisted.agda"));
}

#[test]
fn build_output_and_directories_do_not_satisfy_basename_references() {
let s = Scratch::new();
s.file("build/generated/Echo.agda");
std::fs::create_dir_all(s.0.join("proofs/Echo.agda")).unwrap();
assert!(!s.resolves("docs/note.adoc", "Echo.agda"));
}

#[test]
fn absolute_and_outside_paths_do_not_count_as_repository_files() {
let s = Scratch::new();
let outside = Scratch::new();
let file = outside.file("outside.agda");
assert!(!s.resolves("docs/note.adoc", file.to_str().unwrap()));
let parent_ref = format!(
"../{}/outside.agda",
outside.0.file_name().unwrap().to_str().unwrap()
);
assert!(!s.resolves("note.adoc", &parent_ref));
}
}
Loading
Loading