use std::{
collections::{BTreeMap, BTreeSet},
ffi::{OsStr, OsString},
fmt, fs, io,
path::{Path, PathBuf},
process::Command,
};
use chrono::{DateTime, NaiveDate, Utc};
use duckdb::{AccessMode, Config, Connection, params};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
pub const DEFAULT_PRIVATE_MEMORY_CONFIG_FILE: &str = ".opensymphony/memory/memory.yaml";
pub const DEFAULT_MEMORY_CONFIG_FILE: &str = "opensymphony-memory.yaml";
pub const FALLBACK_PRIVATE_MEMORY_CONFIG_FILE: &str = ".opensymphony/memory/config.yaml";
pub const DEFAULT_MEMORY_ROOT: &str = ".opensymphony/memory";
pub const DEFAULT_INDEX_FILE_NAME: &str = "memory.duckdb";
pub const DEFAULT_PUBLIC_DOCS_ROOT: &str = "docs";
pub const ISSUE_CAPSULE_BEGIN: &str = "<!-- BEGIN OPENSYMPHONY MANAGED ISSUE CAPSULE -->";
pub const ISSUE_CAPSULE_END: &str = "<!-- END OPENSYMPHONY MANAGED ISSUE CAPSULE -->";
pub const TOPIC_DOC_BEGIN: &str = "<!-- BEGIN OPENSYMPHONY MANAGED MEMORY SYNC -->";
pub const TOPIC_DOC_END: &str = "<!-- END OPENSYMPHONY MANAGED MEMORY SYNC -->";
const MEMORY_SCHEMA_VERSION: i64 = 3;
#[derive(Debug, Error)]
pub enum MemoryError {
#[error("failed to read {path}: {source}")]
ReadFile {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to create {path}: {source}")]
CreateDir {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to write {path}: {source}")]
WriteFile {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to parse YAML from {path}: {source}")]
ParseYaml {
path: PathBuf,
#[source]
source: serde_yaml::Error,
},
#[error("{path} lacks OKF YAML frontmatter")]
OkfMissingFrontmatter { path: PathBuf },
#[error("{path} has unterminated OKF YAML frontmatter")]
OkfUnterminatedFrontmatter { path: PathBuf },
#[error("failed to encode JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("failed to update DuckDB index {path}: {source}")]
DuckDb {
path: PathBuf,
#[source]
source: duckdb::Error,
},
#[error("failed to resolve {path}: {source}")]
ResolvePath {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("Linear operation failed: {0}")]
Linear(String),
#[error("{0}")]
InvalidInput(String),
#[error("{path} is outside the repository root {repo_root}")]
PathOutsideRepo { path: PathBuf, repo_root: PathBuf },
#[error("{path} is outside the OKF bundle root {bundle_root}")]
PathOutsideBundle { path: PathBuf, bundle_root: PathBuf },
#[error(
"{source}; additionally failed to remove OKF export staging directory `{path}` after the export failure: {cleanup}; remove the staging directory manually"
)]
OkfExportStagingCleanup {
path: PathBuf,
#[source]
source: Box<MemoryError>,
cleanup: Box<MemoryError>,
},
#[error("code-intelligence operation failed: {0}")]
CodeIntel(#[source] CodeIntelError),
}
impl From<CodeIntelError> for MemoryError {
fn from(error: CodeIntelError) -> Self {
match error {
CodeIntelError::InvalidInput(message) => Self::InvalidInput(message),
CodeIntelError::ResolvePath { path, source } => Self::ResolvePath { path, source },
CodeIntelError::PathOutsideRepo { path, repo_root } => {
Self::PathOutsideRepo { path, repo_root }
}
error => Self::CodeIntel(error),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryVisibility {
#[default]
Private,
Public,
}
impl MemoryVisibility {
pub fn as_str(self) -> &'static str {
match self {
Self::Private => "private",
Self::Public => "public",
}
}
}
impl fmt::Display for MemoryVisibility {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KnowledgeScopeKind {
LocalInstance,
Organization,
ProjectSet,
Project,
Milestone,
WorkItem,
Repository,
CodePath,
Area,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KnowledgeScope {
pub kind: KnowledgeScopeKind,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryRecordKind {
IssueCapsule,
TopicDoc,
CodeContext,
CodeSymbol,
CodeEdge,
CodeDiagnostic,
RunSummary,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryFreshness {
Current,
Stale,
#[default]
Unknown,
}
impl MemoryFreshness {
pub fn as_str(self) -> &'static str {
match self {
Self::Current => "current",
Self::Stale => "stale",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemorySourceRef {
pub kind: String,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoryRecord {
pub kind: MemoryRecordKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scope_refs: Vec<KnowledgeScope>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source_refs: Vec<MemorySourceRef>,
pub visibility: MemoryVisibility,
pub body_ref: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub indexed_at: Option<DateTime<Utc>>,
pub freshness: MemoryFreshness,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderStatus {
pub provider: String,
pub available: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
pub use crate::opensymphony_code_intel::{
CodeIntelArtifact as ProviderCodeIntelArtifact, CodeIntelError, CodeIntelProvider,
CodeIntelScope, CodeIntelScopeKind, CodeIntelSourceRef,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeIntelArtifact {
pub provider: String,
pub kind: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scope_refs: Vec<KnowledgeScope>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source_refs: Vec<MemorySourceRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit_sha: Option<String>,
#[serde(default)]
pub title: String,
#[serde(default)]
pub summary: String,
}
impl From<ProviderCodeIntelArtifact> for CodeIntelArtifact {
fn from(artifact: ProviderCodeIntelArtifact) -> Self {
Self {
provider: artifact.provider,
kind: artifact.kind,
scope_refs: artifact.scope_refs.into_iter().map(Into::into).collect(),
source_refs: artifact.source_refs.into_iter().map(Into::into).collect(),
path: artifact.path,
commit_sha: artifact.commit_sha,
title: artifact.title,
summary: artifact.summary,
}
}
}
impl From<CodeIntelArtifact> for ProviderCodeIntelArtifact {
fn from(artifact: CodeIntelArtifact) -> Self {
Self {
provider: artifact.provider,
kind: artifact.kind,
scope_refs: artifact.scope_refs.into_iter().map(Into::into).collect(),
source_refs: artifact.source_refs.into_iter().map(Into::into).collect(),
path: artifact.path,
commit_sha: artifact.commit_sha,
title: artifact.title,
summary: artifact.summary,
}
}
}
impl From<CodeIntelScopeKind> for KnowledgeScopeKind {
fn from(kind: CodeIntelScopeKind) -> Self {
match kind {
CodeIntelScopeKind::LocalInstance => Self::LocalInstance,
CodeIntelScopeKind::Organization => Self::Organization,
CodeIntelScopeKind::ProjectSet => Self::ProjectSet,
CodeIntelScopeKind::Project => Self::Project,
CodeIntelScopeKind::Milestone => Self::Milestone,
CodeIntelScopeKind::WorkItem => Self::WorkItem,
CodeIntelScopeKind::Repository => Self::Repository,
CodeIntelScopeKind::CodePath => Self::CodePath,
CodeIntelScopeKind::Area => Self::Area,
}
}
}
impl From<KnowledgeScopeKind> for CodeIntelScopeKind {
fn from(kind: KnowledgeScopeKind) -> Self {
match kind {
KnowledgeScopeKind::LocalInstance => Self::LocalInstance,
KnowledgeScopeKind::Organization => Self::Organization,
KnowledgeScopeKind::ProjectSet => Self::ProjectSet,
KnowledgeScopeKind::Project => Self::Project,
KnowledgeScopeKind::Milestone => Self::Milestone,
KnowledgeScopeKind::WorkItem => Self::WorkItem,
KnowledgeScopeKind::Repository => Self::Repository,
KnowledgeScopeKind::CodePath => Self::CodePath,
KnowledgeScopeKind::Area => Self::Area,
}
}
}
impl From<CodeIntelScope> for KnowledgeScope {
fn from(scope: CodeIntelScope) -> Self {
Self {
kind: scope.kind.into(),
id: scope.id,
label: scope.label,
}
}
}
impl From<KnowledgeScope> for CodeIntelScope {
fn from(scope: KnowledgeScope) -> Self {
Self {
kind: scope.kind.into(),
id: scope.id,
label: scope.label,
}
}
}
impl From<CodeIntelSourceRef> for MemorySourceRef {
fn from(source_ref: CodeIntelSourceRef) -> Self {
Self {
kind: source_ref.kind,
id: source_ref.id,
url: source_ref.url,
}
}
}
impl From<MemorySourceRef> for CodeIntelSourceRef {
fn from(source_ref: MemorySourceRef) -> Self {
Self {
kind: source_ref.kind,
id: source_ref.id,
url: source_ref.url,
}
}
}
pub trait MemoryCatalog {
fn provider_status(&self) -> ProviderStatus;
}
pub trait DocumentStore {
fn read_document(&self, body_ref: &Path) -> Result<String, MemoryError>;
}
pub trait LexicalIndex {
fn search_text(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>, MemoryError>;
}
pub trait VectorIndex {
fn search_vectors(
&self,
query: &str,
scope_refs: &[KnowledgeScope],
limit: usize,
) -> Result<Vec<SearchResult>, MemoryError>;
}
pub trait CodeIntelIndex {
fn code_context(
&self,
paths: &[PathBuf],
scope_refs: &[KnowledgeScope],
limit: usize,
) -> Result<Vec<CodeIntelArtifact>, MemoryError>;
}
#[derive(Debug, Clone)]
pub struct CodeIntelProviderAdapter<P> {
provider: P,
}
impl<P> CodeIntelProviderAdapter<P> {
pub fn new(provider: P) -> Self {
Self { provider }
}
pub fn provider(&self) -> &P {
&self.provider
}
pub fn into_inner(self) -> P {
self.provider
}
}
impl<P> CodeIntelIndex for CodeIntelProviderAdapter<P>
where
P: CodeIntelProvider,
{
fn code_context(
&self,
paths: &[PathBuf],
scope_refs: &[KnowledgeScope],
limit: usize,
) -> Result<Vec<CodeIntelArtifact>, MemoryError> {
let provider_scope_refs = scope_refs
.iter()
.cloned()
.map(Into::into)
.collect::<Vec<_>>();
self.provider
.code_context(paths, &provider_scope_refs, limit)
.map(|artifacts| artifacts.into_iter().map(Into::into).collect())
.map_err(MemoryError::from)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodeIntelPersistBatch {
pub repo_id: String,
pub commit_sha: Option<String>,
pub worktree_dirty: bool,
pub documents: Vec<CodeIntelDocumentInput>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodeIntelDocumentInput {
pub path: PathBuf,
pub language: String,
pub content_sha256: String,
pub parser_id: String,
pub parser_version: String,
pub query_pack_version: String,
pub byte_len: usize,
pub line_count: usize,
pub symbols: Vec<CodeIntelSymbolInput>,
pub edges: Vec<CodeIntelEdgeInput>,
pub diagnostics: Vec<CodeIntelDiagnosticInput>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodeIntelSymbolInput {
pub kind: String,
pub name: String,
pub signature: Option<String>,
pub start_line: usize,
pub start_col: usize,
pub end_line: usize,
pub end_col: usize,
pub start_byte: usize,
pub end_byte: usize,
pub selection_start_line: usize,
pub selection_end_line: usize,
pub snippet_sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodeIntelEdgeInput {
pub edge_kind: String,
pub target_hint: Option<String>,
pub confidence: String,
pub start_line: usize,
pub start_col: usize,
pub end_line: usize,
pub end_col: usize,
pub start_byte: usize,
pub end_byte: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodeIntelDiagnosticInput {
pub kind: String,
pub severity: String,
pub message: String,
pub start_line: usize,
pub start_col: usize,
pub end_line: usize,
pub end_col: usize,
pub start_byte: usize,
pub end_byte: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodeIntelPersistReport {
pub parsed_files: usize,
pub persisted_documents: usize,
pub persisted_symbols: usize,
pub persisted_edges: usize,
pub persisted_diagnostics: usize,
pub stale_rows: usize,
pub skipped_files: Vec<String>,
pub diagnostics: Vec<String>,
}
pub trait FusionRetriever {
fn retrieve(
&self,
query: &str,
scope_refs: &[KnowledgeScope],
limit: usize,
) -> Result<Vec<SearchResult>, MemoryError>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopVectorIndex;
impl VectorIndex for NoopVectorIndex {
fn search_vectors(
&self,
_query: &str,
_scope_refs: &[KnowledgeScope],
_limit: usize,
) -> Result<Vec<SearchResult>, MemoryError> {
Ok(Vec::new())
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopCodeIntelIndex;
impl CodeIntelIndex for NoopCodeIntelIndex {
fn code_context(
&self,
_paths: &[PathBuf],
_scope_refs: &[KnowledgeScope],
_limit: usize,
) -> Result<Vec<CodeIntelArtifact>, MemoryError> {
Ok(Vec::new())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceSnapshotPolicy {
Disabled,
#[default]
Hashes,
PrivateSnapshots,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryConfig {
pub enabled: bool,
pub code_intel: CodeIntelConfig,
pub config_path: PathBuf,
pub repo_root: PathBuf,
pub memory_root: PathBuf,
pub visibility: MemoryVisibility,
pub index_path: PathBuf,
pub confidence_threshold: u8,
pub source_snapshot_policy: SourceSnapshotPolicy,
pub markdown_indexes: bool,
pub docs: DocsConfig,
pub areas: BTreeMap<String, AreaConfig>,
pub redaction: RedactionConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodeIntelConfig {
pub enabled: bool,
pub ast: AstCodeIntelConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AstCodeIntelConfig {
pub enabled: bool,
pub max_file_bytes: u64,
pub max_files_per_request: usize,
pub max_matches_per_request: usize,
pub max_capture_bytes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocsConfig {
pub public_root: PathBuf,
pub default_visibility: MemoryVisibility,
pub deny_private_links: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AreaConfig {
pub slug: String,
pub title: String,
pub docs_target: PathBuf,
pub visibility: MemoryVisibility,
pub status: AreaStatus,
pub confidence: u8,
pub aliases: Vec<String>,
pub source_refs: AreaSourceRefs,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AreaStatus {
#[default]
Candidate,
Stable,
}
impl AreaStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Candidate => "candidate",
Self::Stable => "stable",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AreaSourceRefs {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub docs: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub linear_labels: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub linear_milestones: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub linear_issues: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub github_prs: Vec<String>,
}
impl AreaSourceRefs {
fn is_empty(&self) -> bool {
self.docs.is_empty()
&& self.linear_labels.is_empty()
&& self.linear_milestones.is_empty()
&& self.linear_issues.is_empty()
&& self.github_prs.is_empty()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RedactionConfig {
pub deny_patterns: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryInitPlan {
pub config_path: PathBuf,
pub config_contents: String,
pub gitignore_path: PathBuf,
pub gitignore_before: Option<String>,
pub gitignore_after: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemoryInitFileChange {
Created,
Updated,
Unchanged,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryInitApplyReport {
pub config_path: PathBuf,
pub config: MemoryInitFileChange,
pub gitignore_path: PathBuf,
pub gitignore: MemoryInitFileChange,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct MemoryConfigFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
code_intel: Option<CodeIntelConfigFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
memory_root: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
visibility: Option<MemoryVisibility>,
#[serde(default, skip_serializing_if = "Option::is_none")]
index_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
confidence_threshold: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
source_snapshots: Option<SourceSnapshotPolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
markdown_indexes: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
docs: Option<DocsConfigFile>,
#[serde(default)]
areas: BTreeMap<String, AreaConfigFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
redaction: Option<RedactionConfigFile>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct CodeIntelConfigFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
ast: Option<AstCodeIntelConfigFile>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct AstCodeIntelConfigFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_file_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_files_per_request: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_matches_per_request: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_matches_per_file: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_capture_bytes: Option<usize>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct DocsConfigFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
public_root: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
default_visibility: Option<MemoryVisibility>,
#[serde(default, skip_serializing_if = "Option::is_none")]
deny_private_links: Option<bool>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct AreaConfigFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
docs_target: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
visibility: Option<MemoryVisibility>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option<AreaStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
confidence: Option<u8>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
aliases: Vec<String>,
#[serde(default, skip_serializing_if = "AreaSourceRefs::is_empty")]
source_refs: AreaSourceRefs,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct RedactionConfigFile {
#[serde(default)]
deny_patterns: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceFile {
#[serde(default)]
pub issues: Vec<IssueEvidence>,
#[serde(default)]
pub prs: Vec<PullRequestEvidence>,
#[serde(default)]
pub overrides: BTreeMap<String, IssueOverride>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct IssueEvidence {
#[serde(default)]
pub id: Option<String>,
pub identifier: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub milestone: Option<String>,
#[serde(default)]
pub milestone_id: Option<String>,
#[serde(default)]
pub parent: Option<IssueLinkEvidence>,
#[serde(default)]
pub children: Vec<IssueLinkEvidence>,
#[serde(default)]
pub blocked_by: Vec<IssueLinkEvidence>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default)]
pub comments: Vec<CommentEvidence>,
#[serde(default)]
pub linked_prs: Vec<u64>,
#[serde(default)]
pub task_files: Vec<PathBuf>,
#[serde(default)]
pub updated_at: Option<DateTime<Utc>>,
#[serde(default)]
pub completed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct IssueLinkEvidence {
#[serde(default)]
pub id: Option<String>,
pub identifier: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub state: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommentEvidence {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub body: String,
#[serde(default)]
pub updated_at: Option<DateTime<Utc>>,
#[serde(default)]
pub source: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PullRequestEvidence {
pub number: u64,
#[serde(default)]
pub title: String,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub merge_sha: Option<String>,
#[serde(default)]
pub merged_at: Option<DateTime<Utc>>,
#[serde(default)]
pub commits: Vec<CommitEvidence>,
#[serde(default)]
pub changed_files: Vec<ChangedFileEvidence>,
#[serde(default)]
pub checks: Vec<CheckEvidence>,
#[serde(default)]
pub reviews: Vec<ReviewEvidence>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommitEvidence {
pub sha: String,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub timestamp: Option<DateTime<Utc>>,
#[serde(default)]
pub summary: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChangedFileEvidence {
pub path: PathBuf,
#[serde(default)]
pub change_kind: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckEvidence {
pub name: String,
#[serde(default)]
pub conclusion: Option<String>,
#[serde(default)]
pub completed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewEvidence {
#[serde(default)]
pub reviewer: Option<String>,
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub submitted_at: Option<DateTime<Utc>>,
#[serde(default)]
pub disposition: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct IssueOverride {
#[serde(default)]
pub prs: Vec<u64>,
#[serde(default)]
pub areas: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IssueSelection {
pub identifiers: Vec<String>,
pub milestone: Option<String>,
pub state: Option<String>,
pub before_date: Option<NaiveDate>,
pub before_issue: Option<String>,
pub area: Option<String>,
pub since_last_sync: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapturePlan {
pub write: bool,
pub selected: Vec<CaptureIssuePlan>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CaptureIssuePlan {
pub issue: IssueEvidence,
pub prs: Vec<PullRequestEvidence>,
pub capsule_path: PathBuf,
pub areas: Vec<String>,
pub docs_targets: Vec<PathBuf>,
pub source_hash: String,
pub already_captured: bool,
pub stale: bool,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CaptureWriteReport {
pub written_capsules: Vec<PathBuf>,
pub index_path: PathBuf,
pub markdown_indexes: Vec<PathBuf>,
pub milestone_nodes: Vec<PathBuf>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchResult {
pub issue_key: String,
pub title: String,
pub capsule_path: PathBuf,
pub areas: Vec<String>,
pub snippet: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoryScopeFilter {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_set: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub milestone: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issue: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub area: Option<String>,
#[serde(default)]
pub all_accessible: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusReport {
pub issue_count: usize,
pub warning_count: usize,
pub docs_pending_count: usize,
pub issues: Vec<StatusIssue>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryReindexReport {
pub issue_count: usize,
pub index_path: PathBuf,
pub markdown_indexes: Vec<PathBuf>,
pub warning_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusIssue {
pub issue_key: String,
pub title: String,
pub state: Option<String>,
pub milestone: Option<String>,
pub capsule_path: PathBuf,
pub visibility: MemoryVisibility,
pub areas: Vec<String>,
pub docs_sync_status: String,
pub warning_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LintReport {
pub findings: Vec<LintFinding>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LintFinding {
pub severity: LintSeverity,
pub path: Option<PathBuf>,
pub message: String,
pub next_command: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LintCode {
OkfPrivateMemoryLink,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LintSeverity {
Info,
Warn,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocsSyncPlan {
pub write: bool,
pub selected_issue_keys: Vec<String>,
pub targets: Vec<DocsTargetPlan>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocsTargetPlan {
pub area: String,
pub title: String,
pub path: PathBuf,
pub visibility: MemoryVisibility,
pub create: bool,
pub before: Option<String>,
pub after: String,
pub diff: String,
pub issue_keys: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchivePlan {
pub write: bool,
pub force: bool,
pub issues: Vec<ArchiveIssuePlan>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveIssuePlan {
pub issue_key: String,
pub eligible: bool,
pub reason: String,
pub capsule_path: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct IndexedIssue {
issue_key: String,
concept_id: String,
concept_type: String,
title: String,
description: Option<String>,
state: Option<String>,
milestone: Option<String>,
labels: Vec<String>,
tags: Vec<String>,
areas: Vec<String>,
capsule_path: PathBuf,
visibility: MemoryVisibility,
source_hash: String,
warning_count: usize,
docs_sync_status: String,
completion_time: Option<String>,
captured_at: String,
changed_files: Vec<PathBuf>,
scope_refs: Vec<KnowledgeScope>,
source_refs: Vec<MemorySourceRef>,
links: Vec<OkfLink>,
citations: Vec<OkfCitation>,
freshness: MemoryFreshness,
warnings: Vec<String>,
body: String,
}
include!("config.rs");
include!("okf.rs");
include!("capture.rs");
include!("query.rs");
include!("graph.rs");
include!("docs_sync.rs");
include!("archive.rs");
include!("capture_render.rs");
include!("index.rs");
include!("github.rs");
include!("util.rs");
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
#[derive(Debug)]
struct EchoCodeIntelProvider;
impl CodeIntelProvider for EchoCodeIntelProvider {
fn code_context(
&self,
paths: &[PathBuf],
scope_refs: &[CodeIntelScope],
limit: usize,
) -> Result<Vec<ProviderCodeIntelArtifact>, CodeIntelError> {
assert_eq!(
paths,
&[PathBuf::from("crates/opensymphony-memory/src/lib.rs")]
);
assert_eq!(limit, 7);
Ok(vec![ProviderCodeIntelArtifact {
provider: "echo".to_string(),
kind: "summary".to_string(),
scope_refs: scope_refs.to_vec(),
source_refs: vec![CodeIntelSourceRef {
kind: "path".to_string(),
id: "crates/opensymphony-memory/src/lib.rs".to_string(),
url: None,
}],
path: Some(PathBuf::from("crates/opensymphony-memory/src/lib.rs")),
commit_sha: Some("abc123".to_string()),
title: "Memory boundary".to_string(),
summary: "Adapter converted provider artifacts into memory artifacts.".to_string(),
}])
}
}
#[test]
fn code_intel_provider_adapter_preserves_memory_index_contract() {
let adapter = CodeIntelProviderAdapter::new(EchoCodeIntelProvider);
let scope = KnowledgeScope {
kind: KnowledgeScopeKind::WorkItem,
id: "COE-506".to_string(),
label: Some("Trait inversion".to_string()),
};
let artifacts = adapter
.code_context(
&[PathBuf::from("crates/opensymphony-memory/src/lib.rs")],
std::slice::from_ref(&scope),
7,
)
.expect("adapter should convert provider output");
assert_eq!(artifacts.len(), 1);
let artifact = &artifacts[0];
assert_eq!(artifact.provider, "echo");
assert_eq!(artifact.scope_refs, vec![scope]);
assert_eq!(
artifact.source_refs,
vec![MemorySourceRef {
kind: "path".to_string(),
id: "crates/opensymphony-memory/src/lib.rs".to_string(),
url: None,
}]
);
}
#[test]
fn noop_code_intel_index_keeps_memory_contract() {
let artifacts = NoopCodeIntelIndex
.code_context(
&[],
&[KnowledgeScope {
kind: KnowledgeScopeKind::Repository,
id: "repo".to_string(),
label: None,
}],
1,
)
.expect("noop index should not fail");
assert!(artifacts.is_empty());
}
#[test]
fn ensure_memory_initialized_creates_config_and_gitignore_policy_once() {
let repo = TempDir::new().expect("temp repo");
let first = ensure_memory_initialized(repo.path(), None).expect("memory init");
assert_eq!(first.config, MemoryInitFileChange::Created);
assert_eq!(first.gitignore, MemoryInitFileChange::Created);
assert!(
repo.path()
.join(DEFAULT_PRIVATE_MEMORY_CONFIG_FILE)
.is_file()
);
assert_eq!(
fs::read_to_string(repo.path().join(".gitignore")).expect(".gitignore"),
".opensymphony*\n!.opensymphony/\n.opensymphony/*\n!.opensymphony/memory/\n.opensymphony/memory/*\n!.opensymphony/memory/memory.yaml\n"
);
let second = ensure_memory_initialized(repo.path(), None).expect("memory init idempotent");
assert_eq!(second.config, MemoryInitFileChange::Unchanged);
assert_eq!(second.gitignore, MemoryInitFileChange::Unchanged);
}
#[test]
fn memory_completed_task_rows_join_pull_request_evidence() {
let repo = TempDir::new().expect("temp repo");
ensure_memory_initialized(repo.path(), None).expect("memory init");
let config = MemoryConfig::load(repo.path(), None).expect("config should load");
let source: SourceFile = serde_yaml::from_str(
r#"
issues:
- identifier: COE-123
title: WebSocket reconnect recovery
url: https://linear.app/example/issue/COE-123
state: Done
completed_at: 2026-06-13T17:00:00Z
labels: [runtime]
linked_prs: [456, 490]
prs:
- number: 456
title: COE-123 first attempt
url: https://github.com/example/repo/pull/456
branch: coe-123-attempt-1
- number: 490
title: COE-123 recover websocket reconnects
url: https://github.com/example/repo/pull/490
branch: coe-123-reconnect
merge_sha: abcdef1234567890
merged_at: 2026-06-13T16:00:00Z
"#,
)
.expect("source yaml should parse");
let selection = IssueSelection {
identifiers: vec!["COE-123".to_string()],
..IssueSelection::default()
};
let plan =
plan_capture(&config, &source, &selection, true, false).expect("capture should plan");
write_capture_plan(&config, &plan, false).expect("capture should write");
let rows = memory_completed_task_rows(&config, MemoryGraphAccess::AllAccessible)
.expect("completed rows should project");
assert_eq!(rows.len(), 1);
let row = &rows[0];
assert_eq!(row.issue_key, "COE-123");
assert_eq!(row.concept_id, "issues/COE-123");
assert_eq!(row.state.as_deref(), Some("Done"));
assert!(row.completed_at.is_some());
assert_eq!(row.url, None);
assert_eq!(row.prs.len(), 2);
assert_eq!(row.prs[0].number, 456);
assert!(!row.prs[0].merged);
assert_eq!(row.prs[1].number, 490);
assert!(row.prs[1].merged);
assert!(row.prs[1].merged_at.is_some());
}
#[test]
fn memory_completed_task_rows_keep_prs_after_okf_reindex() {
let repo = TempDir::new().expect("temp repo");
ensure_memory_initialized(repo.path(), None).expect("memory init");
let config = MemoryConfig::load(repo.path(), None).expect("config should load");
let source: SourceFile = serde_yaml::from_str(
r#"
issues:
- identifier: COE-123
title: WebSocket reconnect recovery
url: https://linear.app/example/issue/COE-123
state: Done
completed_at: 2026-06-13T17:00:00Z
linked_prs: [456, 490]
prs:
- number: 456
title: COE-123 first attempt
url: https://github.com/example/repo/pull/456
- number: 490
title: COE-123 recover websocket reconnects
url: https://github.com/example/repo/pull/490
merge_sha: abcdef1234567890
merged_at: 2026-06-13T16:00:00Z
"#,
)
.expect("source yaml should parse");
let selection = IssueSelection {
identifiers: vec!["COE-123".to_string()],
..IssueSelection::default()
};
let plan =
plan_capture(&config, &source, &selection, true, false).expect("capture should plan");
write_capture_plan(&config, &plan, false).expect("capture should write");
refresh_memory_index_from_okf(&config, &config.memory_root)
.expect("reindex should succeed");
let rows = memory_completed_task_rows(&config, MemoryGraphAccess::AllAccessible)
.expect("completed rows should project");
let row = rows
.iter()
.find(|row| row.issue_key == "COE-123")
.expect("completed capsule should project after reindex");
assert_eq!(row.prs.len(), 2, "PR evidence must survive OKF reindex");
assert_eq!(row.prs[0].number, 456);
assert!(!row.prs[0].merged);
assert_eq!(row.prs[1].number, 490);
assert!(row.prs[1].merged);
assert_eq!(
row.completed_at,
Some(
DateTime::parse_from_rfc3339("2026-06-13T17:00:00Z")
.expect("test timestamp")
.with_timezone(&Utc)
),
);
}
#[test]
fn okf_reindex_keeps_valid_prs_when_one_entry_is_malformed() {
let repo = TempDir::new().expect("temp repo");
ensure_memory_initialized(repo.path(), None).expect("memory init");
let config = MemoryConfig::load(repo.path(), None).expect("config should load");
let issues_dir = config.memory_root.join("issues");
fs::create_dir_all(&issues_dir).expect("issues dir");
fs::write(
issues_dir.join("COE-800.md"),
r#"---
type: issue-capsule
title: "COE-800: Mixed PR frontmatter"
state: Done
timestamp: 2026-06-15T10:00:00Z
prs:
- number: 512
url: https://github.com/example/repo/pull/512
merge_sha: deadbeef
- note: "malformed entry with no number"
opensymphony:
visibility: private
scope_refs:
- kind: work_item
id: COE-800
---
# COE-800: Mixed PR frontmatter
Body.
"#,
)
.expect("capsule should write");
refresh_memory_index_from_okf(&config, &config.memory_root)
.expect("reindex should succeed");
let rows = memory_completed_task_rows(&config, MemoryGraphAccess::AllAccessible)
.expect("completed rows should project");
let row = rows
.iter()
.find(|row| row.issue_key == "COE-800")
.expect("capsule should project");
assert_eq!(
row.prs.len(),
1,
"the valid PR must survive a malformed sibling"
);
assert_eq!(row.prs[0].number, 512);
assert!(row.prs[0].merged);
}
#[test]
fn memory_completed_task_rows_omit_completed_at_without_a_completion_timestamp() {
let repo = TempDir::new().expect("temp repo");
ensure_memory_initialized(repo.path(), None).expect("memory init");
let config = MemoryConfig::load(repo.path(), None).expect("config should load");
let issues_dir = config.memory_root.join("issues");
fs::create_dir_all(&issues_dir).expect("issues dir");
fs::write(
issues_dir.join("COE-700.md"),
r#"---
type: issue-capsule
title: "COE-700: Legacy done capsule"
description: Completed but timestamp-less.
state: Done
opensymphony:
visibility: private
scope_refs:
- kind: work_item
id: COE-700
---
# COE-700: Legacy done capsule
Body.
"#,
)
.expect("capsule should write");
refresh_memory_index_from_okf(&config, &config.memory_root)
.expect("reindex should succeed");
let rows = memory_completed_task_rows(&config, MemoryGraphAccess::AllAccessible)
.expect("completed rows should project");
let row = rows
.iter()
.find(|row| row.issue_key == "COE-700")
.expect("completed capsule should project as a completed task");
assert_eq!(row.state.as_deref(), Some("Done"));
assert_eq!(
row.completed_at, None,
"no completion timestamp must leave completed_at null, not fall back to captured_at",
);
}
#[test]
fn okf_parses_legacy_issue_capsule_without_losing_metadata() {
let repo = TempDir::new().expect("temp repo");
let capsule = r#"---
type: issue-capsule
visibility: private
issue: COE-123
title: "COE-123: WebSocket reconnect recovery"
milestone: "M3: Runtime"
milestone_id: milestone-3
linear_url: https://linear.app/example/issue/COE-123
areas:
- openhands-runtime
repository: OpenSymphony
prs:
- number: 456
url: https://github.com/example/repo/pull/456
merge_sha: abcdef1234567890
source_refs:
linear_issue: linear:COE-123
github_prs:
- github:pr:456
docs_sync:
status: pending
legacy_custom: keep-me
---
# COE-123: WebSocket reconnect recovery
See [runtime docs](/areas/openhands-runtime.md).
"#;
let concept = parse_okf_concept(repo.path(), Path::new("issues/COE-123.md"), capsule)
.expect("legacy issue capsule should parse");
assert_eq!(concept.id, "issues/COE-123");
assert_eq!(concept.frontmatter.concept_type, "issue-capsule");
assert_eq!(
concept.frontmatter.extra.get("legacy_custom"),
Some(&serde_yaml::Value::String("keep-me".to_string()))
);
assert!(concept.frontmatter.extra.contains_key("source_refs"));
let metadata = concept
.frontmatter
.opensymphony
.as_ref()
.expect("legacy fields should map to OpenSymphony metadata");
assert_eq!(metadata.visibility, Some(MemoryVisibility::Private));
assert_eq!(metadata.kind.as_deref(), Some("issue_capsule"));
assert!(
metadata.scope_refs.iter().any(|scope| {
scope.kind == KnowledgeScopeKind::WorkItem && scope.id == "COE-123"
})
);
assert!(metadata.scope_refs.iter().any(|scope| {
scope.kind == KnowledgeScopeKind::Milestone && scope.id == "milestone-3"
}));
assert!(metadata.scope_refs.iter().any(|scope| {
scope.kind == KnowledgeScopeKind::Area && scope.id == "openhands-runtime"
}));
assert!(metadata.scope_refs.iter().any(|scope| {
scope.kind == KnowledgeScopeKind::Repository && scope.id == "OpenSymphony"
}));
assert!(
metadata
.source_refs
.iter()
.any(|source| { source.kind == "linear_issue" && source.id == "COE-123" })
);
assert!(
metadata
.source_refs
.iter()
.any(|source| { source.kind == "github_pr" && source.id == "456" })
);
assert!(metadata.source_refs.iter().any(|source| {
source.kind == "github_pr"
&& source.id == "456"
&& source.url.as_deref() == Some("https://github.com/example/repo/pull/456")
}));
assert_eq!(concept.links[0].target, "/areas/openhands-runtime.md");
let rendered = render_okf_concept(&concept).expect("concept should render");
assert!(rendered.contains("legacy_custom: keep-me"));
assert!(rendered.contains("issue: COE-123"));
assert!(!rendered.contains("opensymphony:"));
}
#[test]
fn okf_explicit_opensymphony_metadata_round_trips() {
let repo = TempDir::new().expect("temp repo");
let original = r#"---
type: topic-doc
area: legacy-area
visibility: private
legacy_custom: keep-me
opensymphony:
visibility: public
kind: curated_topic
schema_version: 7
scope_refs:
- kind: area
id: explicit-area
---
# Runtime
"#;
let concept = parse_okf_concept(repo.path(), Path::new("areas/runtime.md"), original)
.expect("concept should parse");
assert!(!concept.derived_opensymphony);
let rendered = render_okf_concept(&concept).expect("concept should render");
assert!(rendered.contains("opensymphony:"));
assert!(rendered.contains("curated_topic"));
assert!(rendered.contains("explicit-area"));
assert!(rendered.contains("legacy_custom: keep-me"));
assert!(!rendered.contains("legacy-area"));
assert!(!rendered.contains("visibility: private"));
}
#[test]
fn okf_partial_explicit_opensymphony_preserves_unrepresented_legacy_fields() {
let repo = TempDir::new().expect("temp repo");
let original = r#"---
type: topic-doc
area: legacy-area
visibility: private
issue: COE-123
legacy_custom: keep-me
opensymphony:
kind: curated_topic
---
# Runtime
"#;
let concept = parse_okf_concept(repo.path(), Path::new("areas/runtime.md"), original)
.expect("concept should parse");
let rendered = render_okf_concept(&concept).expect("concept should render");
assert!(rendered.contains("opensymphony:"));
assert!(rendered.contains("kind: curated_topic"));
assert!(rendered.contains("area: legacy-area"));
assert!(rendered.contains("visibility: private"));
assert!(rendered.contains("issue: COE-123"));
assert!(rendered.contains("legacy_custom: keep-me"));
}
#[test]
fn okf_null_opensymphony_uses_legacy_source_of_truth() {
let repo = TempDir::new().expect("temp repo");
let original = r#"---
type: topic-doc
area: legacy-area
visibility: public
opensymphony: ~
---
# Runtime
"#;
let concept = parse_okf_concept(repo.path(), Path::new("areas/runtime.md"), original)
.expect("concept should parse");
assert!(concept.derived_opensymphony);
let rendered = render_okf_concept(&concept).expect("concept should render");
assert!(rendered.contains("area: legacy-area"));
assert!(rendered.contains("visibility: public"));
assert!(!rendered.contains("opensymphony:"));
}
#[test]
fn okf_demo_parse_render_preserves_legacy_source_of_truth() {
let repo = TempDir::new().expect("temp repo");
let original = r#"---
type: topic-doc
area: openhands-runtime
visibility: public
docs_sync:
status: pending
---
# Runtime
See [COE-123](/issues/COE-123.md).
"#;
let concept = parse_okf_concept(repo.path(), Path::new("./areas/./runtime.md"), original)
.expect("concept should parse");
let rendered = render_okf_concept(&concept).expect("concept should render");
println!("{rendered}");
assert_eq!(concept.path.as_path(), Path::new("areas/runtime.md"));
assert!(concept.derived_opensymphony);
assert!(rendered.contains("visibility: public"));
assert!(rendered.contains("docs_sync:"));
assert!(!rendered.contains("opensymphony:"));
}
#[test]
fn okf_parses_milestone_and_topic_doc_fixtures() {
let repo = TempDir::new().expect("temp repo");
let milestone = parse_okf_concept(
repo.path(),
Path::new("milestones/m3-runtime.md"),
r#"---
type: milestone-memory-node
milestone: "M3: Runtime"
updated_at: 2026-06-13T17:00:00Z
---
# M3: Runtime
- [COE-123](/issues/COE-123.md)
"#,
)
.expect("milestone node should parse");
let milestone_metadata = milestone
.frontmatter
.opensymphony
.as_ref()
.expect("milestone should map legacy fields");
assert_eq!(
milestone_metadata.kind.as_deref(),
Some("milestone_memory_node")
);
assert!(milestone_metadata.scope_refs.iter().any(|scope| {
scope.kind == KnowledgeScopeKind::Milestone && scope.id == "M3: Runtime"
}));
let topic = parse_okf_concept(
repo.path(),
Path::new("areas/openhands-runtime.md"),
r#"---
type: topic-doc
area: openhands-runtime
visibility: public
last_memory_sync: 2026-06-13T17:00:00Z
---
# OpenHands Runtime
See [COE-123](/issues/COE-123.md).
"#,
)
.expect("topic doc should parse");
let topic_metadata = topic
.frontmatter
.opensymphony
.as_ref()
.expect("topic should map legacy fields");
assert_eq!(topic_metadata.visibility, Some(MemoryVisibility::Public));
assert!(topic_metadata.scope_refs.iter().any(|scope| {
scope.kind == KnowledgeScopeKind::Area && scope.id == "openhands-runtime"
}));
assert_eq!(topic.links[0].target, "/issues/COE-123.md");
}
#[test]
fn okf_rejects_empty_type_and_escaping_paths() {
let empty_type = OkfFrontmatter::new("");
assert!(matches!(empty_type, Err(MemoryError::InvalidInput(_))));
let frontmatter = OkfFrontmatter::new("topic-doc").expect("frontmatter");
let escaped = OkfConcept::new("../escape.md", frontmatter.clone(), "");
assert!(matches!(escaped, Err(MemoryError::InvalidInput(_))));
let absolute = OkfConcept::new("/tmp/escape.md", frontmatter.clone(), "");
assert!(matches!(absolute, Err(MemoryError::InvalidInput(_))));
let contained = OkfConcept::new("./areas/./runtime.md", frontmatter.clone(), "")
.expect("curdir components should normalize away");
assert_eq!(contained.path.as_path(), Path::new("areas/runtime.md"));
let uppercase_markdown = OkfConcept::new("areas/runtime.MD", frontmatter.clone(), "")
.expect("markdown extension should be case-insensitive");
assert_eq!(
uppercase_markdown.path.as_path(),
Path::new("areas/runtime.MD")
);
let not_markdown = OkfConcept::new("areas/runtime.txt", frontmatter, "");
assert!(matches!(not_markdown, Err(MemoryError::InvalidInput(_))));
}
#[test]
fn okf_unknown_fields_round_trip_through_writer() {
let repo = TempDir::new().expect("temp repo");
let original = r#"---
type: topic-doc
title: Runtime
x_unknown:
nested: true
legacy_number: 7
---
# Runtime
"#;
let concept = parse_okf_concept(repo.path(), Path::new("areas/runtime.md"), original)
.expect("concept should parse");
let rendered = render_okf_concept(&concept).expect("concept should render");
let reparsed = parse_okf_concept(repo.path(), Path::new("areas/runtime.md"), &rendered)
.expect("rendered concept should parse");
assert_eq!(
reparsed.frontmatter.extra.get("x_unknown"),
concept.frontmatter.extra.get("x_unknown")
);
assert_eq!(
reparsed.frontmatter.extra.get("legacy_number"),
concept.frontmatter.extra.get("legacy_number")
);
assert_eq!(reparsed.body, "# Runtime\n");
}
#[test]
fn okf_frontmatter_accepts_real_markdown_delimiters() {
let repo = TempDir::new().expect("temp repo");
let contents =
"---\r\ntype: topic-doc\r\ntitle: Runtime\r\n\r\n--- \r\n\r\n# Runtime\r\n";
let concept = parse_okf_concept(repo.path(), Path::new("areas/runtime.md"), contents)
.expect("CRLF frontmatter should parse");
assert_eq!(concept.frontmatter.concept_type, "topic-doc");
assert_eq!(concept.body, "# Runtime\n");
}
#[test]
fn okf_frontmatter_does_not_close_on_indented_yaml_delimiter() {
let repo = TempDir::new().expect("temp repo");
let contents = r#"---
type: topic-doc
description: |
---
YAML literal content
---
# Runtime
"#;
let concept = parse_okf_concept(repo.path(), Path::new("areas/runtime.md"), contents)
.expect("indented yaml delimiter should not close frontmatter");
assert_eq!(
concept.frontmatter.description.as_deref(),
Some("---\nYAML literal content\n")
);
assert_eq!(concept.body, "# Runtime\n");
}
#[test]
fn okf_markdown_links_skip_images_code_and_escapes() {
let repo = TempDir::new().expect("temp repo");
let contents = r#"---
type: topic-doc
---

`[code](/ignored.md)`
\[escaped](/ignored.md)
[text [nested]](/issues/COE-123.md)
[paren](/issues/COE-124.md?query=(ok))
\
[reference link][runtime-ref]
[shortcut link]
<https://example.com/okf>
```text
[fenced](/ignored.md)
```
<!-- [commented](/ignored.md) -->
[runtime-ref]: /areas/runtime.md
[shortcut link]: /areas/shortcut.md
"#;
let concept = parse_okf_concept(repo.path(), Path::new("areas/runtime.md"), contents)
.expect("concept should parse");
assert_eq!(
concept
.links
.iter()
.map(|link| link.target.as_str())
.collect::<Vec<_>>(),
vec![
"/issues/COE-123.md",
"/issues/COE-124.md?query=(ok)",
"/issues/COE-125.md",
"/areas/runtime.md",
"/areas/shortcut.md",
"https://example.com/okf",
]
);
}
#[test]
fn okf_lint_fixture_reports_errors_warnings_and_info() {
let report = lint_okf_bundle(&okf_fixture("okf-migration"), false).expect("lint");
let has = |severity, text: &str| {
report
.findings
.iter()
.any(|finding| finding.severity == severity && finding.message.contains(text))
};
assert!(has(LintSeverity::Error, "lacks OKF YAML frontmatter"));
assert!(has(
LintSeverity::Error,
"frontmatter is not parseable YAML"
));
assert!(has(
LintSeverity::Error,
"frontmatter lacks non-empty `type`"
));
assert!(has(
LintSeverity::Error,
"reserved log.md must use ISO date headings"
));
assert!(has(LintSeverity::Error, "private export leak"));
assert!(has(LintSeverity::Warn, "missing recommended field(s)"));
assert!(has(LintSeverity::Warn, "broken Markdown link"));
assert!(has(LintSeverity::Warn, "wiki-only link"));
assert!(has(LintSeverity::Warn, "missing generated index.md"));
assert!(has(LintSeverity::Warn, "citation section missing"));
assert!(has(LintSeverity::Warn, "unknown type"));
assert!(has(LintSeverity::Info, "title can be synthesized"));
assert!(has(LintSeverity::Info, "description can be synthesized"));
assert!(has(LintSeverity::Info, "legacy field(s) retained"));
assert!(has(
LintSeverity::Info,
"bundle contains OpenSymphony extension fields"
));
let public_report =
lint_okf_bundle(&okf_fixture("okf-migration"), true).expect("public lint");
assert!(public_report.findings.iter().any(|finding| {
finding.severity == LintSeverity::Error
&& finding
.message
.contains("public export includes a private concept")
}));
let fixture = okf_fixture("okf-migration");
let capsule_path = Path::new("issues/COE-123.md");
let capsule =
fs::read_to_string(fixture.join(capsule_path)).expect("fixture capsule should read");
let concept =
parse_okf_concept(&fixture, capsule_path, &capsule).expect("legacy fixture parses");
let rendered = render_okf_concept(&concept).expect("legacy fixture renders");
assert!(rendered.contains("legacy_custom: keep-me"));
}
#[test]
fn reindex_from_okf_fixture_rebuilds_catalog_with_warning_metadata() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
let bundle = repo.path().join(".opensymphony/memory");
copy_dir_recursive(&okf_fixture("okf-reindex"), &bundle);
let report =
refresh_memory_index_from_okf(&config, &bundle).expect("OKF reindex should work");
let related = related_by_issue(&config, "COE-123", 10).expect("related memory");
let search_results = search(&config, "generic concept", 10).expect("search");
assert_eq!(report.issue_count, 2);
assert!(
report.warning_count >= 2,
"unknown type and broken link warnings should be indexed"
);
assert_eq!(related[0].issue_key, "COE-124");
assert!(
search_results
.iter()
.any(|result| result.issue_key == "COE-124")
);
let connection = Connection::open(&config.index_path).expect("index should open");
let (
concept_id,
concept_type,
description,
tags_json,
scope_refs_json,
source_refs_json,
links_json,
citations_json,
freshness,
warnings_json,
): (
String,
String,
String,
String,
String,
String,
String,
String,
String,
String,
) = connection
.query_row(
"SELECT concept_id, concept_type, description, tags_json, scope_refs_json, source_refs_json, links_json, citations_json, freshness, warnings_json FROM issues WHERE issue_key = 'COE-124'",
[],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
row.get(7)?,
row.get(8)?,
row.get(9)?,
))
},
)
.expect("generic concept should be indexed");
assert_eq!(concept_id, "issues/COE-124");
assert_eq!(concept_type, "generic-concept");
assert_eq!(description, "Unknown concept types stay query-compatible.");
assert!(tags_json.contains("okf"));
assert!(scope_refs_json.contains("COE-124"));
assert!(source_refs_json.contains("linear_issue"));
assert!(links_json.contains("missing.md"));
assert!(citations_json.contains("linear.app/example/issue/COE-124"));
assert_eq!(freshness, "stale");
assert!(warnings_json.contains("unknown type"));
assert!(warnings_json.contains("broken Markdown link"));
connection
.execute(
"INSERT INTO doc_memory_links (topic_doc, issue_key, visibility) VALUES (?, ?, ?)",
params!["docs/memory.md", "COE-124", "public"],
)
.expect("doc link should insert");
drop(connection);
refresh_memory_index_from_okf(&config, &bundle).expect("OKF reindex should repeat");
let connection = Connection::open(&config.index_path).expect("index should reopen");
let doc_link_count: i64 = connection
.query_row(
"SELECT COUNT(*) FROM doc_memory_links WHERE topic_doc = 'docs/memory.md' AND issue_key = 'COE-124'",
[],
|row| row.get(0),
)
.expect("doc link count should query");
assert_eq!(doc_link_count, 1);
}
#[test]
fn memory_graph_projection_filters_private_and_redacts_local_paths() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
let bundle = repo.path().join(".opensymphony/memory");
let issues_dir = bundle.join("issues");
copy_dir_recursive(&okf_fixture("okf-reindex"), &bundle);
fs::write(
issues_dir.join("COE-200.md"),
format!(
r#"---
type: topic-doc
title: "COE-200: Public graph concept"
description: Public graph DTO fixture.
resource: https://linear.app/example/issue/COE-123
tags: [memory, graph]
timestamp: 2026-06-22T10:00:00Z
unknown_field: keep-me
apisecret: api-secret-compact-fixture
apitoken: api-token-compact-fixture
authtoken: auth-token-compact-fixture
bearertoken: bearer-token-compact-fixture
csrf_token: csrf-token-fixture
encryptionkey: encryption-key-compact-fixture
idtoken: id-token-compact-fixture
refreshtoken: refresh-token-compact-fixture
signingkey: signing-key-compact-fixture
xsrf_token: xsrf-token-fixture
apikey: sk-live-compact-fixture
api_key: sk-live-public-fixture
apiKey: sk-live-camel-fixture
api_key_format: sk-live-prefix
accesstoken: access-token-compact-fixture
accessToken: access-token-camel-fixture
clientsecret: client-secret-compact-fixture
clientSecret: client-secret-camel-fixture
client_secret_64: client-secret-fixture
nested_secret:
token: nested-token-fixture
oauth_token: oauth-token-fixture
password_policy: rotate-often
passwords:
- plural-password-fixture
privatekey: private-key-compact-fixture
private_key_type: ed25519
secretariat: public-office
secret_key_algorithm: ed25519
session_cookies:
- session-cookie-fixture
sessionid: session-compact-fixture
session_id: session-fixture
token_bucket: graph-rate-limit
opensymphony:
visibility: public
credential_ref: local-secret-fixture
scope_refs:
- kind: work_item
id: COE-200
- kind: area
id: graph-view
source_refs:
- kind: linear_issue
id: COE-200
url: https://linear.app/example/issue/COE-200
citations:
- id: "1"
target: https://linear.app/example/issue/COE-200
label: COE-200
---
# COE-200: Public graph concept
Public graph body mentions .opensymphony/memory/issues/COE-999.md and {}.
Sibling prefix must not be mangled: {}bed.
See [private concept](COE-123.md), [external](https://example.com/reference),
and [external mirror](https://example.com/reference).
"#,
repo.path().display(),
repo.path().display()
),
)
.expect("public concept should write");
fs::write(
issues_dir.join("AAA-111.md"),
r#"---
type: issue
title: "AAA-111: Private ranked search match"
opensymphony:
visibility: private
scope_refs:
- kind: work_item
id: AAA-111
---
# AAA-111: Private ranked search match
public graph
"#,
)
.expect("private search concept should write");
fs::write(
issues_dir.join("COE-333.md"),
r#"---
type: issue
title: "COE-333: Isolated private concept"
description: No semantic graph edges.
opensymphony:
visibility: private
---
# COE-333: Isolated private concept
This concept intentionally has no tags, links, citations, resources, or refs.
"#,
)
.expect("isolated private concept should write");
fs::create_dir_all(bundle.join("backlog")).expect("backlog dir should write");
fs::write(
bundle.join("backlog/COE-222.md"),
r#"---
type: issue
title: "Backlog COE-222"
opensymphony:
visibility: public
scope_refs:
- kind: work_item
id: COE-222
---
# Backlog COE-222
"#,
)
.expect("backlog concept should write");
refresh_memory_index_from_okf(&config, &bundle).expect("OKF reindex should work");
let public_bundles =
memory_graph_bundles(&config, MemoryGraphAccess::Public).expect("bundles");
assert_eq!(public_bundles.bundles[0].concept_count, 2);
assert_eq!(
public_bundles.bundles[0].visibility,
MemoryGraphVisibility::Public
);
let all_graph = memory_graph_snapshot(
&config,
DEFAULT_MEMORY_GRAPH_BUNDLE_ID,
MemoryGraphAccess::AllAccessible,
)
.expect("graph snapshot");
assert_eq!(all_graph.metrics.broken_link_count, 1);
assert_eq!(all_graph.metrics.stale_concept_count, 1);
assert!(all_graph.metrics.warning_count >= 1);
assert_eq!(all_graph.metrics.orphan_count, 1);
let node_kinds = all_graph
.nodes
.iter()
.map(|node| node.kind)
.collect::<BTreeSet<_>>();
let edge_kinds = all_graph
.edges
.iter()
.map(|edge| edge.kind)
.collect::<BTreeSet<_>>();
use crate::opensymphony_gateway_schema::memory_graph::{
MemoryGraphEdgeKind as EdgeKind, MemoryGraphNodeKind as NodeKind,
};
for kind in [
NodeKind::Bundle,
NodeKind::Directory,
NodeKind::Concept,
NodeKind::Tag,
NodeKind::Resource,
NodeKind::Citation,
NodeKind::SourceRef,
NodeKind::Community,
] {
assert!(node_kinds.contains(&kind), "missing node kind {kind:?}");
}
for kind in [
EdgeKind::Contains,
EdgeKind::MarkdownLink,
EdgeKind::ExternalLink,
EdgeKind::Cites,
EdgeKind::TaggedWith,
EdgeKind::DescribesResource,
EdgeKind::ScopedTo,
EdgeKind::SourceSupportedBy,
EdgeKind::SameResource,
] {
assert!(edge_kinds.contains(&kind), "missing edge kind {kind:?}");
}
let parallel_external_edges = all_graph
.edges
.iter()
.filter(|edge| {
edge.kind == EdgeKind::ExternalLink
&& edge.target_id == "resource:https://example.com/reference"
})
.count();
assert_eq!(parallel_external_edges, 2);
let external_edge = all_graph
.edges
.iter()
.find(|edge| edge.kind == EdgeKind::ExternalLink)
.expect("external edge");
assert!(external_edge.id.starts_with("external_link:"));
assert!(!external_edge.id.starts_with("ExternalLink:"));
assert!(
all_graph
.edges
.iter()
.any(|edge| edge.kind == EdgeKind::MarkdownLink && edge.unresolved)
);
let linked_private_node = all_graph
.nodes
.iter()
.find(|node| node.id == "concept:issues/COE-123")
.expect("linked private concept node");
assert!(linked_private_node.metrics.degree > 0);
assert!(linked_private_node.metrics.centrality.is_some());
assert!(linked_private_node.metrics.bridge_score.is_some());
assert_eq!(
linked_private_node.metrics.community_id.as_deref(),
Some("area:openhands-runtime")
);
assert!(
all_graph
.edges
.iter()
.any(|edge| edge.id.ends_with(":label:external"))
);
assert!(
all_graph
.edges
.iter()
.any(|edge| edge.id.ends_with(":label:external%20mirror"))
);
let update = memory_graph_updated_event(
&config,
DEFAULT_MEMORY_GRAPH_BUNDLE_ID,
MemoryGraphAccess::AllAccessible,
)
.expect("update event payload");
assert_eq!(update.bundle_id, DEFAULT_MEMORY_GRAPH_BUNDLE_ID);
assert_eq!(
update.cursor.partition,
"memory-graph:local-default".to_string()
);
assert!(update.cursor.sequence > 0);
let later_update = memory_graph_updated_event(
&config,
DEFAULT_MEMORY_GRAPH_BUNDLE_ID,
MemoryGraphAccess::AllAccessible,
)
.expect("later update event payload");
assert!(later_update.cursor.sequence > update.cursor.sequence);
let public_graph = memory_graph_snapshot(
&config,
DEFAULT_MEMORY_GRAPH_BUNDLE_ID,
MemoryGraphAccess::Public,
)
.expect("public graph snapshot");
let public_json = serde_json::to_string(&public_graph).expect("public graph serializes");
assert!(public_json.contains("COE-200"));
assert!(!public_json.contains("concept:issues/COE-123"));
assert!(!public_json.contains("COE-123: OKF catalog rebuild"));
assert!(!public_json.contains("sk-live-public-fixture"));
assert!(!public_json.contains("api-secret-compact-fixture"));
assert!(!public_json.contains("api-token-compact-fixture"));
assert!(!public_json.contains("client-secret-fixture"));
assert!(!public_json.contains("nested-token-fixture"));
assert!(!public_json.contains("oauth-token-fixture"));
assert!(!public_json.contains("plural-password-fixture"));
assert!(!public_json.contains("session-cookie-fixture"));
assert!(!public_json.contains("session-fixture"));
assert!(!public_json.contains("local-secret-fixture"));
assert!(public_json.contains("ed25519"));
assert!(public_json.contains("sk-live-prefix"));
assert!(public_json.contains("graph-rate-limit"));
assert!(public_json.contains("[redacted-secret]"));
assert!(!public_json.contains(".opensymphony/memory"));
assert!(!public_json.contains(&format!("{}/", repo.path().display())));
assert!(!public_json.contains(&format!("{}.", repo.path().display())));
assert!(!public_json.contains("[redacted-local-path]bed"));
assert!(
public_graph
.communities
.iter()
.any(|community| community.label == "graph-view")
);
assert!(public_graph.communities.iter().all(|community| {
!community
.node_ids
.iter()
.any(|node_id| node_id == "tag:graph")
}));
let public_graph_with_aux = memory_graph_snapshot_with_options(
&config,
DEFAULT_MEMORY_GRAPH_BUNDLE_ID,
MemoryGraphAccess::Public,
MemoryGraphCommunityOptions {
include_tags: true,
include_citations: true,
include_source_refs: true,
},
)
.expect("public graph with auxiliary community inputs");
let graph_view = public_graph_with_aux
.communities
.iter()
.find(|community| community.id == "area:graph-view")
.expect("graph-view community");
assert!(
graph_view
.node_ids
.iter()
.any(|node_id| node_id == "tag:graph")
);
assert!(
graph_view
.node_ids
.iter()
.any(|node_id| node_id == "citation:1")
);
assert!(
graph_view
.node_ids
.iter()
.any(|node_id| { node_id == "source_ref:linear_issue:COE-200" })
);
assert!(
public_graph_with_aux
.filters_applied
.contains(&"communities:include_tags".to_string())
);
let detail = memory_concept_detail(
&config,
DEFAULT_MEMORY_GRAPH_BUNDLE_ID,
"issues/COE-200",
MemoryGraphAccess::Public,
)
.expect("concept detail");
assert_eq!(
detail.frontmatter_view.primary.get("type"),
Some(&serde_json::json!("topic-doc"))
);
assert!(
detail
.frontmatter_view
.opensymphony
.contains_key("scope_refs")
);
assert_eq!(
detail.frontmatter_view.unknown.get("unknown_field"),
Some(&serde_json::json!("keep-me"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("api_key"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("authtoken"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("bearertoken"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("csrf_token"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("encryptionkey"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("idtoken"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("refreshtoken"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("signingkey"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("xsrf_token"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("apikey"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("apiKey"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("accesstoken"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("accessToken"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("clientsecret"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("clientSecret"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("nested_secret"),
Some(&serde_json::json!({
"token": "[redacted-secret]"
}))
);
assert_eq!(
detail.frontmatter_view.unknown.get("client_secret_64"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("api_key_format"),
Some(&serde_json::json!("sk-live-prefix"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("password_policy"),
Some(&serde_json::json!("rotate-often"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("passwords"),
Some(&serde_json::json!(["[redacted-secret]"]))
);
assert_eq!(
detail.frontmatter_view.unknown.get("privatekey"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("oauth_token"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("private_key_type"),
Some(&serde_json::json!("ed25519"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("secretariat"),
Some(&serde_json::json!("public-office"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("secret_key_algorithm"),
Some(&serde_json::json!("ed25519"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("session_id"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("sessionid"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert_eq!(
detail.frontmatter_view.unknown.get("session_cookies"),
Some(&serde_json::json!(["[redacted-secret]"]))
);
assert_eq!(
detail.frontmatter_view.unknown.get("token_bucket"),
Some(&serde_json::json!("graph-rate-limit"))
);
assert_eq!(
detail.frontmatter_view.opensymphony.get("credential_ref"),
Some(&serde_json::json!("[redacted-secret]"))
);
assert!(!detail.links.is_empty());
assert!(!detail.citations.is_empty());
assert!(!detail.source_refs.is_empty());
assert!(!detail.body_markdown.contains(".opensymphony/memory"));
assert!(
!detail
.body_markdown
.contains(&format!("{}/", repo.path().display()))
);
assert!(
!detail
.body_markdown
.contains(&format!("{}.", repo.path().display()))
);
let bare_issue_detail = memory_concept_detail(
&config,
DEFAULT_MEMORY_GRAPH_BUNDLE_ID,
"COE-222",
MemoryGraphAccess::AllAccessible,
)
.expect("bare issue key lookup");
assert_eq!(bare_issue_detail.concept_id, "backlog/COE-222");
let wrong_path_detail = memory_concept_detail(
&config,
DEFAULT_MEMORY_GRAPH_BUNDLE_ID,
"issues/COE-222",
MemoryGraphAccess::AllAccessible,
);
assert!(matches!(
wrong_path_detail,
Err(MemoryGraphProjectionError::ConceptNotFound(_))
));
let search = memory_graph_search(&config, "public graph", 1, MemoryGraphAccess::Public)
.expect("public graph search");
assert_eq!(search.results.len(), 1);
assert_eq!(search.results[0].concept_id, "issues/COE-200");
}
#[test]
fn memory_graph_snapshot_metrics_count_only_semantic_orphans() {
use crate::opensymphony_gateway_schema::memory_graph::{
MemoryGraphEdgeKind as EdgeKind, MemoryGraphNodeKind as NodeKind,
};
let nodes = vec![
simple_node(
"bundle:local-default",
NodeKind::Bundle,
"bundle",
Some(DEFAULT_MEMORY_GRAPH_BUNDLE_ID),
),
simple_node(
"concept:isolated",
NodeKind::Concept,
"isolated",
Some(DEFAULT_MEMORY_GRAPH_BUNDLE_ID),
),
simple_node(
"concept:linked",
NodeKind::Concept,
"linked",
Some(DEFAULT_MEMORY_GRAPH_BUNDLE_ID),
),
simple_node(
"tag:memory",
NodeKind::Tag,
"memory",
Some(DEFAULT_MEMORY_GRAPH_BUNDLE_ID),
),
];
let mut edges = BTreeMap::new();
insert_edge(
&mut edges,
EdgeKind::Contains,
"bundle:local-default",
"concept:isolated",
None,
false,
);
insert_edge(
&mut edges,
EdgeKind::Contains,
"bundle:local-default",
"concept:linked",
None,
false,
);
insert_edge(
&mut edges,
EdgeKind::TaggedWith,
"concept:linked",
"tag:memory",
None,
false,
);
let metrics = graph_snapshot_metrics(&nodes, &edges.into_values().collect::<Vec<_>>());
assert_eq!(metrics.orphan_count, 1);
}
#[test]
fn memory_graph_bridge_score_counts_only_cross_community_edges() {
use crate::opensymphony_gateway_schema::memory_graph::{
MemoryGraphCommunity, MemoryGraphEdgeKind as EdgeKind, MemoryGraphNodeKind as NodeKind,
};
let mut nodes = vec![
simple_node(
"concept:left",
NodeKind::Concept,
"left",
Some(DEFAULT_MEMORY_GRAPH_BUNDLE_ID),
),
simple_node(
"concept:unknown",
NodeKind::Concept,
"unknown",
Some(DEFAULT_MEMORY_GRAPH_BUNDLE_ID),
),
simple_node(
"concept:right",
NodeKind::Concept,
"right",
Some(DEFAULT_MEMORY_GRAPH_BUNDLE_ID),
),
];
let communities = vec![
MemoryGraphCommunity {
id: "area:left".to_string(),
label: "left".to_string(),
node_ids: vec!["concept:left".to_string()],
concept_count: 1,
},
MemoryGraphCommunity {
id: "area:right".to_string(),
label: "right".to_string(),
node_ids: vec!["concept:right".to_string()],
concept_count: 1,
},
];
let mut edges = BTreeMap::new();
insert_edge(
&mut edges,
EdgeKind::MarkdownLink,
"concept:left",
"concept:unknown",
None,
false,
);
insert_edge(
&mut edges,
EdgeKind::MarkdownLink,
"concept:left",
"concept:right",
None,
false,
);
apply_node_metrics(
&mut nodes,
&edges.into_values().collect::<Vec<_>>(),
&communities,
);
let metric = |id: &str| {
&nodes
.iter()
.find(|node| node.id == id)
.expect("node")
.metrics
};
assert_eq!(metric("concept:left").bridge_score, Some(1.0));
assert_eq!(metric("concept:right").bridge_score, Some(1.0));
assert_eq!(metric("concept:unknown").bridge_score, None);
}
#[test]
fn memory_graph_path_redaction_respects_token_boundaries() {
assert_eq!(
replace_path_token("/tmp/repo/file.md", "/tmp/repo", "[redacted]"),
"[redacted]/file.md"
);
assert_eq!(
replace_path_token("/foo/tmp/repo/file.md", "/tmp/repo", "[redacted]"),
"/foo/tmp/repo/file.md"
);
assert_eq!(
replace_path_token("/tmp/repository/file.md", "/tmp/repo", "[redacted]"),
"/tmp/repository/file.md"
);
assert_eq!(
replace_path_token(
".opensymphony/memory/issues/COE-200.md",
".opensymphony/memory",
"[redacted-memory-path]",
),
"[redacted-memory-path]/issues/COE-200.md"
);
assert_eq!(
replace_path_token(
"prefix.opensymphony/memory/issues/COE-200.md",
".opensymphony/memory",
"[redacted-memory-path]",
),
"prefix.opensymphony/memory/issues/COE-200.md"
);
}
#[test]
fn memory_graph_resolves_relative_capsules_under_memory_root_first() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
fs::create_dir_all(repo.path().join("issues")).expect("repo issues dir");
fs::create_dir_all(config.memory_root.join("issues")).expect("memory issues dir");
fs::write(repo.path().join("issues/COE-200.md"), "repo file").expect("repo file");
fs::write(config.memory_root.join("issues/COE-200.md"), "memory file")
.expect("memory file");
assert_eq!(
resolve_index_path(&config, Path::new("issues/COE-200.md")),
config.memory_root.join("issues/COE-200.md")
);
assert_eq!(
resolve_index_path(
&config,
Path::new(DEFAULT_MEMORY_ROOT)
.join("issues/COE-200.md")
.as_path(),
),
repo.path()
.join(DEFAULT_MEMORY_ROOT)
.join("issues/COE-200.md")
);
}
#[test]
fn migration_adds_okf_columns_with_new_table_nullability() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
fs::create_dir_all(config.index_path.parent().expect("index parent"))
.expect("index parent should write");
let connection = Connection::open(&config.index_path).expect("index should open");
connection
.execute_batch(
r#"
CREATE TABLE issues (
issue_key TEXT PRIMARY KEY,
title TEXT NOT NULL,
state TEXT,
milestone TEXT,
labels_json TEXT NOT NULL,
completion_time TEXT,
archive_status TEXT NOT NULL,
capsule_path TEXT NOT NULL,
visibility TEXT NOT NULL,
source_hash TEXT NOT NULL,
warning_count BIGINT NOT NULL,
docs_sync_status TEXT NOT NULL,
body TEXT NOT NULL,
captured_at TEXT NOT NULL
);
INSERT INTO issues (
issue_key,
title,
labels_json,
archive_status,
capsule_path,
visibility,
source_hash,
warning_count,
docs_sync_status,
body,
captured_at
) VALUES (
'COE-999',
'Legacy row',
'[]',
'not_archived',
'.opensymphony/memory/issues/COE-999.md',
'private',
'hash',
0,
'pending',
'# Legacy row',
'2026-06-20T00:00:00Z'
);
"#,
)
.expect("legacy schema should write");
migrate_index(&connection).expect("migration should apply");
let (
concept_id,
concept_type,
tags_json,
scope_refs_json,
source_refs_json,
links_json,
citations_json,
freshness,
warnings_json,
description,
): (
String,
String,
String,
String,
String,
String,
String,
String,
String,
Option<String>,
) = connection
.query_row(
"SELECT concept_id, concept_type, tags_json, scope_refs_json, source_refs_json, links_json, citations_json, freshness, warnings_json, description FROM issues WHERE issue_key = 'COE-999'",
[],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
row.get(7)?,
row.get(8)?,
row.get(9)?,
))
},
)
.expect("migrated row should query");
assert_eq!(concept_id, "");
assert_eq!(concept_type, "issue-capsule");
assert_eq!(tags_json, "[]");
assert_eq!(scope_refs_json, "[]");
assert_eq!(source_refs_json, "[]");
assert_eq!(links_json, "[]");
assert_eq!(citations_json, "[]");
assert_eq!(freshness, "unknown");
assert_eq!(warnings_json, "[]");
assert_eq!(description, None);
for column in [
"concept_id",
"concept_type",
"tags_json",
"scope_refs_json",
"source_refs_json",
"links_json",
"citations_json",
"freshness",
"warnings_json",
] {
let is_nullable: String = connection
.query_row(
"SELECT is_nullable FROM information_schema.columns WHERE table_name = 'issues' AND column_name = ?",
params![column],
|row| row.get(0),
)
.expect("column nullability should query");
assert_eq!(is_nullable, "NO", "{column} should be non-nullable");
}
let description_nullable: String = connection
.query_row(
"SELECT is_nullable FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'description'",
[],
|row| row.get(0),
)
.expect("description nullability should query");
assert_eq!(description_nullable, "YES");
}
#[test]
fn capture_plan_matches_prs_and_infers_areas() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
let source = sample_source();
let selection = IssueSelection {
identifiers: vec!["COE-123".to_string()],
..IssueSelection::default()
};
let plan = plan_capture(&config, &source, &selection, false, false).expect("plan");
assert_eq!(plan.selected.len(), 1);
let issue = &plan.selected[0];
assert_eq!(issue.prs[0].number, 456);
assert!(issue.areas.contains(&"openhands-runtime".to_string()));
assert!(issue.docs_targets[0].ends_with("docs/openhands-runtime.md"));
}
#[test]
fn capture_plan_prefers_merged_replacement_prs_in_capsules_and_docs() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
let mut source = sample_source();
source.issues[0].linked_prs = vec![194, 195];
source.prs = vec![
PullRequestEvidence {
number: 194,
title: "COE-123 promote workspace graph surfaces".to_string(),
url: Some("https://github.com/example/repo/pull/194".to_string()),
branch: Some("feat/coe-123-graph".to_string()),
..PullRequestEvidence::default()
},
PullRequestEvidence {
number: 195,
title: "COE-123 promote workspace graph surfaces".to_string(),
url: Some("https://github.com/example/repo/pull/195".to_string()),
branch: Some("feat/coe-123-graph-v2".to_string()),
merge_sha: Some("932991fbb1ab91f99741bd89fb0897ee1dba3a15".to_string()),
..PullRequestEvidence::default()
},
];
let selection = IssueSelection {
identifiers: vec!["COE-123".to_string()],
..IssueSelection::default()
};
let plan = plan_capture(&config, &source, &selection, true, false).expect("plan");
assert_eq!(
plan.selected[0]
.prs
.iter()
.map(|pr| pr.number)
.collect::<Vec<_>>(),
vec![195, 194]
);
let capsule = render_issue_capsule(&config, &plan.selected[0]).expect("capsule");
let merged = capsule.find("PR #195").expect("merged PR should render");
let stale = capsule.find("PR #194").expect("stale PR should render");
assert!(merged < stale, "merged PR should lead capsule: {capsule}");
write_capture_plan(&config, &plan, false).expect("write capture");
let docs = plan_docs_sync(&config, &selection, false, false).expect("docs plan");
assert!(
docs.targets[0]
.after
.contains("COE-123 contributed: PR #195"),
"topic doc should summarize the merged replacement PR: {}",
docs.targets[0].after
);
}
#[test]
fn capsule_generation_omits_transcript_like_comments() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
let mut source = sample_source();
source.issues[0].comments.push(CommentEvidence {
body: "assistant: a full transcript should not be copied".to_string(),
..CommentEvidence::default()
});
let plan = plan_capture(
&config,
&source,
&IssueSelection {
identifiers: vec!["COE-123".to_string()],
..IssueSelection::default()
},
false,
false,
)
.expect("plan");
let markdown = render_issue_capsule(&config, &plan.selected[0]).expect("capsule");
assert!(markdown.contains("WebSocket reconnect recovery"));
assert!(!markdown.contains("assistant: a full transcript"));
assert!(markdown.contains("opensymphony debug COE-123"));
}
#[test]
fn capsule_generation_filters_low_signal_review_noise() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
let mut source = sample_source();
source.prs[0].reviews = vec</sub></sub> Fail doctor config when env placeholders are unset**
Missing env-backed config should be surfaced as an explicit doctor failure.
"#
.to_string(),
),
..ReviewEvidence::default()
},
ReviewEvidence {
reviewer: Some("chatgpt-codex-connector".to_string()),
state: Some("COMMENTED".to_string()),
disposition: Some(
r#"
### Codex Review
Here are some automated review suggestions for this pull request.
**Reviewed commit:** `abc1234`
<details> <summary>About Codex in GitHub</summary>
[Your team has set up Codex to review pull requests in this repo](https://example.com).
Reviews are triggered when you open a pull request for review.
"#
.to_string(),
),
..ReviewEvidence::default()
},
ReviewEvidence {
reviewer: Some("kumanday".to_string()),
state: Some("COMMENTED".to_string()),
..ReviewEvidence::default()
},
ReviewEvidence {
reviewer: Some("github-actions".to_string()),
state: Some("COMMENTED".to_string()),
disposition: Some(
"Good taste. The changes address the remaining unresolved threads.".to_string(),
),
..ReviewEvidence::default()
},
ReviewEvidence {
reviewer: Some("github-actions".to_string()),
state: Some("COMMENTED".to_string()),
disposition: Some(
"Good taste. The changes address the remaining unresolved threads.".to_string(),
),
..ReviewEvidence::default()
},
ReviewEvidence {
reviewer: Some("reviewer".to_string()),
state: Some("APPROVED".to_string()),
..ReviewEvidence::default()
},
];
let plan = plan_capture(
&config,
&source,
&IssueSelection {
identifiers: vec!["COE-123".to_string()],
..IssueSelection::default()
},
false,
false,
)
.expect("plan");
let markdown = render_issue_capsule(&config, &plan.selected[0]).expect("capsule");
assert!(!markdown.contains("Codex Review"));
assert!(!markdown.contains("About Codex"));
assert!(!markdown.contains("github.com/example/repo/blob"));
assert!(!markdown.contains("P2 Badge"));
assert!(markdown.contains("Fail doctor config when env placeholders are unset"));
assert!(!markdown.contains("kumanday COMMENTED"));
assert_eq!(
markdown.matches("github-actions COMMENTED").count(),
1,
"duplicate automated summaries should collapse: {markdown}",
);
assert!(markdown.contains("reviewer APPROVED"));
}
#[test]
fn write_capture_indexes_capsule_in_duckdb() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
let source = sample_source();
let plan = plan_capture(
&config,
&source,
&IssueSelection {
identifiers: vec!["COE-123".to_string()],
..IssueSelection::default()
},
true,
false,
)
.expect("plan");
let report = write_capture_plan(&config, &plan, false).expect("write");
let results = search(&config, "reconnect recovery", 10).expect("search");
assert_eq!(report.written_capsules.len(), 1);
assert!(config.index_path.exists());
assert_eq!(results[0].issue_key, "COE-123");
}
#[test]
fn canonical_area_label_is_authoritative_without_prefix_leakage() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
let mut source = sample_source();
source.issues[0].labels = vec!["area:openhands-runtime".to_string()];
let plan = plan_capture(
&config,
&source,
&IssueSelection {
identifiers: vec!["COE-123".to_string()],
..IssueSelection::default()
},
false,
false,
)
.expect("plan");
assert_eq!(
plan.selected[0].areas,
vec!["openhands-runtime".to_string()]
);
}
#[test]
fn deterministic_context_excludes_current_and_merges_documentation_impact() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
let mut captured_source = sample_source();
captured_source.issues.push(IssueEvidence {
identifier: "COE-124".to_string(),
title: "Memory server context compiler".to_string(),
url: Some("https://linear.app/example/issue/COE-124".to_string()),
description: Some("Build deterministic memory context.".to_string()),
state: Some("Done".to_string()),
labels: vec!["area:memory".to_string()],
comments: vec![CommentEvidence {
body: "Decision: precompute context before worker launch.".to_string(),
..CommentEvidence::default()
}],
..IssueEvidence::default()
});
let capture = plan_capture(
&config,
&captured_source,
&IssueSelection {
identifiers: vec!["COE-123".to_string(), "COE-124".to_string()],
..IssueSelection::default()
},
true,
false,
)
.expect("capture plan");
write_capture_plan(&config, &capture, false).expect("write capture");
let context_source = SourceFile {
issues: vec![IssueEvidence {
identifier: "COE-200".to_string(),
title: "Use deterministic pre-implementation memory".to_string(),
description: Some("Bootstrap the worker with relevant prior work.".to_string()),
state: Some("In Progress".to_string()),
labels: vec!["area:memory".to_string()],
children: vec![IssueLinkEvidence {
identifier: "COE-124".to_string(),
title: Some("Memory server context compiler".to_string()),
state: Some("Done".to_string()),
..IssueLinkEvidence::default()
}],
blocked_by: vec![IssueLinkEvidence {
identifier: "COE-123".to_string(),
title: Some("WebSocket reconnect recovery".to_string()),
state: Some("Done".to_string()),
..IssueLinkEvidence::default()
}],
..IssueEvidence::default()
}],
..SourceFile::default()
};
let options = MemoryContextOptions {
issue: "COE-200".to_string(),
explicit_includes: Vec::new(),
paths: Vec::new(),
limit: 20,
};
let context =
context_for_issue_with_options(&config, &context_source, &options).expect("context");
assert!(context.contains("## Blocking Predecessors"));
assert!(context.contains("## Completed Children"));
assert!(context.contains("COE-123: WebSocket reconnect recovery"));
assert!(context.contains("COE-124: Memory server context compiler"));
assert!(context.contains("Reasons: area match, completed child"));
assert!(!context.contains("### COE-200"));
assert_eq!(context.matches("## Documentation impact").count(), 1);
assert!(context.contains("- docs/memory.md"));
assert!(context.contains("- docs/openhands-runtime.md"));
}
#[test]
fn capture_evolves_memory_config_and_keeps_changed_files_index_only() {
let repo = TempDir::new().expect("temp repo");
let config = MemoryConfig::load(repo.path(), None).expect("default config");
let source = sample_source();
let plan = plan_capture(
&config,
&source,
&IssueSelection {
identifiers: vec!["COE-123".to_string()],
..IssueSelection::default()
},
true,
false,
)
.expect("plan");
write_capture_plan(&config, &plan, false).expect("write");
let evolved = MemoryConfig::load(repo.path(), None).expect("evolved config");
let area = evolved.areas.get("runtime").expect("runtime area");
assert_eq!(area.status, AreaStatus::Stable);
assert!(area.confidence >= evolved.confidence_threshold);
assert!(
area.source_refs
.linear_labels
.contains(&"runtime".to_string())
);
assert!(
area.source_refs.linear_issues.is_empty(),
"per-issue inventory belongs in capsules and DuckDB, not tracked memory.yaml"
);
assert!(
area.source_refs.github_prs.is_empty(),
"per-PR inventory belongs in capsules and DuckDB, not tracked memory.yaml"
);
let capsule =
fs::read_to_string(evolved.issue_capsule_path("COE-123")).expect("capsule should read");
assert!(capsule.contains("github_merge_shas"));
assert!(capsule.contains("abcdef1234567890"));
assert!(
!capsule.contains("crates/opensymphony-openhands/src/client.rs"),
"changed files should stay out of capsule prose and frontmatter"
);
let connection = Connection::open(&evolved.index_path).expect("index should open");
let changed_file: String = connection
.query_row(
"SELECT file_path FROM changed_files WHERE issue_key = 'COE-123'",
[],
|row| row.get(0),
)
.expect("changed file should be indexed");
assert_eq!(changed_file, "crates/opensymphony-openhands/src/client.rs");
}
#[test]
fn capture_creates_candidate_area_from_linear_and_pr_narrative() {
let repo = TempDir::new().expect("temp repo");
let config = MemoryConfig::load(repo.path(), None).expect("default config");
let mut source = sample_source();
source.issues[0].title = "OpenHands runtime adapter".to_string();
source.issues[0].milestone = None;
source.issues[0].labels.clear();
source.prs[0].title = "COE-123 support OpenHands runtime adapter".to_string();
let plan = plan_capture(
&config,
&source,
&IssueSelection {
identifiers: vec!["COE-123".to_string()],
..IssueSelection::default()
},
true,
false,
)
.expect("plan");
assert_eq!(plan.selected[0].areas, vec!["openhands-runtime-adapter"]);
write_capture_plan(&config, &plan, false).expect("write");
let evolved = MemoryConfig::load(repo.path(), None).expect("evolved config");
let area = evolved
.areas
.get("openhands-runtime-adapter")
.expect("candidate area");
assert_eq!(area.status, AreaStatus::Candidate);
assert!(area.confidence < evolved.confidence_threshold);
assert!(
area.source_refs.linear_issues.is_empty(),
"candidate areas should not accumulate issue inventory in tracked config"
);
}
#[test]
fn area_evidence_matching_requires_whole_tokens() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
let mut source = sample_source();
source.issues[0].title = "OpenHands gruntimeerror handling".to_string();
source.issues[0].description =
Some("Fix gruntimeerror handling without ownership changes.".to_string());
source.issues[0].labels.clear();
source.prs[0].title = "COE-123 harden gruntimeerror handling".to_string();
source.prs[0].body = Some("No ownership area changed.".to_string());
let plan = plan_capture(
&config,
&source,
&IssueSelection {
identifiers: vec!["COE-123".to_string()],
..IssueSelection::default()
},
true,
false,
)
.expect("plan");
assert!(
!plan.selected[0]
.areas
.contains(&"openhands-runtime".to_string())
);
}
#[test]
fn capture_index_rolls_back_when_a_later_issue_fails() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
let mut source = sample_source();
source.issues.push(IssueEvidence {
identifier: "COE-124".to_string(),
title: "Missing capsule should abort".to_string(),
url: Some("https://linear.app/example/issue/COE-124".to_string()),
state: Some("Done".to_string()),
labels: vec!["runtime".to_string()],
..IssueEvidence::default()
});
let plan = plan_capture(
&config,
&source,
&IssueSelection {
identifiers: vec!["COE-123".to_string(), "COE-124".to_string()],
..IssueSelection::default()
},
true,
false,
)
.expect("plan");
let first_issue = plan
.selected
.iter()
.find(|issue| issue.issue.identifier == "COE-123")
.expect("first issue should be planned");
fs::create_dir_all(first_issue.capsule_path.parent().expect("capsule parent"))
.expect("capsule dir should write");
fs::write(
&first_issue.capsule_path,
render_issue_capsule(&config, first_issue).expect("capsule should render"),
)
.expect("first capsule should write");
let result = index_capture_plan(&config, &plan);
assert!(
matches!(result, Err(MemoryError::ReadFile { .. })),
"missing second capsule should fail indexing: {result:?}",
);
assert!(
load_indexed_issues(&config)
.expect("index should load")
.is_empty(),
"first issue writes should roll back when a later issue fails",
);
}
#[test]
fn docs_sync_omits_private_capsule_links_for_public_docs() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
let source = sample_source();
let capture = plan_capture(
&config,
&source,
&IssueSelection {
identifiers: vec!["COE-123".to_string()],
..IssueSelection::default()
},
true,
false,
)
.expect("plan");
write_capture_plan(&config, &capture, false).expect("write capture");
let docs = plan_docs_sync(
&config,
&IssueSelection {
identifiers: vec!["COE-123".to_string()],
..IssueSelection::default()
},
false,
false,
)
.expect("docs plan");
assert_eq!(docs.targets.len(), 1);
assert!(!docs.targets[0].after.contains(".opensymphony/memory"));
assert!(docs.targets[0].after.contains("COE-123"));
}
#[test]
fn private_link_guard_allows_tracked_memory_config_path() {
assert!(!contains_private_memory_link(
"Commit .opensymphony/memory/memory.yaml"
));
assert!(contains_private_memory_link(
"See .opensymphony/memory/issues/COE-123.md"
));
assert!(!contains_private_memory_link(
"Do not publish .opensymphony/memory/memory.duckdb"
));
}
#[test]
fn private_link_guard_ignores_markdown_examples() {
let hidden = "Inline sample: `.opensymphony/memory/issues/COE-123.md`.\n\n\
```text\n.opensymphony/memory/issues/COE-123.md\n```\n\n\
<!-- .opensymphony/memory/issues/COE-123.md -->\n";
assert!(!contains_private_memory_link(&markdown_visible_text(
hidden
)));
assert!(contains_private_memory_link(&markdown_visible_text(
"See .opensymphony/memory/issues/COE-123.md for private details."
)));
}
#[test]
fn docs_sync_summary_reports_changed_line_counts() {
let diff = render_diff_stat(
"alpha\nshared\nold\nomega\n",
"alpha\nshared\nnew\nomega\n",
Path::new("docs/topic.md"),
);
assert!(diff.contains("docs/topic.md"));
assert!(diff.contains("4 -> 4 lines"));
assert!(diff.contains("+1 -1"));
}
#[test]
fn docs_sync_summary_for_new_docs_reports_only_adds() {
let diff = render_diff_stat("", "alpha\nbeta\n", Path::new("docs/topic.md"));
assert!(diff.contains("0 -> 2 lines"));
assert!(diff.contains("+2 -0"));
}
#[test]
fn archive_blocks_missing_memory_unless_forced() {
let repo = TempDir::new().expect("temp repo");
let config = config_for(repo.path());
let blocked = plan_archive(
&config,
&[String::from("COE-999")],
false,
None,
false,
false,
)
.expect("archive plan");
let forced = plan_archive(
&config,
&[String::from("COE-999")],
false,
None,
false,
true,
)
.expect("forced archive plan");
assert!(!blocked.issues[0].eligible);
assert!(forced.issues[0].eligible);
}
#[cfg(unix)]
#[test]
fn repo_containment_rejects_symlink_escape() {
let repo = TempDir::new().expect("temp repo");
let outside = TempDir::new().expect("outside dir");
std::os::unix::fs::symlink(outside.path(), repo.path().join("docs"))
.expect("symlink should be created");
let result = ensure_repo_contained(repo.path(), &repo.path().join("docs/escape.md"));
assert!(matches!(result, Err(MemoryError::PathOutsideRepo { .. })));
}
#[test]
fn sanitized_issue_keys_avoid_separator_collisions() {
assert_ne!(sanitize_issue_key("COE_123"), sanitize_issue_key("COE-123"));
}
fn config_for(repo_root: &Path) -> MemoryConfig {
let config_path = repo_root.join("opensymphony-memory.yaml");
fs::write(
&config_path,
r#"
areas:
openhands-runtime:
title: OpenHands Runtime
docs_target: docs/openhands-runtime.md
status: stable
confidence: 90
aliases:
- runtime
- OpenHands Runtime
source_refs:
linear_labels:
- runtime
"#,
)
.expect("config");
MemoryConfig::load(repo_root, Some(&config_path)).expect("memory config")
}
fn okf_fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("crates/opensymphony-memory/tests/fixtures")
.join(name)
}
fn copy_dir_recursive(source: &Path, destination: &Path) {
fs::create_dir_all(destination).expect("destination directory should be created");
for entry in fs::read_dir(source).expect("source directory should be readable") {
let entry = entry.expect("source entry should be readable");
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
if entry
.file_type()
.expect("source entry type should be readable")
.is_dir()
{
copy_dir_recursive(&source_path, &destination_path);
} else {
fs::copy(&source_path, &destination_path).expect("fixture file should copy");
}
}
}
fn sample_source() -> SourceFile {
SourceFile {
issues: vec![IssueEvidence {
identifier: "COE-123".to_string(),
title: "WebSocket reconnect recovery".to_string(),
url: Some("https://linear.app/example/issue/COE-123".to_string()),
description: Some("Recover OpenHands runtime streams after reconnect.".to_string()),
state: Some("Done".to_string()),
milestone: Some("M3".to_string()),
labels: vec!["runtime".to_string()],
comments: vec![CommentEvidence {
body: "Decision: reconcile REST event backlog after readiness.".to_string(),
..CommentEvidence::default()
}],
linked_prs: vec![456],
..IssueEvidence::default()
}],
prs: vec![PullRequestEvidence {
number: 456,
title: "COE-123 recover websocket reconnects".to_string(),
url: Some("https://github.com/example/repo/pull/456".to_string()),
branch: Some("coe-123-reconnect".to_string()),
merge_sha: Some("abcdef1234567890".to_string()),
changed_files: vec![ChangedFileEvidence {
path: PathBuf::from("crates/opensymphony-openhands/src/client.rs"),
change_kind: Some("modified".to_string()),
}],
checks: vec![CheckEvidence {
name: "cargo test".to_string(),
conclusion: Some("success".to_string()),
..CheckEvidence::default()
}],
reviews: vec![ReviewEvidence {
reviewer: Some("reviewer".to_string()),
state: Some("APPROVED".to_string()),
disposition: Some("Reconnect ordering looked correct.".to_string()),
..ReviewEvidence::default()
}],
..PullRequestEvidence::default()
}],
..SourceFile::default()
}
}
}