diff --git a/Cargo.lock b/Cargo.lock index 1c4e4e94..08327049 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1615,6 +1615,16 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "lint-common" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1779,6 +1789,7 @@ dependencies = [ "async-trait", "chrono", "libc", + "lint-common", "llm-worker", "manifest", "schemars", @@ -4404,6 +4415,7 @@ name = "workflow" version = "0.1.0" dependencies = [ "chrono", + "lint-common", "manifest", "memory", "serde", diff --git a/Cargo.toml b/Cargo.toml index 05151fc9..c9088d14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/provider", "crates/pod-registry", "crates/session-metrics", + "crates/lint-common", "crates/tools", "crates/tui", "crates/memory", @@ -28,6 +29,7 @@ client = { path = "crates/client" } llm-worker = { path = "crates/llm-worker", version = "0.2" } llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" } manifest = { path = "crates/manifest" } +lint-common = { path = "crates/lint-common" } memory = { path = "crates/memory" } workflow = { path = "crates/workflow" } pod-registry = { path = "crates/pod-registry" } diff --git a/TODO.md b/TODO.md index a941b72c..1dab3486 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,6 @@ - 半自動開発運用 Workflow → [tickets/auto-maintain-workflow.md](tickets/auto-maintain-workflow.md) - AI maintainer 用 WorkItem / Thread 抽象 → [tickets/maintainer-work-items.md](tickets/maintainer-work-items.md) - Prompt / Workflow 評価メトリクスと改善 Offer → [tickets/prompt-eval-metrics.md](tickets/prompt-eval-metrics.md) - - memory / workflow 共通基盤(Slug / frontmatter helpers)を別 crate に切り出す → [tickets/lint-common-crate.md](tickets/lint-common-crate.md) - Permission: allow-all 既定 policy への整理 → [tickets/permission-default-policy.md](tickets/permission-default-policy.md) - Pod CLI: マニフェスト関連フラグの整理 → [tickets/pod-cli-manifest-flags.md](tickets/pod-cli-manifest-flags.md) - Pod: 空応答ターン (Submit 後 AI 応答ゼロで Pause/Cancel) を自動巻き戻し → [tickets/pod-empty-turn-rollback.md](tickets/pod-empty-turn-rollback.md) diff --git a/crates/lint-common/Cargo.toml b/crates/lint-common/Cargo.toml new file mode 100644 index 00000000..75e55487 --- /dev/null +++ b/crates/lint-common/Cargo.toml @@ -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 } diff --git a/crates/lint-common/src/frontmatter.rs b/crates/lint-common/src/frontmatter.rs new file mode 100644 index 00000000..695484b3 --- /dev/null +++ b/crates/lint-common/src/frontmatter.rs @@ -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>; + fn updated_at(&self) -> Option>; +} + +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, ""); + } +} diff --git a/crates/lint-common/src/lib.rs b/crates/lint-common/src/lib.rs new file mode 100644 index 00000000..b6335100 --- /dev/null +++ b/crates/lint-common/src/lib.rs @@ -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, +} diff --git a/crates/memory/src/slug.rs b/crates/lint-common/src/slug.rs similarity index 92% rename from crates/memory/src/slug.rs rename to crates/lint-common/src/slug.rs index c0650ec5..fda3f80b 100644 --- a/crates/memory/src/slug.rs +++ b/crates/lint-common/src/slug.rs @@ -12,7 +12,7 @@ use std::str::FromStr; use serde::{Deserialize, Deserializer, Serialize}; -use crate::error::LintError; +use crate::RecordLintError; const MIN_LEN: usize = 1; const MAX_LEN: usize = 64; @@ -23,13 +23,13 @@ const MAX_LEN: usize = 64; pub struct Slug(String); impl Slug { - /// Parse and validate. Returns [`LintError::InvalidSlug`] on rejection. - pub fn parse(s: impl Into) -> Result { + /// Parse and validate. Returns [`RecordLintError::InvalidSlug`] on rejection. + pub fn parse(s: impl Into) -> Result { let s = s.into(); if is_valid_slug(&s) { Ok(Self(s)) } else { - Err(LintError::InvalidSlug(s)) + Err(RecordLintError::InvalidSlug(s)) } } @@ -55,7 +55,7 @@ impl AsRef for Slug { } impl FromStr for Slug { - type Err = LintError; + type Err = RecordLintError; fn from_str(s: &str) -> Result { Self::parse(s) diff --git a/crates/memory/Cargo.toml b/crates/memory/Cargo.toml index 330657fe..316e4280 100644 --- a/crates/memory/Cargo.toml +++ b/crates/memory/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true async-trait = { workspace = true } chrono = { version = "0.4", features = ["serde"] } libc = { workspace = true } +lint-common = { workspace = true } llm-worker = { workspace = true } manifest = { workspace = true } schemars = { workspace = true } diff --git a/crates/memory/src/consolidate/tidy.rs b/crates/memory/src/consolidate/tidy.rs index 89fdbdbd..2c613f78 100644 --- a/crates/memory/src/consolidate/tidy.rs +++ b/crates/memory/src/consolidate/tidy.rs @@ -13,10 +13,10 @@ use std::collections::{BTreeMap, BTreeSet}; +use crate::Slug; use crate::schema::{ DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter, }; -use crate::slug::Slug; use crate::workspace::{RecordKind, WorkspaceLayout}; /// `sources` overflow を flag する閾値。`linter::warnings::SOURCES_OVERFLOW_THRESHOLD` diff --git a/crates/memory/src/error.rs b/crates/memory/src/error.rs index 0e9cebdd..746d2231 100644 --- a/crates/memory/src/error.rs +++ b/crates/memory/src/error.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; +use lint_common::RecordLintError; use thiserror::Error; /// 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())] WrongRecordKind(PathBuf), - #[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), diff --git a/crates/memory/src/lib.rs b/crates/memory/src/lib.rs index 2b3d51b8..25fb35b4 100644 --- a/crates/memory/src/lib.rs +++ b/crates/memory/src/lib.rs @@ -13,17 +13,16 @@ pub mod linter; pub mod resident; pub mod schema; pub mod scope; -pub mod slug; pub mod tool; pub mod usage; pub mod workspace; pub use error::{LintError, LintWarning, MemoryError}; pub use extract::ExtractPointerPayload; +pub use lint_common::{RecordLintError, Slug, is_valid_slug}; pub use linter::{LintReport, Linter}; pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge, list_knowledge_slugs}; pub use scope::deny_write_rules; -pub use slug::Slug; pub use usage::{ UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource, append_resident_exposure_event, append_usage_event, append_use_event, build_usage_report, diff --git a/crates/memory/src/linter/existing.rs b/crates/memory/src/linter/existing.rs index 53427e5e..ea99901d 100644 --- a/crates/memory/src/linter/existing.rs +++ b/crates/memory/src/linter/existing.rs @@ -9,10 +9,10 @@ use std::collections::{HashMap, HashSet}; use std::io; use std::path::Path; +use crate::Slug; use crate::schema::{ DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter, }; -use crate::slug::Slug; use crate::workspace::{RecordKind, WorkspaceLayout}; /// Snapshot of every record currently on disk under the workspace. diff --git a/crates/memory/src/linter/frontmatter.rs b/crates/memory/src/linter/frontmatter.rs index 767e2ac2..bf0a3756 100644 --- a/crates/memory/src/linter/frontmatter.rs +++ b/crates/memory/src/linter/frontmatter.rs @@ -1,5 +1,6 @@ //! YAML frontmatter parsing helpers shared by every kind. +use lint_common::RecordLintError; use serde::de::DeserializeOwned; use crate::error::LintError; @@ -26,7 +27,7 @@ fn map_serde_error(err: serde_yaml::Error) -> LintError { } return LintError::InvalidField { field, message }; } - LintError::MalformedFrontmatter(msg) + LintError::Record(RecordLintError::MalformedFrontmatter(msg)) } fn parse_missing_field(msg: &str) -> Option<&'static str> { diff --git a/crates/memory/src/linter/mod.rs b/crates/memory/src/linter/mod.rs index 3039df49..3f4e9235 100644 --- a/crates/memory/src/linter/mod.rs +++ b/crates/memory/src/linter/mod.rs @@ -18,6 +18,7 @@ mod warnings; use std::path::Path; +use lint_common::RecordLintError; use serde::de::DeserializeOwned; use crate::error::{LintError, LintWarning}; @@ -104,8 +105,8 @@ impl Linter { let existing = match existing::scan_existing(&self.layout) { Ok(e) => e, Err(e) => { - report.push_error(LintError::MalformedFrontmatter(format!( - "failed to scan existing records: {e}" + report.push_error(LintError::Record(RecordLintError::MalformedFrontmatter( + format!("failed to scan existing records: {e}"), ))); return report; } @@ -354,7 +355,8 @@ mod tests { let report = linter.lint(&path, &content, WriteMode::Create); assert!(report.errors.iter().any(|e| matches!( e, - LintError::MissingField(_) | LintError::MalformedFrontmatter(_) + LintError::MissingField(_) + | LintError::Record(RecordLintError::MalformedFrontmatter(_)) ))); } diff --git a/crates/memory/src/linter/references.rs b/crates/memory/src/linter/references.rs index fc9dccae..e6d116c6 100644 --- a/crates/memory/src/linter/references.rs +++ b/crates/memory/src/linter/references.rs @@ -2,10 +2,10 @@ use std::collections::HashSet; +use crate::Slug; use crate::error::LintError; use crate::linter::ExistingRecords; use crate::linter::LintReport; -use crate::slug::Slug; use crate::workspace::RecordKind; /// Validate a Decision's `replaced_by` against the existing record set. diff --git a/crates/memory/src/linter/warnings.rs b/crates/memory/src/linter/warnings.rs index 2dcc5744..d8a57525 100644 --- a/crates/memory/src/linter/warnings.rs +++ b/crates/memory/src/linter/warnings.rs @@ -4,10 +4,10 @@ //! integrated into the main linter pass when implemented; this file //! covers per-write checks that only need the proposed content. +use crate::Slug; use crate::error::LintWarning; use crate::linter::LintReport; use crate::linter::existing::ExistingRecords; -use crate::slug::Slug; use crate::workspace::{ClassifiedPath, RecordKind}; const LARGE_BODY_THRESHOLD: usize = 1500; diff --git a/crates/memory/src/schema/common.rs b/crates/memory/src/schema/common.rs index fcd511dd..91099999 100644 --- a/crates/memory/src/schema/common.rs +++ b/crates/memory/src/schema/common.rs @@ -1,10 +1,11 @@ //! Common frontmatter helpers and shared types. -use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::error::LintError; +pub use lint_common::Frontmatter; + /// Reference to a session-store entry range. Stored in `sources` / /// `last_sources` arrays for traceability back to raw session logs. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -14,53 +15,15 @@ pub struct SourceRef { 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; - fn updated_at(&self) -> DateTime; -} - -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), LintError> { - // 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(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)) + lint_common::split_frontmatter(content).map_err(Into::into) } #[cfg(test)] mod tests { use super::*; + use lint_common::RecordLintError; #[test] fn splits_simple() { @@ -73,13 +36,19 @@ mod tests { #[test] fn no_leading_delim_errors() { let err = split_frontmatter("hello").unwrap_err(); - assert!(matches!(err, LintError::MissingFrontmatter)); + assert!(matches!( + err, + LintError::Record(RecordLintError::MissingFrontmatter) + )); } #[test] fn no_closing_delim_errors() { let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err(); - assert!(matches!(err, LintError::MalformedFrontmatter(_))); + assert!(matches!( + err, + LintError::Record(RecordLintError::MalformedFrontmatter(_)) + )); } #[test] diff --git a/crates/memory/src/schema/decision.rs b/crates/memory/src/schema/decision.rs index a236ff32..e64700ae 100644 --- a/crates/memory/src/schema/decision.rs +++ b/crates/memory/src/schema/decision.rs @@ -3,8 +3,8 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use crate::Slug; use crate::schema::common::{Frontmatter, SourceRef}; -use crate::slug::Slug; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -27,10 +27,10 @@ pub struct DecisionFrontmatter { impl Frontmatter for DecisionFrontmatter { const BODY_LIMIT: usize = 8000; - fn created_at(&self) -> DateTime { - self.created_at + fn created_at(&self) -> Option> { + Some(self.created_at) } - fn updated_at(&self) -> DateTime { - self.updated_at + fn updated_at(&self) -> Option> { + Some(self.updated_at) } } diff --git a/crates/memory/src/schema/knowledge.rs b/crates/memory/src/schema/knowledge.rs index 12e35211..5e493f5f 100644 --- a/crates/memory/src/schema/knowledge.rs +++ b/crates/memory/src/schema/knowledge.rs @@ -24,10 +24,10 @@ pub struct KnowledgeFrontmatter { impl Frontmatter for KnowledgeFrontmatter { const BODY_LIMIT: usize = 8000; - fn created_at(&self) -> DateTime { - self.created_at + fn created_at(&self) -> Option> { + Some(self.created_at) } - fn updated_at(&self) -> DateTime { - self.updated_at + fn updated_at(&self) -> Option> { + Some(self.updated_at) } } diff --git a/crates/memory/src/schema/request.rs b/crates/memory/src/schema/request.rs index f0190fd5..1b60c7b9 100644 --- a/crates/memory/src/schema/request.rs +++ b/crates/memory/src/schema/request.rs @@ -15,10 +15,10 @@ pub struct RequestFrontmatter { impl Frontmatter for RequestFrontmatter { const BODY_LIMIT: usize = 8000; - fn created_at(&self) -> DateTime { - self.created_at + fn created_at(&self) -> Option> { + Some(self.created_at) } - fn updated_at(&self) -> DateTime { - self.updated_at + fn updated_at(&self) -> Option> { + Some(self.updated_at) } } diff --git a/crates/memory/src/schema/summary.rs b/crates/memory/src/schema/summary.rs index 53aa49b4..a3156238 100644 --- a/crates/memory/src/schema/summary.rs +++ b/crates/memory/src/schema/summary.rs @@ -23,10 +23,10 @@ impl Frontmatter for SummaryFrontmatter { /// than per-record kinds (~5k tokens at the upper end). const BODY_LIMIT: usize = 20000; - fn created_at(&self) -> DateTime { - self.created_at.unwrap_or(self.updated_at) + fn created_at(&self) -> Option> { + Some(self.created_at.unwrap_or(self.updated_at)) } - fn updated_at(&self) -> DateTime { - self.updated_at + fn updated_at(&self) -> Option> { + Some(self.updated_at) } } diff --git a/crates/memory/src/tool/mod.rs b/crates/memory/src/tool/mod.rs index 2e0f3725..a6802c28 100644 --- a/crates/memory/src/tool/mod.rs +++ b/crates/memory/src/tool/mod.rs @@ -15,7 +15,7 @@ use std::path::PathBuf; use llm_worker::tool::ToolError; use serde::Deserialize; -use crate::slug::Slug; +use crate::Slug; use crate::workspace::{RecordKind, WorkspaceLayout}; pub use edit::edit_tool; diff --git a/crates/memory/src/usage.rs b/crates/memory/src/usage.rs index 20f42d6c..f2d3e8ab 100644 --- a/crates/memory/src/usage.rs +++ b/crates/memory/src/usage.rs @@ -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) } diff --git a/crates/memory/src/workspace.rs b/crates/memory/src/workspace.rs index 68834e32..f1d40902 100644 --- a/crates/memory/src/workspace.rs +++ b/crates/memory/src/workspace.rs @@ -22,8 +22,9 @@ use std::path::{Path, PathBuf}; +use crate::Slug; use crate::error::LintError; -use crate::slug::Slug; +use lint_common::RecordLintError; const INSOMNIA_DIR: &str = ".insomnia"; const MEMORY_DIR: &str = "memory"; @@ -159,7 +160,7 @@ impl WorkspaceLayout { /// /// On a conventional path that's *almost* a record but malformed /// (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. pub fn classify(&self, path: &Path) -> Result, LintError> { let memory = self.memory_dir(); @@ -320,7 +321,10 @@ mod tests { let err = layout() .classify(&PathBuf::from("/ws/.insomnia/memory/decisions/Foo.md")) .unwrap_err(); - assert!(matches!(err, LintError::InvalidSlug(_))); + assert!(matches!( + err, + LintError::Record(RecordLintError::InvalidSlug(_)) + )); } #[test] diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 07ffe93f..496f9961 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -1221,7 +1221,7 @@ impl Pod { continue; }; let parsed = workflow_crate::Slug::parse(slug.clone()) - .map_err(WorkflowResolveError::InvalidSlug)?; + .map_err(|source| WorkflowResolveError::InvalidSlug(source.into()))?; let record = self .workflow_registry .get(&parsed) diff --git a/crates/pod/src/workflow/mod.rs b/crates/pod/src/workflow/mod.rs index 97824650..0d0c3feb 100644 --- a/crates/pod/src/workflow/mod.rs +++ b/crates/pod/src/workflow/mod.rs @@ -77,7 +77,8 @@ pub fn resolve_workflow_invocation( layout: &WorkspaceLayout, raw_slug: &str, ) -> Result, 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 .get(&slug) .ok_or_else(|| WorkflowResolveError::NotFound { diff --git a/crates/workflow/Cargo.toml b/crates/workflow/Cargo.toml index daf7228d..61ee98a9 100644 --- a/crates/workflow/Cargo.toml +++ b/crates/workflow/Cargo.toml @@ -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"] } diff --git a/crates/workflow/src/error.rs b/crates/workflow/src/error.rs index 6e9ba3e0..76e9ce50 100644 --- a/crates/workflow/src/error.rs +++ b/crates/workflow/src/error.rs @@ -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), diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index 065f5cac..a4e3f077 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -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, diff --git a/crates/workflow/src/linter.rs b/crates/workflow/src/linter.rs index e5cf7b1f..1243e0cc 100644 --- a/crates/workflow/src/linter.rs +++ b/crates/workflow/src/linter.rs @@ -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( if let Some(field) = parse_missing_field(&msg) { WorkflowLintError::MissingField(field) } else { - WorkflowLintError::MalformedFrontmatter(msg) + WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(msg)) } })?; Ok(Parsed { frontmatter, body }) diff --git a/crates/workflow/src/schema.rs b/crates/workflow/src/schema.rs index 9538a4bf..a76253bc 100644 --- a/crates/workflow/src/schema.rs +++ b/crates/workflow/src/schema.rs @@ -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, } +impl Frontmatter for WorkflowFrontmatter { + const BODY_LIMIT: usize = WORKFLOW_BODY_LIMIT; + + fn created_at(&self) -> Option> { + self.created_at + } + + fn updated_at(&self) -> Option> { + 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] diff --git a/crates/workflow/src/skill.rs b/crates/workflow/src/skill.rs index 9450701a..e88ec530 100644 --- a/crates/workflow/src/skill.rs +++ b/crates/workflow/src/skill.rs @@ -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 Result) -> Result { - 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 for Slug { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl FromStr for Slug { - type Err = WorkflowLintError; - - fn from_str(s: &str) -> Result { - Self::parse(s) - } -} - -impl<'de> Deserialize<'de> for Slug { - fn deserialize(deserializer: D) -> Result - 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 = serde_json::from_str(bad); - assert!(err.is_err()); - } -} diff --git a/crates/workflow/src/workflow.rs b/crates/workflow/src/workflow.rs index cbe74260..b03aecf3 100644 --- a/crates/workflow/src/workflow.rs +++ b/crates/workflow/src/workflow.rs @@ -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 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> { diff --git a/tickets/lint-common-crate.md b/tickets/lint-common-crate.md deleted file mode 100644 index d130e0da..00000000 --- a/tickets/lint-common-crate.md +++ /dev/null @@ -1,61 +0,0 @@ -# memory / workflow の共通基盤を別 crate に切り出す - -## 背景 - -`tickets/workflow-crate-extraction.md`(完了済 / git log 参照)で Workflow を `crates/memory/` から `crates/workflow/` に切り出した際、依存方向を「workflow → memory(`WorkspaceLayout` のみ)」に限定するため、本来共通であるべき型・関数を **両 crate にコピペで重複** させて済ませている。 - -具体的に重複しているもの: - -- **`Slug` 型と `is_valid_slug`**: `crates/memory/src/slug.rs` と `crates/workflow/src/slug.rs` がエラー型(`LintError::InvalidSlug` / `WorkflowLintError::InvalidSlug`)以外完全に同じ。テストごと丸ごとコピー。 -- **`split_frontmatter`**: `crates/memory/src/schema/common.rs` と `crates/workflow/src/schema.rs` に同等の実装。返すエラー型だけ違う。 -- **YAML frontmatter の `MissingFrontmatter` / `MalformedFrontmatter` バリアント**: `LintError` と `WorkflowLintError` の両方に重複定義。 -- **`Frontmatter` trait(`created_at` / `updated_at` の統一アクセス)**: 現状 memory 側だけにあり、workflow 側の `WorkflowFrontmatter` は同 trait を実装していない。共通 crate に出るなら、workflow 側でも揃えられる。 - -memory / workflow どちらも agent-skills 互換のスラグ規約と Markdown + YAML frontmatter の同一フォーマットを採用しているため、これらは設計上「両者が共有すべき同一の概念」であって、別物として持つ理由はない。`tickets/workflow-crate-extraction.md` も完了条件と直交する形で「共有が必要なら共通部分を別 crate(例: `crates/lint-common/`)に切る判断を行う」と前置きしており、抽出時にスキップした判断を本チケットで補う。 - -## 要件 - -### 新 crate の新設 - -memory / workflow 双方が依存する共通 crate を 1 つ立てる。crate 名は実装時に決める(候補: `lint-common`, `record-core`, `frontmatter` など)。memory / workflow より下層に位置し、両者が import する。 - -新 crate が持つもの: - -- `Slug` 型 + `is_valid_slug`(agent-skills 互換規約) -- `split_frontmatter`(YAML frontmatter / Markdown body 分離) -- 上記に紐づく共通エラー型(`InvalidSlug` / `MissingFrontmatter` / `MalformedFrontmatter`) -- `Frontmatter` trait(`BODY_LIMIT` / `created_at` / `updated_at` のアクセサ) - -### memory / workflow からの重複削除 - -- `crates/memory/src/slug.rs` と `crates/workflow/src/slug.rs` を削除し、新 crate の `Slug` を再 export または直接 import する形に書き換える -- `crates/memory/src/schema/common.rs` 内の `split_frontmatter` と `crates/workflow/src/schema.rs` 内の `split_frontmatter` を新 crate のものに統合 -- `LintError` / `WorkflowLintError` の `InvalidSlug` / `MissingFrontmatter` / `MalformedFrontmatter` バリアントは、共通エラー型を `#[from]` で包む形に揃えるか、共通エラー型をそのまま使う形に切り替える(実装時に判断) -- `WorkflowFrontmatter` も共通 `Frontmatter` trait を実装するように揃える(`BODY_LIMIT` を 8000 で踏襲) - -### 依存方向 - -- 新 crate は memory / workflow / その他に依存しない(純粋なドメイン型のみ) -- memory / workflow 双方が新 crate を import する -- workflow → memory の `WorkspaceLayout` 依存は維持(このチケットの対象外) - -## 範囲外 - -- linter 本体の共通化(memory `Linter` と workflow `WorkflowLinter` の統合) -- `WorkspaceLayout` の memory crate からの切り出し -- `WorkflowFrontmatter` / `KnowledgeFrontmatter` 等のスキーマ変更 -- agent-skills 互換規約自体の変更 - -## 完了条件 - -- 新 crate が `Slug` / `is_valid_slug` / `split_frontmatter` / 共通エラー型 / `Frontmatter` trait を提供している -- `crates/memory/src/slug.rs` と `crates/workflow/src/slug.rs` の重複コードが消えている(少なくとも一方からは) -- `split_frontmatter` の実装が 1 箇所に集約されている -- `WorkflowFrontmatter` が `Frontmatter` trait を実装している -- 既存テスト(memory / workflow / pod)が新構造で通る -- 循環依存が無い - -## 参照 - -- 直前: `tickets/workflow-crate-extraction.md`(git log、`workflow-crate-extraction.review.md` で本件が見落とされた経緯あり) -- 関連: `tickets/internal-worker-workflow.md`(本チケット完了後に着手すると共通基盤が揃った状態で進められる)