メモリーに関するクレート作成・ファイル構造の実装
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
//! Walks `<workspace>/memory/{decisions,requests}/`, `memory/workflow/`,
|
||||
//! and `<workspace>/knowledge/` to collect the slug set the linter
|
||||
//! needs for reference-integrity and same-slug-duplication checks.
|
||||
//!
|
||||
//! No caching: each lint call walks fresh. Tree size is expected to
|
||||
//! stay small (hundreds of files, not thousands).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::schema::{
|
||||
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, WorkflowFrontmatter,
|
||||
split_frontmatter,
|
||||
};
|
||||
use crate::slug::Slug;
|
||||
use crate::workspace::{RecordKind, WorkspaceLayout};
|
||||
|
||||
/// Snapshot of every record currently on disk under the workspace.
|
||||
///
|
||||
/// Carries enough metadata to answer:
|
||||
/// - "does slug X of kind K exist?" (same-slug duplication, reference checks)
|
||||
/// - "what is X's `replaced_by`?" (cycle detection)
|
||||
/// - "what other slugs of kind K exist?" (similar-slug warning)
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ExistingRecords {
|
||||
decisions: HashMap<Slug, DecisionMeta>,
|
||||
requests: HashSet<Slug>,
|
||||
knowledge: HashSet<Slug>,
|
||||
workflow: HashSet<Slug>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecisionMeta {
|
||||
pub replaced_by: Option<Slug>,
|
||||
}
|
||||
|
||||
impl ExistingRecords {
|
||||
pub fn contains(&self, kind: RecordKind, slug: &Slug) -> bool {
|
||||
match kind {
|
||||
RecordKind::Decision => self.decisions.contains_key(slug),
|
||||
RecordKind::Request => self.requests.contains(slug),
|
||||
RecordKind::Knowledge => self.knowledge.contains(slug),
|
||||
RecordKind::Workflow => self.workflow.contains(slug),
|
||||
RecordKind::Summary => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decision(&self, slug: &Slug) -> Option<&DecisionMeta> {
|
||||
self.decisions.get(slug)
|
||||
}
|
||||
|
||||
pub fn slugs(&self, kind: RecordKind) -> Vec<&Slug> {
|
||||
match kind {
|
||||
RecordKind::Decision => self.decisions.keys().collect(),
|
||||
RecordKind::Request => self.requests.iter().collect(),
|
||||
RecordKind::Knowledge => self.knowledge.iter().collect(),
|
||||
RecordKind::Workflow => self.workflow.iter().collect(),
|
||||
RecordKind::Summary => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the workspace and collect every record.
|
||||
pub fn scan_existing(layout: &WorkspaceLayout) -> io::Result<ExistingRecords> {
|
||||
let mut out = ExistingRecords::default();
|
||||
|
||||
scan_dir(&layout.decisions_dir(), |path, slug| {
|
||||
let meta = read_decision_meta(path);
|
||||
out.decisions.insert(slug, meta);
|
||||
})?;
|
||||
scan_dir(&layout.requests_dir(), |path, slug| {
|
||||
// Parse to validate but discard contents — only slug existence
|
||||
// matters for reference checks. Parse failure is silently
|
||||
// ignored: existing record corruption isn't this write's
|
||||
// responsibility to fix.
|
||||
let _ = parse_silent::<RequestFrontmatter>(path);
|
||||
out.requests.insert(slug);
|
||||
})?;
|
||||
scan_dir(&layout.knowledge_dir(), |path, slug| {
|
||||
let _ = parse_silent::<KnowledgeFrontmatter>(path);
|
||||
out.knowledge.insert(slug);
|
||||
})?;
|
||||
scan_dir(&layout.workflow_dir(), |path, slug| {
|
||||
let _ = parse_silent::<WorkflowFrontmatter>(path);
|
||||
out.workflow.insert(slug);
|
||||
})?;
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn scan_dir<F>(dir: &Path, mut visit: F) -> io::Result<()>
|
||||
where
|
||||
F: FnMut(&Path, Slug),
|
||||
{
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let stem = match path.file_stem().and_then(|s| s.to_str()) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if ext != "md" {
|
||||
continue;
|
||||
}
|
||||
if let Ok(slug) = Slug::parse(stem) {
|
||||
visit(&path, slug);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_decision_meta(path: &Path) -> DecisionMeta {
|
||||
match parse_silent::<DecisionFrontmatter>(path) {
|
||||
Some(fm) => DecisionMeta {
|
||||
replaced_by: fm.replaced_by,
|
||||
},
|
||||
None => DecisionMeta { replaced_by: None },
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_silent<F>(path: &Path) -> Option<F>
|
||||
where
|
||||
F: serde::de::DeserializeOwned,
|
||||
{
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let (yaml, _) = split_frontmatter(&content).ok()?;
|
||||
serde_yaml::from_str::<F>(yaml).ok()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! YAML frontmatter parsing helpers shared by every kind.
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::error::LintError;
|
||||
|
||||
/// Strict YAML deserialization that maps serde errors into the linter's
|
||||
/// `MissingField` / `InvalidField` / `MalformedFrontmatter` taxonomy
|
||||
/// when possible.
|
||||
pub fn deserialize_strict<F: DeserializeOwned>(yaml: &str) -> Result<F, LintError> {
|
||||
serde_yaml::from_str::<F>(yaml).map_err(map_serde_error)
|
||||
}
|
||||
|
||||
fn map_serde_error(err: serde_yaml::Error) -> LintError {
|
||||
let msg = err.to_string();
|
||||
|
||||
// `missing field \`X\`` is the exact pattern serde uses for missing
|
||||
// required fields. Hoist into the typed variant so the LLM sees a
|
||||
// crisp message it can act on.
|
||||
if let Some(field) = parse_missing_field(&msg) {
|
||||
return LintError::MissingField(field);
|
||||
}
|
||||
if let Some((field, message)) = parse_invalid_status(&msg) {
|
||||
if field == "status" {
|
||||
return LintError::InvalidStatus(message);
|
||||
}
|
||||
return LintError::InvalidField { field, message };
|
||||
}
|
||||
LintError::MalformedFrontmatter(msg)
|
||||
}
|
||||
|
||||
fn parse_missing_field(msg: &str) -> Option<&'static str> {
|
||||
let needle = "missing field `";
|
||||
let start = msg.find(needle)? + needle.len();
|
||||
let end = msg[start..].find('`')? + start;
|
||||
let field_name = &msg[start..end];
|
||||
static FIELDS: &[&str] = &[
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"sources",
|
||||
"status",
|
||||
"kind",
|
||||
"description",
|
||||
"model_invokation",
|
||||
"user_invocable",
|
||||
"last_sources",
|
||||
"auto_invoke",
|
||||
"requires",
|
||||
];
|
||||
FIELDS.iter().copied().find(|n| *n == field_name)
|
||||
}
|
||||
|
||||
fn parse_invalid_status(msg: &str) -> Option<(&'static str, String)> {
|
||||
// serde renders enum failures as: "unknown variant `Foo`, expected one of ..."
|
||||
// We can't reliably attribute it to a specific field from the message
|
||||
// alone, so we conservatively label it as `status` only when the
|
||||
// message mentions one of the DecisionStatus variants in the
|
||||
// expected set.
|
||||
if msg.contains("unknown variant") && msg.contains("`open`") {
|
||||
let needle = "unknown variant `";
|
||||
let start = msg.find(needle)? + needle.len();
|
||||
let end = msg[start..].find('`')? + start;
|
||||
let bad = msg[start..end].to_string();
|
||||
return Some(("status", bad));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Pre-write Linter for the memory subsystem.
|
||||
//!
|
||||
//! The linter is pure: given a [`WorkspaceLayout`], a target path, and
|
||||
//! the proposed file content (raw bytes), it returns a [`LintReport`]
|
||||
//! aggregating every applicable rule violation. The memory tool calls
|
||||
//! this *before* committing to fs and surfaces a non-empty `errors`
|
||||
//! collection back to the LLM as `ToolError::InvalidArgument`.
|
||||
//!
|
||||
//! Reference-integrity checks (`replaced_by` / `requires` existence,
|
||||
//! cycle detection) walk the whole `memory/` and `knowledge/` trees
|
||||
//! each call. No caching; the trees are expected to be small.
|
||||
|
||||
mod existing;
|
||||
mod frontmatter;
|
||||
mod references;
|
||||
mod size;
|
||||
mod warnings;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::error::{LintError, LintWarning};
|
||||
use crate::schema::{
|
||||
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, SummaryFrontmatter,
|
||||
WorkflowFrontmatter, split_frontmatter,
|
||||
};
|
||||
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
|
||||
|
||||
pub use existing::{ExistingRecords, scan_existing};
|
||||
|
||||
/// Aggregated linter result. `errors` empty ⇒ write proceeds.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct LintReport {
|
||||
pub errors: Vec<LintError>,
|
||||
pub warnings: Vec<LintWarning>,
|
||||
}
|
||||
|
||||
impl LintReport {
|
||||
pub fn has_errors(&self) -> bool {
|
||||
!self.errors.is_empty()
|
||||
}
|
||||
|
||||
pub fn extend_errors(&mut self, more: impl IntoIterator<Item = LintError>) {
|
||||
self.errors.extend(more);
|
||||
}
|
||||
|
||||
pub fn push_error(&mut self, err: LintError) {
|
||||
self.errors.push(err);
|
||||
}
|
||||
|
||||
pub fn push_warning(&mut self, w: LintWarning) {
|
||||
self.warnings.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
/// Operation context: is this a brand-new file or an update of an
|
||||
/// existing one? Affects same-slug duplication check.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WriteMode {
|
||||
Create,
|
||||
Update,
|
||||
}
|
||||
|
||||
/// Stateless entry point holding the workspace layout. Cheap to clone.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Linter {
|
||||
layout: WorkspaceLayout,
|
||||
}
|
||||
|
||||
impl Linter {
|
||||
pub fn new(layout: WorkspaceLayout) -> Self {
|
||||
Self { layout }
|
||||
}
|
||||
|
||||
pub fn layout(&self) -> &WorkspaceLayout {
|
||||
&self.layout
|
||||
}
|
||||
|
||||
/// Lint a proposed write to `path` with the given UTF-8 `content`.
|
||||
///
|
||||
/// `mode` tells the linter whether the path already existed at the
|
||||
/// moment of write — Create triggers same-slug duplication checks,
|
||||
/// Update doesn't.
|
||||
pub fn lint(&self, path: &Path, content: &str, mode: WriteMode) -> LintReport {
|
||||
let mut report = LintReport::default();
|
||||
|
||||
// 1. Path classification.
|
||||
let classified = match self.layout.classify(path) {
|
||||
Ok(Some(cp)) => cp,
|
||||
Ok(None) => {
|
||||
report.push_error(LintError::InvalidPath(path.to_path_buf()));
|
||||
return report;
|
||||
}
|
||||
Err(e) => {
|
||||
report.push_error(e);
|
||||
return report;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Workflow paths are sub-Worker-forbidden at the tool layer.
|
||||
if classified.kind == RecordKind::Workflow {
|
||||
report.push_error(LintError::WorkflowWriteForbidden);
|
||||
return report;
|
||||
}
|
||||
|
||||
// 3. Frontmatter parse + kind-specific structural checks +
|
||||
// size limits. Reference-integrity needs the existing
|
||||
// record set, fetched once below.
|
||||
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}"
|
||||
)));
|
||||
return report;
|
||||
}
|
||||
};
|
||||
|
||||
// Same-slug check on Create.
|
||||
if mode == WriteMode::Create {
|
||||
if let Some(slug) = &classified.slug {
|
||||
if existing.contains(classified.kind, slug) {
|
||||
report.push_error(LintError::SlugAlreadyExists(slug.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Frontmatter parse dispatch by kind.
|
||||
match classified.kind {
|
||||
RecordKind::Decision => {
|
||||
self.check_decision(content, &classified, &existing, &mut report);
|
||||
}
|
||||
RecordKind::Request => {
|
||||
self.check_kind::<RequestFrontmatter>(content, &classified, &mut report);
|
||||
}
|
||||
RecordKind::Knowledge => {
|
||||
self.check_knowledge(content, &classified, &mut report);
|
||||
}
|
||||
RecordKind::Summary => {
|
||||
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report);
|
||||
}
|
||||
RecordKind::Workflow => unreachable!("guarded above"),
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
fn check_kind<F>(&self, content: &str, cp: &ClassifiedPath, report: &mut LintReport)
|
||||
where
|
||||
F: DeserializeOwned + crate::schema::Frontmatter,
|
||||
{
|
||||
let parsed = match parse_frontmatter::<F>(content) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.push_error(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let body = parsed.body;
|
||||
size::check_body::<F>(body, report);
|
||||
warnings::check_warnings_kindless(cp, body, report);
|
||||
let _ = parsed.frontmatter; // discarded after structural checks
|
||||
}
|
||||
|
||||
fn check_decision(
|
||||
&self,
|
||||
content: &str,
|
||||
cp: &ClassifiedPath,
|
||||
existing: &ExistingRecords,
|
||||
report: &mut LintReport,
|
||||
) {
|
||||
let parsed = match parse_frontmatter::<DecisionFrontmatter>(content) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.push_error(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let fm = parsed.frontmatter;
|
||||
size::check_body::<DecisionFrontmatter>(parsed.body, report);
|
||||
|
||||
// replaced_by structural rules.
|
||||
if let Some(target) = &fm.replaced_by {
|
||||
if let Some(self_slug) = &cp.slug {
|
||||
if target == self_slug {
|
||||
report.push_error(LintError::ReplacedBySelf);
|
||||
}
|
||||
}
|
||||
references::check_replaced_by(
|
||||
cp.slug.as_ref(),
|
||||
target,
|
||||
existing,
|
||||
report,
|
||||
);
|
||||
}
|
||||
|
||||
warnings::check_warnings_with_sources(parsed.body, fm.sources.len(), report);
|
||||
}
|
||||
|
||||
fn check_knowledge(
|
||||
&self,
|
||||
content: &str,
|
||||
cp: &ClassifiedPath,
|
||||
report: &mut LintReport,
|
||||
) {
|
||||
let parsed = match parse_frontmatter::<KnowledgeFrontmatter>(content) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.push_error(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let fm = parsed.frontmatter;
|
||||
size::check_body::<KnowledgeFrontmatter>(parsed.body, report);
|
||||
|
||||
if fm.model_invokation
|
||||
&& fm.description.chars().count()
|
||||
> crate::schema::KNOWLEDGE_DESCRIPTION_HARD_CAP
|
||||
{
|
||||
report.push_error(LintError::DescriptionTooLong {
|
||||
actual: fm.description.chars().count(),
|
||||
limit: crate::schema::KNOWLEDGE_DESCRIPTION_HARD_CAP,
|
||||
});
|
||||
}
|
||||
|
||||
warnings::check_warnings_with_sources(parsed.body, fm.last_sources.len(), report);
|
||||
let _ = cp;
|
||||
}
|
||||
}
|
||||
|
||||
/// Workflow frontmatter validator exposed for human-edit paths
|
||||
/// (CLI / pre-commit). Not used by the memory tool, which rejects
|
||||
/// workflow writes outright.
|
||||
pub fn lint_workflow_frontmatter(content: &str) -> Result<WorkflowFrontmatter, LintError> {
|
||||
let parsed = parse_frontmatter::<WorkflowFrontmatter>(content)?;
|
||||
Ok(parsed.frontmatter)
|
||||
}
|
||||
|
||||
struct Parsed<'a, F> {
|
||||
frontmatter: F,
|
||||
body: &'a str,
|
||||
}
|
||||
|
||||
fn parse_frontmatter<F: DeserializeOwned>(content: &str) -> Result<Parsed<'_, F>, LintError> {
|
||||
let (yaml, body) = split_frontmatter(content)?;
|
||||
let fm = frontmatter::deserialize_strict::<F>(yaml)?;
|
||||
Ok(Parsed {
|
||||
frontmatter: fm,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write(p: &std::path::Path, content: &str) {
|
||||
if let Some(parent) = p.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(p, content).unwrap();
|
||||
}
|
||||
|
||||
fn iso_now() -> String {
|
||||
Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
fn workspace() -> (TempDir, Linter) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
let linter = Linter::new(layout);
|
||||
(dir, linter)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_write_rejected() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("memory/workflow/wf.md");
|
||||
let content = format!(
|
||||
"---\nupdated_at: {now}\ndescription: x\nauto_invoke: false\nuser_invocable: true\n---\nbody",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::WorkflowWriteForbidden)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_memory_tree_rejected() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("src/main.rs");
|
||||
let report = linter.lint(&path, "ignored", WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::InvalidPath(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_with_unknown_replaced_by_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: ghost\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(
|
||||
e,
|
||||
LintError::UnknownReference { .. }
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_replaced_by_self_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: foo\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Update);
|
||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::ReplacedBySelf)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_replaced_by_existing_ok() {
|
||||
let (dir, linter) = workspace();
|
||||
// Pre-create the target.
|
||||
let target = dir.path().join("memory/decisions/bar.md");
|
||||
write(
|
||||
&target,
|
||||
&format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nbar body\n",
|
||||
now = iso_now()
|
||||
),
|
||||
);
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: bar\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_field_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
// Missing `status`.
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(
|
||||
e,
|
||||
LintError::MissingField(_) | LintError::MalformedFrontmatter(_)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knowledge_long_description_with_model_invokation_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("knowledge/foo.md");
|
||||
let big_desc = "x".repeat(2000);
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nkind: rule\ndescription: {big_desc}\nmodel_invokation: true\nuser_invocable: true\nlast_sources: []\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::DescriptionTooLong { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knowledge_long_description_without_model_invokation_ok() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("knowledge/foo.md");
|
||||
let big_desc = "x".repeat(2000);
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nkind: rule\ndescription: {big_desc}\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_path_accepted() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("memory/summary.md");
|
||||
let content = format!(
|
||||
"---\nupdated_at: {now}\n---\nsummary body\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Update);
|
||||
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_when_existing_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
write(
|
||||
&path,
|
||||
&format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nold\n",
|
||||
now = iso_now()
|
||||
),
|
||||
);
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nnew\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::SlugAlreadyExists(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_size_limit_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
let big_body = "x".repeat(8001);
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\n{body}",
|
||||
now = iso_now(),
|
||||
body = big_body
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::BodyTooLong { .. })));
|
||||
// Sanity: ensure path was treated as PathBuf consistently.
|
||||
let _ = PathBuf::from(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Reference-integrity checks: `replaced_by` existence + cycle detection.
|
||||
//!
|
||||
//! `requires` (Workflow) is checked symmetrically when/if the Workflow
|
||||
//! linter is invoked from a human-edit path; the memory tool itself
|
||||
//! never writes Workflow records.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
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.
|
||||
///
|
||||
/// `self_slug` is the slug of the record currently being written (None
|
||||
/// only when the path was malformed and we shouldn't even reach here).
|
||||
pub fn check_replaced_by(
|
||||
self_slug: Option<&Slug>,
|
||||
target: &Slug,
|
||||
existing: &ExistingRecords,
|
||||
report: &mut LintReport,
|
||||
) {
|
||||
// Existence: target must already be a Decision on disk.
|
||||
if !existing.contains(RecordKind::Decision, target) {
|
||||
report.push_error(LintError::UnknownReference {
|
||||
field: "replaced_by",
|
||||
kind: "decision",
|
||||
slug: target.to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Cycle: walk the chain target → target.replaced_by → ... and
|
||||
// ensure we never revisit `self_slug` or any node twice.
|
||||
let mut visited = HashSet::new();
|
||||
if let Some(s) = self_slug {
|
||||
visited.insert(s.clone());
|
||||
}
|
||||
let mut cursor = Some(target.clone());
|
||||
let mut chain: Vec<String> = Vec::new();
|
||||
while let Some(node) = cursor {
|
||||
if !visited.insert(node.clone()) {
|
||||
chain.push(node.to_string());
|
||||
report.push_error(LintError::ReplacedByCycle {
|
||||
chain: chain.join(" -> "),
|
||||
});
|
||||
return;
|
||||
}
|
||||
chain.push(node.to_string());
|
||||
cursor = existing
|
||||
.decision(&node)
|
||||
.and_then(|m| m.replaced_by.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// Smoke test: cycle detection terminates on a 2-node loop where the
|
||||
// existing tree already contains A↔B and the new write would close
|
||||
// the loop. A direct unit test against `check_replaced_by` is
|
||||
// exercised by linter::tests; here we just guard the loop bound.
|
||||
#[test]
|
||||
fn empty_chain_terminates() {
|
||||
let mut report = LintReport::default();
|
||||
let existing = ExistingRecords::default();
|
||||
let target = Slug::parse("foo").unwrap();
|
||||
check_replaced_by(None, &target, &existing, &mut report);
|
||||
assert_eq!(report.errors.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Body size limit checks.
|
||||
|
||||
use crate::error::LintError;
|
||||
use crate::linter::LintReport;
|
||||
use crate::schema::Frontmatter;
|
||||
|
||||
pub fn check_body<F: Frontmatter>(body: &str, report: &mut LintReport) {
|
||||
let chars = body.chars().count();
|
||||
if chars > F::BODY_LIMIT {
|
||||
report.push_error(LintError::BodyTooLong {
|
||||
actual: chars,
|
||||
limit: F::BODY_LIMIT,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Soft warnings: low-importance large records, sources accumulation.
|
||||
//!
|
||||
//! Similar-slug warnings need the existing record set and are
|
||||
//! integrated into the main linter pass when implemented; this file
|
||||
//! covers per-write checks that only need the proposed content.
|
||||
|
||||
use crate::error::LintWarning;
|
||||
use crate::linter::LintReport;
|
||||
use crate::workspace::ClassifiedPath;
|
||||
|
||||
const LARGE_BODY_THRESHOLD: usize = 1500;
|
||||
const SOURCES_OVERFLOW_THRESHOLD: usize = 10;
|
||||
|
||||
/// For kinds that don't carry a `sources` array (Summary), emit only
|
||||
/// the body-size warning.
|
||||
pub fn check_warnings_kindless(_cp: &ClassifiedPath, body: &str, _report: &mut LintReport) {
|
||||
let _ = body;
|
||||
// Summary intentionally has no warning band — the per-record
|
||||
// size:importance heuristic doesn't apply to a single rolling file.
|
||||
}
|
||||
|
||||
/// For kinds with `sources` (Decisions / Requests / Knowledge), consult
|
||||
/// both the body length and the sources count.
|
||||
pub fn check_warnings_with_sources(body: &str, source_count: usize, report: &mut LintReport) {
|
||||
let chars = body.chars().count();
|
||||
if source_count <= 1 && chars >= LARGE_BODY_THRESHOLD {
|
||||
report.push_warning(LintWarning::LowImportanceLargeRecord { chars });
|
||||
}
|
||||
if source_count > SOURCES_OVERFLOW_THRESHOLD {
|
||||
report.push_warning(LintWarning::SourcesOverflow {
|
||||
count: source_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user