refactor: extract shared lint record primitives
This commit is contained in:
parent
bedaf62cb0
commit
cf822dbc5c
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
13
crates/lint-common/Cargo.toml
Normal file
13
crates/lint-common/Cargo.toml
Normal 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 }
|
||||||
81
crates/lint-common/src/frontmatter.rs
Normal file
81
crates/lint-common/src/frontmatter.rs
Normal 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, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
20
crates/lint-common/src/lib.rs
Normal file
20
crates/lint-common/src/lib.rs
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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(_))
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 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> {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user