refactor: extract shared lint record primitives

This commit is contained in:
Keisuke Hirata 2026-05-12 21:56:25 +09:00
parent 2f70411254
commit 7ce4600a42
No known key found for this signature in database
33 changed files with 233 additions and 281 deletions

12
Cargo.lock generated
View File

@ -1615,6 +1615,16 @@ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
] ]
[[package]]
name = "lint-common"
version = "0.1.0"
dependencies = [
"chrono",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.15" version = "0.4.15"
@ -1779,6 +1789,7 @@ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
"libc", "libc",
"lint-common",
"llm-worker", "llm-worker",
"manifest", "manifest",
"schemars", "schemars",
@ -4404,6 +4415,7 @@ name = "workflow"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"lint-common",
"manifest", "manifest",
"memory", "memory",
"serde", "serde",

View File

@ -12,6 +12,7 @@ members = [
"crates/provider", "crates/provider",
"crates/pod-registry", "crates/pod-registry",
"crates/session-metrics", "crates/session-metrics",
"crates/lint-common",
"crates/tools", "crates/tools",
"crates/tui", "crates/tui",
"crates/memory", "crates/memory",
@ -28,6 +29,7 @@ client = { path = "crates/client" }
llm-worker = { path = "crates/llm-worker", version = "0.2" } llm-worker = { path = "crates/llm-worker", version = "0.2" }
llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" } llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" }
manifest = { path = "crates/manifest" } manifest = { path = "crates/manifest" }
lint-common = { path = "crates/lint-common" }
memory = { path = "crates/memory" } memory = { path = "crates/memory" }
workflow = { path = "crates/workflow" } workflow = { path = "crates/workflow" }
pod-registry = { path = "crates/pod-registry" } pod-registry = { path = "crates/pod-registry" }

View File

@ -0,0 +1,13 @@
[package]
name = "lint-common"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }

View File

@ -0,0 +1,81 @@
//! Common frontmatter helpers.
use chrono::{DateTime, Utc};
use crate::RecordLintError;
/// Trait record frontmatter types implement so linters can drive them uniformly.
pub trait Frontmatter: Sized {
/// Hard upper bound on body chars (excluding the frontmatter block).
const BODY_LIMIT: usize;
fn created_at(&self) -> Option<DateTime<Utc>>;
fn updated_at(&self) -> Option<DateTime<Utc>>;
}
const FRONTMATTER_DELIM: &str = "---";
/// Split a markdown document into `(yaml_frontmatter, body)`.
///
/// Expects the document to start with `---\n` and have a closing
/// `---\n` (or `---` at EOF) somewhere downstream. Trailing newline
/// after the closing delimiter is consumed.
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), RecordLintError> {
// The opening delimiter must be the very first line.
let after_open = content
.strip_prefix(FRONTMATTER_DELIM)
.and_then(|s| s.strip_prefix('\n').or(Some(s)))
.ok_or(RecordLintError::MissingFrontmatter)?;
// Look for the closing `---` on its own line.
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(|| {
RecordLintError::MalformedFrontmatter("missing closing `---` line".to_string())
})?;
let yaml = &after_open[..yaml_end_excl];
let body = &after_open[body_start..];
Ok((yaml, body))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_simple() {
let doc = "---\nfoo: 1\n---\nbody here\n";
let (y, b) = split_frontmatter(doc).unwrap();
assert_eq!(y, "foo: 1\n");
assert_eq!(b, "body here\n");
}
#[test]
fn no_leading_delim_errors() {
let err = split_frontmatter("hello").unwrap_err();
assert!(matches!(err, RecordLintError::MissingFrontmatter));
}
#[test]
fn no_closing_delim_errors() {
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
assert!(matches!(err, RecordLintError::MalformedFrontmatter(_)));
}
#[test]
fn handles_empty_body() {
let doc = "---\nfoo: 1\n---\n";
let (_, b) = split_frontmatter(doc).unwrap();
assert_eq!(b, "");
}
}

View File

@ -0,0 +1,20 @@
//! Shared record lint primitives for memory and workflow files.
mod frontmatter;
mod slug;
pub use frontmatter::{Frontmatter, split_frontmatter};
pub use slug::{Slug, is_valid_slug};
/// Common lint errors for Markdown record syntax shared by memory and workflow.
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum RecordLintError {
#[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,
}

