memory-file-format完了

This commit is contained in:
2026-04-27 13:59:04 +09:00
parent f2e47629d0
commit cd8b620e6a
6 changed files with 246 additions and 224 deletions
+170 -7
View File
@@ -126,13 +126,18 @@ impl Linter {
}
}
// Similar-slug clustering warning. Skipped for Summary (no slug).
if let Some(slug) = &classified.slug {
warnings::check_similar_slugs(slug, classified.kind, &existing, &mut report);
}
// 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);
self.check_request(content, &classified, &mut report);
}
RecordKind::Knowledge => {
self.check_knowledge(content, &classified, &mut report);
@@ -163,6 +168,22 @@ impl Linter {
let _ = parsed.frontmatter; // discarded after structural checks
}
fn check_request(&self, content: &str, _cp: &ClassifiedPath, report: &mut LintReport) {
let parsed = match parse_frontmatter::<RequestFrontmatter>(content) {
Ok(p) => p,
Err(e) => {
report.push_error(e);
return;
}
};
size::check_body::<RequestFrontmatter>(parsed.body, report);
warnings::check_warnings_with_sources(
parsed.body,
parsed.frontmatter.sources.len(),
report,
);
}
fn check_decision(
&self,
content: &str,
@@ -229,12 +250,45 @@ impl Linter {
}
}
/// 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)
impl Linter {
/// Workflow record validator exposed for human-edit paths
/// (CLI / pre-commit). Not used by the memory tool, which rejects
/// workflow writes outright.
///
/// Verifies frontmatter shape, body size, and that every slug in
/// `requires` points at an existing Knowledge record under the
/// workspace's `knowledge/` directory.
pub fn lint_workflow(&self, content: &str) -> LintReport {
let mut report = LintReport::default();
let parsed = match parse_frontmatter::<WorkflowFrontmatter>(content) {
Ok(p) => p,
Err(e) => {
report.push_error(e);
return report;
}
};
size::check_body::<WorkflowFrontmatter>(parsed.body, &mut report);
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;
}
};
for slug in &parsed.frontmatter.requires {
if !existing.contains(crate::workspace::RecordKind::Knowledge, slug) {
report.push_error(LintError::UnknownReference {
field: "requires",
kind: "knowledge",
slug: slug.to_string(),
});
}
}
report
}
}
struct Parsed<'a, F> {
@@ -417,6 +471,115 @@ mod tests {
assert!(report.errors.iter().any(|e| matches!(e, LintError::SlugAlreadyExists(_))));
}
#[test]
fn workflow_lint_accepts_valid_record() {
let (dir, linter) = workspace();
// Place a Knowledge record that the workflow will reference.
let kn = dir.path().join("knowledge/foo.md");
write(
&kn,
&format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nkind: rule\ndescription: x\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\n",
n = iso_now()
),
);
let wf = format!(
"---\nupdated_at: {n}\ndescription: do thing\nauto_invoke: false\nuser_invocable: true\nrequires: [foo]\n---\nstep 1\n",
n = iso_now()
);
let report = linter.lint_workflow(&wf);
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
}
#[test]
fn workflow_lint_flags_unknown_requires() {
let (_dir, linter) = workspace();
let wf = format!(
"---\nupdated_at: {n}\ndescription: x\nauto_invoke: false\nuser_invocable: true\nrequires: [missing-knowledge]\n---\n",
n = iso_now()
);
let report = linter.lint_workflow(&wf);
assert!(report.errors.iter().any(|e| matches!(
e,
LintError::UnknownReference {
field: "requires",
kind: "knowledge",
..
}
)));
}
#[test]
fn workflow_lint_collects_multiple_unknown_requires() {
let (_dir, linter) = workspace();
let wf = format!(
"---\nupdated_at: {n}\ndescription: x\nauto_invoke: false\nuser_invocable: true\nrequires: [a, b, c]\n---\n",
n = iso_now()
);
let report = linter.lint_workflow(&wf);
let unknown_count = report
.errors
.iter()
.filter(|e| matches!(e, LintError::UnknownReference { .. }))
.count();
assert_eq!(unknown_count, 3);
}
#[test]
fn similar_slugs_warns_on_cluster() {
let (dir, linter) = workspace();
// Two existing decisions within Levenshtein 2 of `db-pool`:
// `db-pol` (1 deletion), `db-pools` (1 insertion).
for slug in ["db-pol", "db-pools"] {
write(
&dir.path().join(format!("memory/decisions/{slug}.md")),
&format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n",
n = iso_now()
),
);
}
let path = dir.path().join("memory/decisions/db-pool.md");
let content = format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\nbody\n",
n = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
let warned = report
.warnings
.iter()
.any(|w| matches!(w, LintWarning::SimilarSlugs(slugs) if slugs.len() >= 3));
assert!(warned, "expected SimilarSlugs warning, got {:?}", report.warnings);
}
#[test]
fn similar_slugs_silent_when_distant() {
let (dir, linter) = workspace();
for slug in ["alpha", "bravo"] {
write(
&dir.path().join(format!("memory/decisions/{slug}.md")),
&format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n",
n = iso_now()
),
);
}
let path = dir.path().join("memory/decisions/charlie.md");
let content = format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n",
n = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(
!report
.warnings
.iter()
.any(|w| matches!(w, LintWarning::SimilarSlugs(_))),
"unexpected SimilarSlugs warning: {:?}",
report.warnings
);
}
#[test]
fn body_size_limit_errors() {
let (dir, linter) = workspace();
+75 -1
View File
@@ -6,10 +6,17 @@
use crate::error::LintWarning;
use crate::linter::LintReport;
use crate::workspace::ClassifiedPath;
use crate::linter::existing::ExistingRecords;
use crate::slug::Slug;
use crate::workspace::{ClassifiedPath, RecordKind};
const LARGE_BODY_THRESHOLD: usize = 1500;
const SOURCES_OVERFLOW_THRESHOLD: usize = 10;
const SIMILAR_SLUG_DISTANCE: usize = 2;
/// Cluster size (including the new slug) at which the similar-slug
/// warning fires. 3 follows `docs/plan/memory.md` §Linter (`類似 slug
/// 乱立`) — two existing close neighbours plus the new write.
const SIMILAR_SLUG_CLUSTER_MIN: usize = 3;
/// For kinds that don't carry a `sources` array (Summary), emit only
/// the body-size warning.
@@ -32,3 +39,70 @@ pub fn check_warnings_with_sources(body: &str, source_count: usize, report: &mut
});
}
}
/// Emit a `SimilarSlugs` warning when the proposed slug joins a cluster
/// of `SIMILAR_SLUG_CLUSTER_MIN` or more slugs in the same kind that
/// are pairwise within `SIMILAR_SLUG_DISTANCE` Levenshtein steps of the
/// new one. The reported list includes the new slug, sorted to keep
/// the warning text deterministic.
pub fn check_similar_slugs(
new_slug: &Slug,
kind: RecordKind,
existing: &ExistingRecords,
report: &mut LintReport,
) {
let mut neighbours: Vec<String> = existing
.slugs(kind)
.into_iter()
.filter(|s| *s != new_slug)
.filter(|s| levenshtein(new_slug.as_str(), s.as_str()) <= SIMILAR_SLUG_DISTANCE)
.map(|s| s.to_string())
.collect();
if neighbours.len() + 1 < SIMILAR_SLUG_CLUSTER_MIN {
return;
}
neighbours.push(new_slug.to_string());
neighbours.sort();
report.push_warning(LintWarning::SimilarSlugs(neighbours));
}
/// Iterative two-row Levenshtein distance over chars.
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr: Vec<usize> = vec![0; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
curr[j + 1] = (curr[j] + 1)
.min(prev[j + 1] + 1)
.min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levenshtein_basics() {
assert_eq!(levenshtein("", ""), 0);
assert_eq!(levenshtein("a", ""), 1);
assert_eq!(levenshtein("", "ab"), 2);
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("foo", "foo"), 0);
assert_eq!(levenshtein("abc", "abd"), 1);
assert_eq!(levenshtein("abcd", "acbd"), 2);
}
}