refactor: extract shared lint record primitives
This commit is contained in:
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
lint-common = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
memory = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -2,19 +2,14 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lint_common::RecordLintError;
|
||||
use thiserror::Error;
|
||||
|
||||
/// A single Workflow linter violation.
|
||||
#[derive(Debug, Clone, Error, PartialEq, Eq)]
|
||||
pub enum WorkflowLintError {
|
||||
#[error("invalid slug `{0}`: must match ^[a-z0-9](?:[a-z0-9-]{{0,62}}[a-z0-9])?$")]
|
||||
InvalidSlug(String),
|
||||
|
||||
#[error("malformed frontmatter: {0}")]
|
||||
MalformedFrontmatter(String),
|
||||
|
||||
#[error("frontmatter is missing or document is empty")]
|
||||
MissingFrontmatter,
|
||||
#[error(transparent)]
|
||||
Record(#[from] RecordLintError),
|
||||
|
||||
#[error("missing required frontmatter field: `{0}`")]
|
||||
MissingField(&'static str),
|
||||
|
||||
@@ -5,17 +5,16 @@ mod linter;
|
||||
mod schema;
|
||||
mod scope;
|
||||
mod skill;
|
||||
mod slug;
|
||||
mod workflow;
|
||||
|
||||
pub use error::WorkflowLintError;
|
||||
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
|
||||
pub use linter::{WorkflowLintReport, WorkflowLinter};
|
||||
pub use schema::{WorkflowFrontmatter, split_frontmatter};
|
||||
pub use scope::deny_write_rules;
|
||||
pub use skill::{
|
||||
SKILL_FILENAME, SkillParseError, SkillRecord, load_skills_from_dir, parse_skill_md,
|
||||
};
|
||||
pub use slug::{Slug, is_valid_slug};
|
||||
pub use workflow::{
|
||||
ResidentWorkflowEntry, ShadowedSkill, WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowLoadError,
|
||||
WorkflowRecord, WorkflowRegistry, WorkflowSource, load_workflows,
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::HashSet;
|
||||
use memory::WorkspaceLayout;
|
||||
|
||||
use crate::{Slug, WorkflowLintError};
|
||||
use lint_common::RecordLintError;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::schema::{WORKFLOW_BODY_LIMIT, WorkflowFrontmatter, split_frontmatter};
|
||||
@@ -74,9 +75,11 @@ impl WorkflowLinter {
|
||||
let knowledge = match scan_knowledge_slugs(&self.layout) {
|
||||
Ok(knowledge) => knowledge,
|
||||
Err(err) => {
|
||||
report.push_error(WorkflowLintError::MalformedFrontmatter(format!(
|
||||
"failed to scan existing Knowledge records: {err}"
|
||||
)));
|
||||
report.push_error(WorkflowLintError::Record(
|
||||
RecordLintError::MalformedFrontmatter(format!(
|
||||
"failed to scan existing Knowledge records: {err}"
|
||||
)),
|
||||
));
|
||||
return report;
|
||||
}
|
||||
};
|
||||
@@ -109,7 +112,7 @@ fn parse_frontmatter<F: DeserializeOwned>(
|
||||
if let Some(field) = parse_missing_field(&msg) {
|
||||
WorkflowLintError::MissingField(field)
|
||||
} else {
|
||||
WorkflowLintError::MalformedFrontmatter(msg)
|
||||
WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(msg))
|
||||
}
|
||||
})?;
|
||||
Ok(Parsed { frontmatter, body })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Workflow frontmatter schema and frontmatter splitting helpers.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use lint_common::Frontmatter;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Slug, WorkflowLintError};
|
||||
@@ -24,42 +25,31 @@ pub struct WorkflowFrontmatter {
|
||||
pub requires: Vec<Slug>,
|
||||
}
|
||||
|
||||
impl Frontmatter for WorkflowFrontmatter {
|
||||
const BODY_LIMIT: usize = WORKFLOW_BODY_LIMIT;
|
||||
|
||||
fn created_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.created_at
|
||||
}
|
||||
|
||||
fn updated_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
fn default_user_invocable() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const FRONTMATTER_DELIM: &str = "---";
|
||||
|
||||
/// Split a markdown document into `(yaml_frontmatter, body)`.
|
||||
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), WorkflowLintError> {
|
||||
let after_open = content
|
||||
.strip_prefix(FRONTMATTER_DELIM)
|
||||
.and_then(|s| s.strip_prefix('\n').or(Some(s)))
|
||||
.ok_or(WorkflowLintError::MissingFrontmatter)?;
|
||||
|
||||
let mut yaml_end = None;
|
||||
let mut byte_offset = 0usize;
|
||||
for line in after_open.split_inclusive('\n') {
|
||||
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
|
||||
if trimmed == FRONTMATTER_DELIM {
|
||||
yaml_end = Some((byte_offset, byte_offset + line.len()));
|
||||
break;
|
||||
}
|
||||
byte_offset += line.len();
|
||||
}
|
||||
|
||||
let (yaml_end_excl, body_start) = yaml_end.ok_or_else(|| {
|
||||
WorkflowLintError::MalformedFrontmatter("missing closing `---` line".to_string())
|
||||
})?;
|
||||
|
||||
let yaml = &after_open[..yaml_end_excl];
|
||||
let body = &after_open[body_start..];
|
||||
Ok((yaml, body))
|
||||
lint_common::split_frontmatter(content).map_err(Into::into)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lint_common::RecordLintError;
|
||||
|
||||
#[test]
|
||||
fn splits_simple() {
|
||||
@@ -72,13 +62,19 @@ mod tests {
|
||||
#[test]
|
||||
fn no_leading_delim_errors() {
|
||||
let err = split_frontmatter("hello").unwrap_err();
|
||||
assert!(matches!(err, WorkflowLintError::MissingFrontmatter));
|
||||
assert!(matches!(
|
||||
err,
|
||||
WorkflowLintError::Record(RecordLintError::MissingFrontmatter)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_closing_delim_errors() {
|
||||
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
|
||||
assert!(matches!(err, WorkflowLintError::MalformedFrontmatter(_)));
|
||||
assert!(matches!(
|
||||
err,
|
||||
WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lint_common::RecordLintError;
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
use tracing::warn;
|
||||
@@ -150,7 +151,9 @@ pub fn parse_skill_md(skill_md_path: &Path) -> Result<SkillRecord, SkillParseErr
|
||||
let frontmatter: SkillFrontmatter =
|
||||
serde_yaml::from_str(yaml).map_err(|err| SkillParseError::Frontmatter {
|
||||
path: skill_md_path.to_path_buf(),
|
||||
source: WorkflowLintError::MalformedFrontmatter(err.to_string()),
|
||||
source: WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(
|
||||
err.to_string(),
|
||||
)),
|
||||
})?;
|
||||
|
||||
if frontmatter.allowed_tools.is_some() {
|
||||
@@ -183,7 +186,7 @@ pub fn parse_skill_md(skill_md_path: &Path) -> Result<SkillRecord, SkillParseErr
|
||||
}
|
||||
let slug = Slug::parse(frontmatter.name).map_err(|source| SkillParseError::InvalidName {
|
||||
skill_md_path: skill_md_path.to_path_buf(),
|
||||
source,
|
||||
source: source.into(),
|
||||
})?;
|
||||
|
||||
Ok(SkillRecord {
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
//! Slug type and validation.
|
||||
//!
|
||||
//! Syntax (agent-skills compatible):
|
||||
//! ^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$
|
||||
//! - 1–64 chars
|
||||
//! - lowercase ASCII alphanumerics and `-`
|
||||
//! - cannot start or end with `-`
|
||||
//! - no consecutive `--`
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::WorkflowLintError;
|
||||
|
||||
const MIN_LEN: usize = 1;
|
||||
const MAX_LEN: usize = 64;
|
||||
|
||||
/// Validated slug. Constructible only via [`Slug::parse`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Slug(String);
|
||||
|
||||
impl Slug {
|
||||
/// Parse and validate. Returns [`WorkflowLintError::InvalidSlug`] on rejection.
|
||||
pub fn parse(s: impl Into<String>) -> Result<Self, WorkflowLintError> {
|
||||
let s = s.into();
|
||||
if is_valid_slug(&s) {
|
||||
Ok(Self(s))
|
||||
} else {
|
||||
Err(WorkflowLintError::InvalidSlug(s))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Slug {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Slug {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Slug {
|
||||
type Err = WorkflowLintError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Slug {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
Self::parse(raw).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure-fn predicate matching the agent-skills slug regex without
|
||||
/// pulling in the `regex` crate.
|
||||
pub fn is_valid_slug(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
let len = bytes.len();
|
||||
if len < MIN_LEN || len > MAX_LEN {
|
||||
return false;
|
||||
}
|
||||
if !is_alnum_lower(bytes[0]) || !is_alnum_lower(bytes[len - 1]) {
|
||||
return false;
|
||||
}
|
||||
let mut prev_dash = false;
|
||||
for &b in bytes {
|
||||
if b == b'-' {
|
||||
if prev_dash {
|
||||
return false;
|
||||
}
|
||||
prev_dash = true;
|
||||
} else if is_alnum_lower(b) {
|
||||
prev_dash = false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn is_alnum_lower(b: u8) -> bool {
|
||||
b.is_ascii_digit() || b.is_ascii_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_basic_slugs() {
|
||||
for s in ["a", "ab", "abc-def", "x9", "a-b-c", "123", "a-1"] {
|
||||
assert!(is_valid_slug(s), "expected `{s}` valid");
|
||||
assert!(Slug::parse(s).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_slugs() {
|
||||
for s in [
|
||||
"", "-", "-foo", "foo-", "Foo", "foo_bar", "foo bar", "foo--bar", "foo.bar", "ä",
|
||||
] {
|
||||
assert!(!is_valid_slug(s), "expected `{s}` invalid");
|
||||
assert!(Slug::parse(s).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforces_length_bounds() {
|
||||
let too_long = "a".repeat(MAX_LEN + 1);
|
||||
assert!(!is_valid_slug(&too_long));
|
||||
let max = "a".repeat(MAX_LEN);
|
||||
assert!(is_valid_slug(&max));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_via_serde() {
|
||||
let json = "\"valid-slug\"";
|
||||
let slug: Slug = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(slug.as_str(), "valid-slug");
|
||||
|
||||
let bad = "\"BAD\"";
|
||||
let err: Result<Slug, _> = serde_json::from_str(bad);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use thiserror::Error;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::schema::{WorkflowFrontmatter, split_frontmatter};
|
||||
use lint_common::RecordLintError;
|
||||
use memory::WorkspaceLayout;
|
||||
|
||||
use crate::{Slug, WorkflowLintError};
|
||||
@@ -218,7 +219,7 @@ pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, Work
|
||||
let slug =
|
||||
Slug::parse(stem.to_string()).map_err(|source| WorkflowLoadError::InvalidSlug {
|
||||
path: path.clone(),
|
||||
source,
|
||||
source: source.into(),
|
||||
})?;
|
||||
if records.contains_key(&slug) {
|
||||
warn!(slug = %slug, path = %path.display(), "duplicate workflow slug encountered; keeping first record");
|
||||
@@ -292,7 +293,7 @@ fn map_serde_workflow_error(err: serde_yaml::Error) -> WorkflowLintError {
|
||||
if let Some(field) = parse_missing_field(&msg) {
|
||||
return WorkflowLintError::MissingField(field);
|
||||
}
|
||||
WorkflowLintError::MalformedFrontmatter(msg)
|
||||
WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(msg))
|
||||
}
|
||||
|
||||
fn parse_missing_field(msg: &str) -> Option<&'static str> {
|
||||
|
||||
Reference in New Issue
Block a user