View File

@ -12,7 +12,7 @@ use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use crate::error::LintError; use crate::RecordLintError;
const MIN_LEN: usize = 1; const MIN_LEN: usize = 1;
const MAX_LEN: usize = 64; const MAX_LEN: usize = 64;
@ -23,13 +23,13 @@ const MAX_LEN: usize = 64;
pub struct Slug(String); pub struct Slug(String);
impl Slug { impl Slug {
/// Parse and validate. Returns [`LintError::InvalidSlug`] on rejection. /// Parse and validate. Returns [`RecordLintError::InvalidSlug`] on rejection.
pub fn parse(s: impl Into<String>) -> Result<Self, LintError> { pub fn parse(s: impl Into<String>) -> Result<Self, RecordLintError> {
let s = s.into(); let s = s.into();
if is_valid_slug(&s) { if is_valid_slug(&s) {
Ok(Self(s)) Ok(Self(s))
} else { } else {
Err(LintError::InvalidSlug(s)) Err(RecordLintError::InvalidSlug(s))
} }
} }
@ -55,7 +55,7 @@ impl AsRef<str> for Slug {
} }
impl FromStr for Slug { impl FromStr for Slug {
type Err = LintError; type Err = RecordLintError;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s) Self::parse(s)

View File

@ -8,6 +8,7 @@ license.workspace = true
async-trait = { workspace = true } async-trait = { workspace = true }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
libc = { workspace = true } libc = { workspace = true }
lint-common = { workspace = true }
llm-worker = { workspace = true } llm-worker = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
schemars = { workspace = true } schemars = { workspace = true }

View File

@ -13,10 +13,10 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use crate::Slug;
use crate::schema::{ use crate::schema::{
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter, DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter,
}; };
use crate::slug::Slug;
use crate::workspace::{RecordKind, WorkspaceLayout}; use crate::workspace::{RecordKind, WorkspaceLayout};
/// `sources` overflow を flag する閾値。`linter::warnings::SOURCES_OVERFLOW_THRESHOLD` /// `sources` overflow を flag する閾値。`linter::warnings::SOURCES_OVERFLOW_THRESHOLD`

View File

@ -2,6 +2,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use lint_common::RecordLintError;
use thiserror::Error; use thiserror::Error;
/// Top-level error for memory operations that don't fit the lint flow. /// Top-level error for memory operations that don't fit the lint flow.
@ -40,14 +41,8 @@ pub enum LintError {
#[error("path is for a different record kind than expected at this location: {}", .0.display())] #[error("path is for a different record kind than expected at this location: {}", .0.display())]
WrongRecordKind(PathBuf), WrongRecordKind(PathBuf),
#[error("invalid slug `{0}`: must match ^[a-z0-9](?:[a-z0-9-]{{0,62}}[a-z0-9])?$")] #[error(transparent)]
InvalidSlug(String), Record(#[from] RecordLintError),
#[error("malformed frontmatter: {0}")]
MalformedFrontmatter(String),
#[error("frontmatter is missing or document is empty")]
MissingFrontmatter,
#[error("missing required frontmatter field: `{0}`")] #[error("missing required frontmatter field: `{0}`")]
MissingField(&'static str), MissingField(&'static str),

View File

@ -13,17 +13,16 @@ pub mod linter;
pub mod resident; pub mod resident;
pub mod schema; pub mod schema;
pub mod scope; pub mod scope;
pub mod slug;
pub mod tool; pub mod tool;
pub mod usage; pub mod usage;
pub mod workspace; pub mod workspace;
pub use error::{LintError, LintWarning, MemoryError}; pub use error::{LintError, LintWarning, MemoryError};
pub use extract::ExtractPointerPayload; pub use extract::ExtractPointerPayload;
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
pub use linter::{LintReport, Linter}; pub use linter::{LintReport, Linter};
pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge, list_knowledge_slugs}; pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge, list_knowledge_slugs};
pub use scope::deny_write_rules; pub use scope::deny_write_rules;
pub use slug::Slug;
pub use usage::{ pub use usage::{
UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource, UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource,
append_resident_exposure_event, append_usage_event, append_use_event, build_usage_report, append_resident_exposure_event, append_usage_event, append_use_event, build_usage_report,

View File

@ -9,10 +9,10 @@ use std::collections::{HashMap, HashSet};
use std::io; use std::io;
use std::path::Path; use std::path::Path;
use crate::Slug;
use crate::schema::{ use crate::schema::{
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter, DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter,
}; };
use crate::slug::Slug;
use crate::workspace::{RecordKind, WorkspaceLayout}; use crate::workspace::{RecordKind, WorkspaceLayout};
/// Snapshot of every record currently on disk under the workspace. /// Snapshot of every record currently on disk under the workspace.

View File

@ -1,5 +1,6 @@
//! YAML frontmatter parsing helpers shared by every kind. //! YAML frontmatter parsing helpers shared by every kind.
use lint_common::RecordLintError;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use crate::error::LintError; use crate::error::LintError;
@ -26,7 +27,7 @@ fn map_serde_error(err: serde_yaml::Error) -> LintError {
} }
return LintError::InvalidField { field, message }; return LintError::InvalidField { field, message };
} }
LintError::MalformedFrontmatter(msg) LintError::Record(RecordLintError::MalformedFrontmatter(msg))
} }
fn parse_missing_field(msg: &str) -> Option<&'static str> { fn parse_missing_field(msg: &str) -> Option<&'static str> {

View File

@ -18,6 +18,7 @@ mod warnings;
use std::path::Path; use std::path::Path;
use lint_common::RecordLintError;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use crate::error::{LintError, LintWarning}; use crate::error::{LintError, LintWarning};
@ -104,8 +105,8 @@ impl Linter {
let existing = match existing::scan_existing(&self.layout) { let existing = match existing::scan_existing(&self.layout) {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
report.push_error(LintError::MalformedFrontmatter(format!( report.push_error(LintError::Record(RecordLintError::MalformedFrontmatter(
"failed to scan existing records: {e}" format!("failed to scan existing records: {e}"),
))); )));
return report; return report;
} }
@ -354,7 +355,8 @@ mod tests {
let report = linter.lint(&path, &content, WriteMode::Create); let report = linter.lint(&path, &content, WriteMode::Create);
assert!(report.errors.iter().any(|e| matches!( assert!(report.errors.iter().any(|e| matches!(
e, e,
LintError::MissingField(_) | LintError::MalformedFrontmatter(_) LintError::MissingField(_)
| LintError::Record(RecordLintError::MalformedFrontmatter(_))
))); )));
} }

View File

@ -2,10 +2,10 @@
use std::collections::HashSet; use std::collections::HashSet;
use crate::Slug;
use crate::error::LintError; use crate::error::LintError;
use crate::linter::ExistingRecords; use crate::linter::ExistingRecords;
use crate::linter::LintReport; use crate::linter::LintReport;
use crate::slug::Slug;
use crate::workspace::RecordKind; use crate::workspace::RecordKind;
/// Validate a Decision's `replaced_by` against the existing record set. /// Validate a Decision's `replaced_by` against the existing record set.

View File

@ -4,10 +4,10 @@
//! integrated into the main linter pass when implemented; this file //! integrated into the main linter pass when implemented; this file
//! covers per-write checks that only need the proposed content. //! covers per-write checks that only need the proposed content.
use crate::Slug;
use crate::error::LintWarning; use crate::error::LintWarning;
use crate::linter::LintReport; use crate::linter::LintReport;
use crate::linter::existing::ExistingRecords; use crate::linter::existing::ExistingRecords;
use crate::slug::Slug;
use crate::workspace::{ClassifiedPath, RecordKind}; use crate::workspace::{ClassifiedPath, RecordKind};
const LARGE_BODY_THRESHOLD: usize = 1500; const LARGE_BODY_THRESHOLD: usize = 1500;

View File

@ -1,10 +1,11 @@
//! Common frontmatter helpers and shared types. //! Common frontmatter helpers and shared types.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::LintError; use crate::error::LintError;
pub use lint_common::Frontmatter;
/// Reference to a session-store entry range. Stored in `sources` / /// Reference to a session-store entry range. Stored in `sources` /
/// `last_sources` arrays for traceability back to raw session logs. /// `last_sources` arrays for traceability back to raw session logs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -14,53 +15,15 @@ pub struct SourceRef {
pub range: [u64; 2], pub range: [u64; 2],
} }
/// Trait every kind-specific frontmatter implements so the linter can
/// drive them uniformly.
pub trait Frontmatter: Sized {
/// Hard upper bound on body chars (excluding the frontmatter block).
const BODY_LIMIT: usize;
fn created_at(&self) -> DateTime<Utc>;
fn updated_at(&self) -> DateTime<Utc>;
}
const FRONTMATTER_DELIM: &str = "---";
/// Split a markdown document into `(yaml_frontmatter, body)`. /// Split a markdown document into `(yaml_frontmatter, body)`.
///
/// Expects the document to start with `---\n` and have a closing
/// `---\n` (or `---` at EOF) somewhere downstream. Trailing newline
/// after the closing delimiter is consumed.
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), LintError> { pub fn split_frontmatter(content: &str) -> Result<(&str, &str), LintError> {
// The opening delimiter must be the very first line. lint_common::split_frontmatter(content).map_err(Into::into)
let after_open = content
.strip_prefix(FRONTMATTER_DELIM)
.and_then(|s| s.strip_prefix('\n').or(Some(s)))
.ok_or(LintError::MissingFrontmatter)?;
// Look for the closing `---` on its own line.
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(|| LintError::MalformedFrontmatter("missing closing `---` line".to_string()))?;
let yaml = &after_open[..yaml_end_excl];
let body = &after_open[body_start..];
Ok((yaml, body))
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use lint_common::RecordLintError;
#[test] #[test]
fn splits_simple() { fn splits_simple() {
@ -73,13 +36,19 @@ mod tests {
#[test] #[test]
fn no_leading_delim_errors() { fn no_leading_delim_errors() {
let err = split_frontmatter("hello").unwrap_err(); let err = split_frontmatter("hello").unwrap_err();
assert!(matches!(err, LintError::MissingFrontmatter)); assert!(matches!(
err,
LintError::Record(RecordLintError::MissingFrontmatter)
));
} }
#[test] #[test]
fn no_closing_delim_errors() { fn no_closing_delim_errors() {
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err(); let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
assert!(matches!(err, LintError::MalformedFrontmatter(_))); assert!(matches!(
err,
LintError::Record(RecordLintError::MalformedFrontmatter(_))
));
} }
#[test] #[test]

View File

@ -3,8 +3,8 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::Slug;
use crate::schema::common::{Frontmatter, SourceRef}; use crate::schema::common::{Frontmatter, SourceRef};
use crate::slug::Slug;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@ -27,10 +27,10 @@ pub struct DecisionFrontmatter {
impl Frontmatter for DecisionFrontmatter { impl Frontmatter for DecisionFrontmatter {
const BODY_LIMIT: usize = 8000; const BODY_LIMIT: usize = 8000;
fn created_at(&self) -> DateTime<Utc> { fn created_at(&self) -> Option<DateTime<Utc>> {
self.created_at Some(self.created_at)
} }
fn updated_at(&self) -> DateTime<Utc> { fn updated_at(&self) -> Option<DateTime<Utc>> {
self.updated_at Some(self.updated_at)
} }
} }

View File

@ -24,10 +24,10 @@ pub struct KnowledgeFrontmatter {
impl Frontmatter for KnowledgeFrontmatter { impl Frontmatter for KnowledgeFrontmatter {
const BODY_LIMIT: usize = 8000; const BODY_LIMIT: usize = 8000;
fn created_at(&self) -> DateTime<Utc> { fn created_at(&self) -> Option<DateTime<Utc>> {
self.created_at Some(self.created_at)
} }
fn updated_at(&self) -> DateTime<Utc> { fn updated_at(&self) -> Option<DateTime<Utc>> {
self.updated_at Some(self.updated_at)
} }
} }

View File

@ -15,10 +15,10 @@ pub struct RequestFrontmatter {
impl Frontmatter for RequestFrontmatter { impl Frontmatter for RequestFrontmatter {
const BODY_LIMIT: usize = 8000; const BODY_LIMIT: usize = 8000;
fn created_at(&self) -> DateTime<Utc> { fn created_at(&self) -> Option<DateTime<Utc>> {
self.created_at Some(self.created_at)
} }
fn updated_at(&self) -> DateTime<Utc> { fn updated_at(&self) -> Option<DateTime<Utc>> {
self.updated_at Some(self.updated_at)
} }
} }

View File

@ -23,10 +23,10 @@ impl Frontmatter for SummaryFrontmatter {
/// than per-record kinds (~5k tokens at the upper end). /// than per-record kinds (~5k tokens at the upper end).
const BODY_LIMIT: usize = 20000; const BODY_LIMIT: usize = 20000;
fn created_at(&self) -> DateTime<Utc> { fn created_at(&self) -> Option<DateTime<Utc>> {
self.created_at.unwrap_or(self.updated_at) Some(self.created_at.unwrap_or(self.updated_at))
} }
fn updated_at(&self) -> DateTime<Utc> { fn updated_at(&self) -> Option<DateTime<Utc>> {
self.updated_at Some(self.updated_at)
} }
} }

View File

@ -15,7 +15,7 @@ use std::path::PathBuf;
use llm_worker::tool::ToolError; use llm_worker::tool::ToolError;
use serde::Deserialize; use serde::Deserialize;
use crate::slug::Slug; use crate::Slug;
use crate::workspace::{RecordKind, WorkspaceLayout}; use crate::workspace::{RecordKind, WorkspaceLayout};
pub use edit::edit_tool; pub use edit::edit_tool;

View File

@ -227,7 +227,7 @@ fn record_path(
} }
} }
fn invalid_slug_error(err: crate::LintError) -> io::Error { fn invalid_slug_error(err: lint_common::RecordLintError) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, err) io::Error::new(io::ErrorKind::InvalidInput, err)
} }

View File

@ -22,8 +22,9 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::Slug;
use crate::error::LintError; use crate::error::LintError;
use crate::slug::Slug; use lint_common::RecordLintError;
const INSOMNIA_DIR: &str = ".insomnia"; const INSOMNIA_DIR: &str = ".insomnia";
const MEMORY_DIR: &str = "memory"; const MEMORY_DIR: &str = "memory";
@ -159,7 +160,7 @@ impl WorkspaceLayout {
/// ///
/// On a conventional path that's *almost* a record but malformed /// On a conventional path that's *almost* a record but malformed
/// (e.g. `.insomnia/memory/decisions/Foo.md` with an invalid slug), /// (e.g. `.insomnia/memory/decisions/Foo.md` with an invalid slug),
/// returns `Err(LintError::InvalidSlug | InvalidPath)` so the caller /// returns `Err(LintError::Record(InvalidSlug) | InvalidPath)` so the caller
/// can surface it as a write violation. /// can surface it as a write violation.
pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> { pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> {
let memory = self.memory_dir(); let memory = self.memory_dir();
@ -320,7 +321,10 @@ mod tests {
let err = layout() let err = layout()
.classify(&PathBuf::from("/ws/.insomnia/memory/decisions/Foo.md")) .classify(&PathBuf::from("/ws/.insomnia/memory/decisions/Foo.md"))
.unwrap_err(); .unwrap_err();
assert!(matches!(err, LintError::InvalidSlug(_))); assert!(matches!(
err,
LintError::Record(RecordLintError::InvalidSlug(_))
));
} }
#[test] #[test]

View File

@ -1221,7 +1221,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
continue; continue;
}; };
let parsed = workflow_crate::Slug::parse(slug.clone()) let parsed = workflow_crate::Slug::parse(slug.clone())
.map_err(WorkflowResolveError::InvalidSlug)?; .map_err(|source| WorkflowResolveError::InvalidSlug(source.into()))?;
let record = self let record = self
.workflow_registry .workflow_registry
.get(&parsed) .get(&parsed)

View File

@ -77,7 +77,8 @@ pub fn resolve_workflow_invocation(
layout: &WorkspaceLayout, layout: &WorkspaceLayout,
raw_slug: &str, raw_slug: &str,
) -> Result<Vec<Item>, WorkflowResolveError> { ) -> Result<Vec<Item>, WorkflowResolveError> {
let slug = Slug::parse(raw_slug.to_string()).map_err(WorkflowResolveError::InvalidSlug)?; let slug = Slug::parse(raw_slug.to_string())
.map_err(|source| WorkflowResolveError::InvalidSlug(source.into()))?;
let record = registry let record = registry
.get(&slug) .get(&slug)
.ok_or_else(|| WorkflowResolveError::NotFound { .ok_or_else(|| WorkflowResolveError::NotFound {

View File

@ -6,6 +6,7 @@ license.workspace = true
[dependencies] [dependencies]
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
lint-common = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
memory = { workspace = true } memory = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }

View File

@ -2,19 +2,14 @@
use std::path::PathBuf; use std::path::PathBuf;
use lint_common::RecordLintError;
use thiserror::Error; use thiserror::Error;
/// A single Workflow linter violation. /// A single Workflow linter violation.
#[derive(Debug, Clone, Error, PartialEq, Eq)] #[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum WorkflowLintError { pub enum WorkflowLintError {
#[error("invalid slug `{0}`: must match ^[a-z0-9](?:[a-z0-9-]{{0,62}}[a-z0-9])?$")] #[error(transparent)]
InvalidSlug(String), Record(#[from] RecordLintError),
#[error("malformed frontmatter: {0}")]
MalformedFrontmatter(String),
#[error("frontmatter is missing or document is empty")]
MissingFrontmatter,
#[error("missing required frontmatter field: `{0}`")] #[error("missing required frontmatter field: `{0}`")]
MissingField(&'static str), MissingField(&'static str),

View File

@ -5,17 +5,16 @@ mod linter;
mod schema; mod schema;
mod scope; mod scope;
mod skill; mod skill;
mod slug;
mod workflow; mod workflow;
pub use error::WorkflowLintError; pub use error::WorkflowLintError;
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
pub use linter::{WorkflowLintReport, WorkflowLinter}; pub use linter::{WorkflowLintReport, WorkflowLinter};
pub use schema::{WorkflowFrontmatter, split_frontmatter}; pub use schema::{WorkflowFrontmatter, split_frontmatter};
pub use scope::deny_write_rules; pub use scope::deny_write_rules;
pub use skill::{ pub use skill::{
SKILL_FILENAME, SkillParseError, SkillRecord, load_skills_from_dir, parse_skill_md, SKILL_FILENAME, SkillParseError, SkillRecord, load_skills_from_dir, parse_skill_md,
}; };
pub use slug::{Slug, is_valid_slug};
pub use workflow::{ pub use workflow::{
ResidentWorkflowEntry, ShadowedSkill, WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowLoadError, ResidentWorkflowEntry, ShadowedSkill, WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowLoadError,
WorkflowRecord, WorkflowRegistry, WorkflowSource, load_workflows, WorkflowRecord, WorkflowRegistry, WorkflowSource, load_workflows,

View File

@ -5,6 +5,7 @@ use std::collections::HashSet;
use memory::WorkspaceLayout; use memory::WorkspaceLayout;
use crate::{Slug, WorkflowLintError}; use crate::{Slug, WorkflowLintError};
use lint_common::RecordLintError;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use crate::schema::{WORKFLOW_BODY_LIMIT, WorkflowFrontmatter, split_frontmatter}; use crate::schema::{WORKFLOW_BODY_LIMIT, WorkflowFrontmatter, split_frontmatter};
@ -74,9 +75,11 @@ impl WorkflowLinter {
let knowledge = match scan_knowledge_slugs(&self.layout) { let knowledge = match scan_knowledge_slugs(&self.layout) {
Ok(knowledge) => knowledge, Ok(knowledge) => knowledge,
Err(err) => { Err(err) => {
report.push_error(WorkflowLintError::MalformedFrontmatter(format!( report.push_error(WorkflowLintError::Record(
"failed to scan existing Knowledge records: {err}" RecordLintError::MalformedFrontmatter(format!(
))); "failed to scan existing Knowledge records: {err}"
)),
));
return report; return report;
} }
}; };
@ -109,7 +112,7 @@ fn parse_frontmatter<F: DeserializeOwned>(
if let Some(field) = parse_missing_field(&msg) { if let Some(field) = parse_missing_field(&msg) {
WorkflowLintError::MissingField(field) WorkflowLintError::MissingField(field)
} else { } else {
WorkflowLintError::MalformedFrontmatter(msg) WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(msg))
} }
})?; })?;
Ok(Parsed { frontmatter, body }) Ok(Parsed { frontmatter, body })

View File

@ -1,6 +1,7 @@
//! Workflow frontmatter schema and frontmatter splitting helpers. //! Workflow frontmatter schema and frontmatter splitting helpers.
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use lint_common::Frontmatter;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{Slug, WorkflowLintError}; use crate::{Slug, WorkflowLintError};
@ -24,42 +25,31 @@ pub struct WorkflowFrontmatter {
pub requires: Vec<Slug>, 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 { fn default_user_invocable() -> bool {
true true
} }
const FRONTMATTER_DELIM: &str = "---";
/// Split a markdown document into `(yaml_frontmatter, body)`. /// Split a markdown document into `(yaml_frontmatter, body)`.
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), WorkflowLintError> { pub fn split_frontmatter(content: &str) -> Result<(&str, &str), WorkflowLintError> {
let after_open = content lint_common::split_frontmatter(content).map_err(Into::into)
.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))
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use lint_common::RecordLintError;
#[test] #[test]
fn splits_simple() { fn splits_simple() {
@ -72,13 +62,19 @@ mod tests {
#[test] #[test]
fn no_leading_delim_errors() { fn no_leading_delim_errors() {
let err = split_frontmatter("hello").unwrap_err(); let err = split_frontmatter("hello").unwrap_err();
assert!(matches!(err, WorkflowLintError::MissingFrontmatter)); assert!(matches!(
err,
WorkflowLintError::Record(RecordLintError::MissingFrontmatter)
));
} }
#[test] #[test]
fn no_closing_delim_errors() { fn no_closing_delim_errors() {
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err(); let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
assert!(matches!(err, WorkflowLintError::MalformedFrontmatter(_))); assert!(matches!(
err,
WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(_))
));
} }
#[test] #[test]

View File

@ -15,6 +15,7 @@
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use lint_common::RecordLintError;
use serde::Deserialize; use serde::Deserialize;
use thiserror::Error; use thiserror::Error;
use tracing::warn; use tracing::warn;
@ -150,7 +151,9 @@ pub fn parse_skill_md(skill_md_path: &Path) -> Result<SkillRecord, SkillParseErr
let frontmatter: SkillFrontmatter = let frontmatter: SkillFrontmatter =
serde_yaml::from_str(yaml).map_err(|err| SkillParseError::Frontmatter { serde_yaml::from_str(yaml).map_err(|err| SkillParseError::Frontmatter {
path: skill_md_path.to_path_buf(), 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() { 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 { let slug = Slug::parse(frontmatter.name).map_err(|source| SkillParseError::InvalidName {
skill_md_path: skill_md_path.to_path_buf(), skill_md_path: skill_md_path.to_path_buf(),
source, source: source.into(),
})?; })?;
Ok(SkillRecord { Ok(SkillRecord {

View File

@ -1,146 +0,0 @@
//! Slug type and validation.
//!
//! Syntax (agent-skills compatible):
//! ^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$
//! - 164 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());
}
}

View File

@ -13,6 +13,7 @@ use thiserror::Error;
use tracing::warn; use tracing::warn;
use crate::schema::{WorkflowFrontmatter, split_frontmatter}; use crate::schema::{WorkflowFrontmatter, split_frontmatter};
use lint_common::RecordLintError;
use memory::WorkspaceLayout; use memory::WorkspaceLayout;
use crate::{Slug, WorkflowLintError}; use crate::{Slug, WorkflowLintError};
@ -218,7 +219,7 @@ pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, Work
let slug = let slug =
Slug::parse(stem.to_string()).map_err(|source| WorkflowLoadError::InvalidSlug { Slug::parse(stem.to_string()).map_err(|source| WorkflowLoadError::InvalidSlug {
path: path.clone(), path: path.clone(),
source, source: source.into(),
})?; })?;
if records.contains_key(&slug) { if records.contains_key(&slug) {
warn!(slug = %slug, path = %path.display(), "duplicate workflow slug encountered; keeping first record"); 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) { if let Some(field) = parse_missing_field(&msg) {
return WorkflowLintError::MissingField(field); return WorkflowLintError::MissingField(field);
} }
WorkflowLintError::MalformedFrontmatter(msg) WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(msg))
} }
fn parse_missing_field(msg: &str) -> Option<&'static str> { fn parse_missing_field(msg: &str) -> Option<&'static str> {