From 9915e12a309b808f517ef37fa5d0418725dcfab6 Mon Sep 17 00:00:00 2001 From: kurihada Date: Fri, 20 Mar 2026 16:01:48 +0800 Subject: [PATCH] Add repo-memory integration tests --- docs/tests/repo-memory/README.md | 13 +- docs/tests/repo-memory/ROADMAP.md | 13 +- .../cmd/repo-memory/add_integration_test.go | 127 +++++++++++ .../repo-memory/events_integration_test.go | 85 ++++++++ .../cmd/repo-memory/execute.go | 66 ++++++ .../repo-memory/ingest_integration_test.go | 85 ++++++++ .../cmd/repo-memory/init_integration_test.go | 34 +++ .../repo-memory/integration_helpers_test.go | 202 ++++++++++++++++++ .../cmd/repo-memory/link_integration_test.go | 107 ++++++++++ .../cmd/repo-memory/list_integration_test.go | 75 +++++++ .../cmd/repo-memory/main.go | 103 +++------ .../cmd/repo-memory/repos_integration_test.go | 54 +++++ .../repo-memory/search_integration_test.go | 113 ++++++++++ .../repo-memory/verify_integration_test.go | 181 ++++++++++++++++ .../repo-memory/workflow_integration_test.go | 195 +++++++++++++++++ .../internal/store/store.go | 20 +- 16 files changed, 1394 insertions(+), 79 deletions(-) create mode 100644 packages/repo-memory-runtime/cmd/repo-memory/add_integration_test.go create mode 100644 packages/repo-memory-runtime/cmd/repo-memory/events_integration_test.go create mode 100644 packages/repo-memory-runtime/cmd/repo-memory/execute.go create mode 100644 packages/repo-memory-runtime/cmd/repo-memory/ingest_integration_test.go create mode 100644 packages/repo-memory-runtime/cmd/repo-memory/init_integration_test.go create mode 100644 packages/repo-memory-runtime/cmd/repo-memory/integration_helpers_test.go create mode 100644 packages/repo-memory-runtime/cmd/repo-memory/link_integration_test.go create mode 100644 packages/repo-memory-runtime/cmd/repo-memory/list_integration_test.go create mode 100644 packages/repo-memory-runtime/cmd/repo-memory/repos_integration_test.go create mode 100644 packages/repo-memory-runtime/cmd/repo-memory/search_integration_test.go create mode 100644 packages/repo-memory-runtime/cmd/repo-memory/verify_integration_test.go create mode 100644 packages/repo-memory-runtime/cmd/repo-memory/workflow_integration_test.go diff --git a/docs/tests/repo-memory/README.md b/docs/tests/repo-memory/README.md index 3f351f9..cae5353 100644 --- a/docs/tests/repo-memory/README.md +++ b/docs/tests/repo-memory/README.md @@ -70,11 +70,22 @@ Unless a case says otherwise: The current executable references are: +- [init_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/init_integration_test.go) for `init` +- [add_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/add_integration_test.go) for `add` +- [ingest_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/ingest_integration_test.go) for `ingest` +- [search_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/search_integration_test.go) for `search` +- [list_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/list_integration_test.go) for `list` +- [events_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/events_integration_test.go) for `events` +- [link_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/link_integration_test.go) for `link` +- [verify_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/verify_integration_test.go) for `verify` +- [repos_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/repos_integration_test.go) for `repos` +- [workflow_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/workflow_integration_test.go) for the four documented workflow cases - [store_test.go](../../../packages/repo-memory-runtime/internal/store/store_test.go) for import, search, alias, dependency, link, and verification-state transitions - [load_test.go](../../../packages/repo-memory-runtime/internal/documents/load_test.go) for markdown parsing - [main_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/main_test.go) for verify downgrade heuristics -These tests do not replace the Markdown plan. They only reduce discovery work. +These tests do not replace the Markdown plan. They are the executable companion +to it. When this Markdown plan expands: diff --git a/docs/tests/repo-memory/ROADMAP.md b/docs/tests/repo-memory/ROADMAP.md index 1315f1b..9cbe8f7 100644 --- a/docs/tests/repo-memory/ROADMAP.md +++ b/docs/tests/repo-memory/ROADMAP.md @@ -24,12 +24,13 @@ Snapshot date: Current state: - `repo-memory` CLI is implemented for `init`, `add`, `ingest`, `search`, `list`, `events`, `link`, `verify`, and `repos` -- package-local automated Go tests already cover markdown parsing, import/search behavior, alias and dependency persistence, relation writes, and verification-state transitions, but they do not yet provide a dedicated CLI Markdown contract map +- package-local automated Go tests now cover every currently documented `repo-memory` command case and workflow case through dedicated CLI integration tests, plus the existing markdown parser and store-level tests - this roadmap now exists under `docs/tests/repo-memory/ROADMAP.md` - all planned global, shared, workflow, command-index, and command-case Markdown documents in the current `repo-memory` test-plan set have been authored - each implemented `repo-memory` command folder now uses `README.md` as an index plus one Markdown file per planned case - `docs/tests/repo-memory-skill/` can now stay focused on skill-forward behavior while `docs/tests/repo-memory/` owns direct CLI contract coverage - a follow-up edge audit on `2026-03-20` identified seven additional boundary cases, and those cases are now authored in the Markdown plan +- `verify` automation also exposed and fixed a store-level deadlock in `ListVerifyCandidates`, so the documented verify workflows now execute end-to-end in tests Progress summary for planned test-plan documents, excluding `ROADMAP.md`: @@ -117,6 +118,16 @@ The Markdown test-plan set starts from explicit CLI contract docs, but these automated tests already exist and should be used as source material when writing the docs: +- [init_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/init_integration_test.go) for `init` +- [add_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/add_integration_test.go) for `add` +- [ingest_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/ingest_integration_test.go) for `ingest` +- [search_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/search_integration_test.go) for `search` +- [list_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/list_integration_test.go) for `list` +- [events_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/events_integration_test.go) for `events` +- [link_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/link_integration_test.go) for `link` +- [verify_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/verify_integration_test.go) for `verify` +- [repos_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/repos_integration_test.go) for `repos` +- [workflow_integration_test.go](../../../packages/repo-memory-runtime/cmd/repo-memory/workflow_integration_test.go) for the documented workflow cases - [store_test.go](../../../packages/repo-memory-runtime/internal/store/store_test.go#L11) `TestImportDocumentAndSearch` - [store_test.go](../../../packages/repo-memory-runtime/internal/store/store_test.go#L67) `TestUpsertEntryWithAliasesAndDependencies` - [store_test.go](../../../packages/repo-memory-runtime/internal/store/store_test.go#L178) `TestApplyVerificationResult` diff --git a/packages/repo-memory-runtime/cmd/repo-memory/add_integration_test.go b/packages/repo-memory-runtime/cmd/repo-memory/add_integration_test.go new file mode 100644 index 0000000..5e083bf --- /dev/null +++ b/packages/repo-memory-runtime/cmd/repo-memory/add_integration_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "path/filepath" + "testing" +) + +func TestAddRegistersRepoAndEntry(t *testing.T) { + fixture := newRepoMemoryFixture(t) + initGitRepo(t, fixture.RepoPath) + sourcePath := filepath.Join(fixture.RepoPath, "app/app/src/main/java/foo/AITask.java") + writeFile(t, sourcePath, "class AITask {}\n") + commitAll(t, fixture.RepoPath, "init") + + addOut := runRepoMemoryCommand( + t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "Plan 内嵌任务结构,不是独立表", + "--status", "confirmed", + "--source-path", sourcePath, + "--source-line", "42", + "--alias", "AI Task", + "--dep", "file:"+sourcePath+":hard", + ) + listOut := runRepoMemoryCommand( + t, + "list", + "--db", fixture.DBPath, + "--repo", "repo", + "--kind", "term", + "--status", "confirmed", + ) + + if got, want := stringsTrim(addOut), "upserted entry 1 (term:AITask)"; got != want { + t.Fatalf("add output = %q, want %q", got, want) + } + assertContains(t, listOut, "#1 [repo] term:AITask [confirmed]") + assertContains(t, listOut, "Plan 内嵌任务结构,不是独立表") +} + +func TestAddUpdatesExistingEntryOnSameKindAndKey(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + initGitRepo(t, fixture.RepoPath) + writeFile(t, filepath.Join(fixture.RepoPath, "foo.txt"), "hello\n") + commitAll(t, fixture.RepoPath, "init") + + runRepoMemoryCommand( + t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "初版摘要", + "--status", "draft", + ) + addOut := runRepoMemoryCommand( + t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "修订后的摘要", + "--status", "confirmed", + "--alias", "AI Task", + ) + eventsOut := runRepoMemoryCommand( + t, + "events", + "--db", fixture.DBPath, + "--id", "1", + ) + listOut := runRepoMemoryCommand( + t, + "list", + "--db", fixture.DBPath, + "--repo", "repo", + "--kind", "term", + ) + + if got, want := stringsTrim(addOut), "upserted entry 1 (term:AITask)"; got != want { + t.Fatalf("second add output = %q, want %q", got, want) + } + assertContains(t, eventsOut, "updated (draft -> confirmed)") + assertContains(t, eventsOut, "created (- -> draft)") + assertContains(t, listOut, "修订后的摘要") +} + +func TestAddFailedValidationStillRegistersRepo(t *testing.T) { + fixture := newRepoMemoryFixture(t) + initGitRepo(t, fixture.RepoPath) + writeFile(t, filepath.Join(fixture.RepoPath, "foo.txt"), "hello\n") + commitAll(t, fixture.RepoPath, "init") + + _, stderr, exitCode := executeRepoMemoryCommand( + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--summary", "missing kind", + ) + reposOut := runRepoMemoryCommand( + t, + "repos", + "--db", fixture.DBPath, + ) + listOut := runRepoMemoryCommand( + t, + "list", + "--db", fixture.DBPath, + "--repo", "repo", + ) + + if exitCode != 1 { + t.Fatalf("add exit code = %d, want 1\nstderr:\n%s", exitCode, stderr) + } + mustContain(t, stderr, "kind is required") + mustContain(t, reposOut, fixture.RepoPath+" (0 entries, updated ") + if got, want := stringsTrim(listOut), "no entries"; got != want { + t.Fatalf("list output = %q, want %q", got, want) + } +} diff --git a/packages/repo-memory-runtime/cmd/repo-memory/events_integration_test.go b/packages/repo-memory-runtime/cmd/repo-memory/events_integration_test.go new file mode 100644 index 0000000..bd0d99c --- /dev/null +++ b/packages/repo-memory-runtime/cmd/repo-memory/events_integration_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "strings" + "testing" +) + +func TestEventsReadsHistoryByID(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + initGitRepo(t, fixture.RepoPath) + + runRepoMemoryCommand(t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "initial summary", + "--status", "draft", + ) + runRepoMemoryCommand(t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "revised summary", + "--status", "confirmed", + ) + + eventsOut := runRepoMemoryCommand(t, + "events", + "--db", fixture.DBPath, + "--id", "1", + ) + + assertContains(t, eventsOut, "term:AITask [confirmed] #1") + assertContains(t, eventsOut, "updated (draft -> confirmed)") + assertContains(t, eventsOut, "created (- -> draft)") + if strings.Index(eventsOut, "updated (draft -> confirmed)") > strings.Index(eventsOut, "created (- -> draft)") { + t.Fatalf("expected newest-first event order, got %q", eventsOut) + } +} + +func TestEventsResolvesEntryByRepoKindKey(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + initGitRepo(t, fixture.RepoPath) + + runRepoMemoryCommand(t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "Plan embedded task structure", + "--status", "confirmed", + ) + + eventsOut := runRepoMemoryCommand(t, + "events", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + ) + + assertContains(t, eventsOut, "term:AITask [confirmed] #1") + assertContains(t, eventsOut, "created (- -> confirmed)") +} + +func TestEventsRejectsMissingEntrySelector(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + + _, stderr, exitCode := executeRepoMemoryCommand( + "events", + "--db", fixture.DBPath, + ) + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d with stderr %q", exitCode, stderr) + } + assertContains(t, stderr, "either --id or --repo+--kind+--key is required") +} diff --git a/packages/repo-memory-runtime/cmd/repo-memory/execute.go b/packages/repo-memory-runtime/cmd/repo-memory/execute.go new file mode 100644 index 0000000..40f4deb --- /dev/null +++ b/packages/repo-memory-runtime/cmd/repo-memory/execute.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "io" + "os" + "sync" +) + +var ( + executeMu sync.Mutex + commandStdout io.Writer = os.Stdout + commandStderr io.Writer = os.Stderr +) + +func Execute(args []string, stdout, stderr io.Writer) int { + executeMu.Lock() + defer executeMu.Unlock() + + prevStdout := commandStdout + prevStderr := commandStderr + commandStdout = stdout + commandStderr = stderr + defer func() { + commandStdout = prevStdout + commandStderr = prevStderr + }() + + if len(args) < 1 { + usage() + return 2 + } + + if err := runCommand(args); err != nil { + _, _ = fmt.Fprintln(commandStderr, err) + return 1 + } + + return 0 +} + +func runCommand(args []string) error { + switch args[0] { + case "init": + return runInit(args[1:]) + case "add": + return runAdd(args[1:]) + case "ingest": + return runIngest(args[1:]) + case "search": + return runSearch(args[1:]) + case "list": + return runList(args[1:]) + case "events": + return runEvents(args[1:]) + case "link": + return runLink(args[1:]) + case "verify": + return runVerify(args[1:]) + case "repos": + return runRepos(args[1:]) + default: + usage() + return fmt.Errorf("unknown command %s", args[0]) + } +} diff --git a/packages/repo-memory-runtime/cmd/repo-memory/ingest_integration_test.go b/packages/repo-memory-runtime/cmd/repo-memory/ingest_integration_test.go new file mode 100644 index 0000000..02a2c7c --- /dev/null +++ b/packages/repo-memory-runtime/cmd/repo-memory/ingest_integration_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestIngestImportsDocsAIMarkdown(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + initGitRepo(t, fixture.RepoPath) + writeFile(t, filepath.Join(fixture.RepoPath, "docs/ai/repo-memory.md"), "# Repo Memory\n\n## Module Map\n\n- gateway\n- app/app\n\n## Danger Zones\n\n- shared libs first\n") + commitAll(t, fixture.RepoPath, "init") + + ingestOut := runRepoMemoryCommand( + t, + "ingest", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + ) + listOut := runRepoMemoryCommand( + t, + "list", + "--db", fixture.DBPath, + "--repo", "repo", + ) + + mustContain(t, ingestOut, "ingested 1 docs from "+fixture.RepoPath) + mustContain(t, listOut, "module:repo-memory:module-map [confirmed]") + mustContain(t, listOut, "danger:repo-memory:danger-zones [confirmed]") +} + +func TestIngestRejectsWhenNoMarkdownFound(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + initGitRepo(t, fixture.RepoPath) + runGit(t, fixture.RepoPath, "commit", "--allow-empty", "-m", "init") + if err := os.MkdirAll(filepath.Join(fixture.RepoPath, "docs", "ai"), 0o755); err != nil { + t.Fatalf("mkdir docs/ai: %v", err) + } + + _, stderr, exitCode := executeRepoMemoryCommand( + "ingest", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + ) + + if exitCode != 1 { + t.Fatalf("ingest exit code = %d, want 1\nstderr:\n%s", exitCode, stderr) + } + mustContain(t, stderr, "no markdown files found under "+filepath.Join(fixture.RepoPath, "docs", "ai")) +} + +func TestIngestImportsHeadinglessMarkdownAsSingleEntry(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + initGitRepo(t, fixture.RepoPath) + writeFile(t, filepath.Join(fixture.RepoPath, "docs/ai/repo-memory.md"), "This repository keeps AI memory notes near docs/ai.\nGateway owns ingress and app/app owns orchestration.\n") + commitAll(t, fixture.RepoPath, "init") + + ingestOut := runRepoMemoryCommand( + t, + "ingest", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + ) + listOut := runRepoMemoryCommand( + t, + "list", + "--db", fixture.DBPath, + "--repo", "repo", + ) + searchOut := runRepoMemoryCommand( + t, + "search", + "--db", fixture.DBPath, + "--repo", "repo", + "--query", "Gateway orchestration", + ) + + mustContain(t, ingestOut, "ingested 1 docs from "+fixture.RepoPath) + mustContain(t, listOut, "decision:repo-memory:overview [confirmed]") + mustContain(t, searchOut, "decision:repo-memory:overview") +} diff --git a/packages/repo-memory-runtime/cmd/repo-memory/init_integration_test.go b/packages/repo-memory-runtime/cmd/repo-memory/init_integration_test.go new file mode 100644 index 0000000..c1f5839 --- /dev/null +++ b/packages/repo-memory-runtime/cmd/repo-memory/init_integration_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestInitCreatesSchemaOnEmptyDB(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "repo-memory.db") + + stdout := runRepoMemoryCommand(t, "init", "--db", dbPath) + + if got, want := stringsTrim(stdout), "initialized "+dbPath; got != want { + t.Fatalf("init output = %q, want %q", got, want) + } + if _, err := os.Stat(dbPath); err != nil { + t.Fatalf("stat db %q: %v", dbPath, err) + } +} + +func TestInitIsIdempotentOnExistingDB(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "repo-memory.db") + + first := runRepoMemoryCommand(t, "init", "--db", dbPath) + second := runRepoMemoryCommand(t, "init", "--db", dbPath) + + if got, want := stringsTrim(first), "initialized "+dbPath; got != want { + t.Fatalf("first init output = %q, want %q", got, want) + } + if got, want := stringsTrim(second), "initialized "+dbPath; got != want { + t.Fatalf("second init output = %q, want %q", got, want) + } +} diff --git a/packages/repo-memory-runtime/cmd/repo-memory/integration_helpers_test.go b/packages/repo-memory-runtime/cmd/repo-memory/integration_helpers_test.go new file mode 100644 index 0000000..ed29641 --- /dev/null +++ b/packages/repo-memory-runtime/cmd/repo-memory/integration_helpers_test.go @@ -0,0 +1,202 @@ +package main + +import ( + "bytes" + "database/sql" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +type repoMemoryFixture struct { + TempDir string + DBPath string + RepoPath string +} + +func newRepoMemoryFixture(t *testing.T) repoMemoryFixture { + t.Helper() + + tempDir := t.TempDir() + return repoMemoryFixture{ + TempDir: tempDir, + DBPath: filepath.Join(tempDir, "repo-memory.db"), + RepoPath: filepath.Join(tempDir, "repo"), + } +} + +func runRepoMemoryCommand(t *testing.T, args ...string) string { + t.Helper() + + stdout, stderr, exitCode := executeRepoMemoryCommand(args...) + if exitCode != 0 { + t.Fatalf("execute repo-memory command %v: exit=%d\nstderr:\n%s\nstdout:\n%s", args, exitCode, stderr, stdout) + } + + return stdout +} + +func executeRepoMemoryCommand(args ...string) (string, string, int) { + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := Execute(args, &stdout, &stderr) + return stdout.String(), stderr.String(), exitCode +} + +func initRepoMemoryTestDB(t *testing.T) repoMemoryFixture { + t.Helper() + + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + return fixture +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write file %s: %v", path, err) + } +} + +func initGitRepo(t *testing.T, repoPath string) { + t.Helper() + + if err := os.MkdirAll(repoPath, 0o755); err != nil { + t.Fatalf("mkdir repo %s: %v", repoPath, err) + } + runGit(t, repoPath, "init") + runGit(t, repoPath, "config", "user.email", "test@example.com") + runGit(t, repoPath, "config", "user.name", "Tester") +} + +func createNamedGitRepo(t *testing.T, name string, files map[string]string) string { + t.Helper() + + repoPath := filepath.Join(t.TempDir(), name) + initGitRepo(t, repoPath) + for relPath, content := range files { + writeFile(t, filepath.Join(repoPath, relPath), content) + } + if len(files) > 0 { + commitAll(t, repoPath, "init") + } else { + runGit(t, repoPath, "commit", "--allow-empty", "-m", "init") + } + return repoPath +} + +func commitAll(t *testing.T, repoPath, message string) { + t.Helper() + + runGit(t, repoPath, "add", ".") + runGit(t, repoPath, "commit", "-m", message) +} + +func runGit(t *testing.T, repoPath string, args ...string) string { + t.Helper() + + cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, string(out)) + } + return string(out) +} + +func mustAbsPath(t *testing.T, path string) string { + t.Helper() + + absPath, err := filepath.Abs(path) + if err != nil { + t.Fatalf("abs path %s: %v", path, err) + } + return absPath +} + +func assertContains(t *testing.T, got, want string) { + t.Helper() + + if !strings.Contains(got, want) { + t.Fatalf("expected %q to contain %q", got, want) + } +} + +func assertNotContains(t *testing.T, got, want string) { + t.Helper() + + if strings.Contains(got, want) { + t.Fatalf("expected %q to not contain %q", got, want) + } +} + +func mustContain(t *testing.T, got, want string) { + t.Helper() + assertContains(t, got, want) +} + +func mustNotContain(t *testing.T, got, want string) { + t.Helper() + assertNotContains(t, got, want) +} + +func mustWriteRepoFile(t *testing.T, repoPath, relPath, content string) string { + t.Helper() + + path := filepath.Join(repoPath, relPath) + writeFile(t, path, content) + return path +} + +func queryStrings(t *testing.T, dbPath, query string, args ...any) []string { + t.Helper() + + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatalf("open sqlite %s: %v", dbPath, err) + } + defer db.Close() + + rows, err := db.Query(query, args...) + if err != nil { + t.Fatalf("query sqlite %q: %v", query, err) + } + defer rows.Close() + + var items []string + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + t.Fatalf("scan sqlite row for %q: %v", query, err) + } + items = append(items, value) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate sqlite rows for %q: %v", query, err) + } + + return items +} + +func queryInt(t *testing.T, dbPath, query string, args ...any) int { + t.Helper() + + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatalf("open sqlite %s: %v", dbPath, err) + } + defer db.Close() + + var value int + if err := db.QueryRow(query, args...).Scan(&value); err != nil { + t.Fatalf("query row sqlite %q: %v", query, err) + } + return value +} diff --git a/packages/repo-memory-runtime/cmd/repo-memory/link_integration_test.go b/packages/repo-memory-runtime/cmd/repo-memory/link_integration_test.go new file mode 100644 index 0000000..908c6fd --- /dev/null +++ b/packages/repo-memory-runtime/cmd/repo-memory/link_integration_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "testing" +) + +func TestLinkCreatesRelationBetweenEntries(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + initGitRepo(t, fixture.RepoPath) + + runRepoMemoryCommand(t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "Plan embedded task structure", + "--status", "confirmed", + ) + runRepoMemoryCommand(t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "chain", + "--key", "ai-insight.get", + "--summary", "gateway -> app service -> cache/db", + "--status", "confirmed", + ) + + linkOut := runRepoMemoryCommand(t, + "link", + "--db", fixture.DBPath, + "--from-id", "1", + "--to-id", "2", + "--relation", "related_to", + ) + assertContains(t, linkOut, "linked #1 -[related_to]-> #2") + + relations := queryStrings(t, fixture.DBPath, `SELECT relation FROM knowledge_links WHERE from_entry_id = 1 AND to_entry_id = 2`) + if len(relations) != 1 || relations[0] != "related_to" { + t.Fatalf("relations = %#v, want [related_to]", relations) + } +} + +func TestLinkRejectsMissingRelation(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + initGitRepo(t, fixture.RepoPath) + + runRepoMemoryCommand(t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "Plan embedded task structure", + "--status", "confirmed", + ) + runRepoMemoryCommand(t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "chain", + "--key", "ai-insight.get", + "--summary", "gateway -> app service -> cache/db", + "--status", "confirmed", + ) + + _, stderr, exitCode := executeRepoMemoryCommand( + "link", + "--db", fixture.DBPath, + "--from-id", "1", + "--to-id", "2", + ) + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d with stderr %q", exitCode, stderr) + } + assertContains(t, stderr, "relation is required") +} + +func TestLinkRejectsWhenEntryIDMissing(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + initGitRepo(t, fixture.RepoPath) + + runRepoMemoryCommand(t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "Plan embedded task structure", + "--status", "confirmed", + ) + + _, stderr, exitCode := executeRepoMemoryCommand( + "link", + "--db", fixture.DBPath, + "--from-id", "1", + "--relation", "related_to", + ) + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d with stderr %q", exitCode, stderr) + } + assertContains(t, stderr, "both entry ids are required") +} diff --git a/packages/repo-memory-runtime/cmd/repo-memory/list_integration_test.go b/packages/repo-memory-runtime/cmd/repo-memory/list_integration_test.go new file mode 100644 index 0000000..4bbeca7 --- /dev/null +++ b/packages/repo-memory-runtime/cmd/repo-memory/list_integration_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "path/filepath" + "testing" +) + +func TestListFiltersByKindAndStatus(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + initGitRepo(t, fixture.RepoPath) + writeFile(t, filepath.Join(fixture.RepoPath, "foo.txt"), "hello\n") + commitAll(t, fixture.RepoPath, "init") + + runRepoMemoryCommand( + t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "Plan 内嵌任务结构", + "--status", "confirmed", + ) + runRepoMemoryCommand( + t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AIJob", + "--summary", "后台任务封装", + "--status", "draft", + ) + runRepoMemoryCommand( + t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "chain", + "--key", "ai-insight.get", + "--summary", "gateway -> app service -> cache/db", + "--status", "confirmed", + ) + + listOut := runRepoMemoryCommand( + t, + "list", + "--db", fixture.DBPath, + "--repo", "repo", + "--kind", "term", + "--status", "confirmed", + ) + + mustContain(t, listOut, "term:AITask [confirmed]") + mustNotContain(t, listOut, "AIJob") + mustNotContain(t, listOut, "chain:ai-insight.get") +} + +func TestListReturnsNoEntriesWhenEmpty(t *testing.T) { + fixture := initRepoMemoryTestDB(t) + + listOut := runRepoMemoryCommand( + t, + "list", + "--db", fixture.DBPath, + "--repo", "repo", + "--kind", "term", + "--status", "confirmed", + ) + + if got, want := stringsTrim(listOut), "no entries"; got != want { + t.Fatalf("list output = %q, want %q", got, want) + } +} diff --git a/packages/repo-memory-runtime/cmd/repo-memory/main.go b/packages/repo-memory-runtime/cmd/repo-memory/main.go index ca0873e..aa1a5d8 100644 --- a/packages/repo-memory-runtime/cmd/repo-memory/main.go +++ b/packages/repo-memory-runtime/cmd/repo-memory/main.go @@ -15,52 +15,7 @@ import ( ) func main() { - if len(os.Args) < 2 { - usage() - os.Exit(2) - } - - switch os.Args[1] { - case "init": - if err := runInit(os.Args[2:]); err != nil { - fatal(err) - } - case "add": - if err := runAdd(os.Args[2:]); err != nil { - fatal(err) - } - case "ingest": - if err := runIngest(os.Args[2:]); err != nil { - fatal(err) - } - case "search": - if err := runSearch(os.Args[2:]); err != nil { - fatal(err) - } - case "list": - if err := runList(os.Args[2:]); err != nil { - fatal(err) - } - case "events": - if err := runEvents(os.Args[2:]); err != nil { - fatal(err) - } - case "link": - if err := runLink(os.Args[2:]); err != nil { - fatal(err) - } - case "verify": - if err := runVerify(os.Args[2:]); err != nil { - fatal(err) - } - case "repos": - if err := runRepos(os.Args[2:]); err != nil { - fatal(err) - } - default: - usage() - os.Exit(2) - } + os.Exit(Execute(os.Args[1:], os.Stdout, os.Stderr)) } type stringSliceFlag []string @@ -76,6 +31,7 @@ func (s *stringSliceFlag) Set(value string) error { func runInit(args []string) error { fs := flag.NewFlagSet("init", flag.ContinueOnError) + fs.SetOutput(commandStderr) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") if err := fs.Parse(args); err != nil { return err @@ -91,12 +47,13 @@ func runInit(args []string) error { return err } - fmt.Printf("initialized %s\n", *dbPath) + _, _ = fmt.Fprintf(commandStdout, "initialized %s\n", *dbPath) return nil } func runIngest(args []string) error { fs := flag.NewFlagSet("ingest", flag.ContinueOnError) + fs.SetOutput(commandStderr) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") repoPath := fs.String("repo", "", "Repository root") scanPath := fs.String("path", "docs/ai", "Relative path under repo to scan for markdown") @@ -156,12 +113,13 @@ func runIngest(args []string) error { } } - fmt.Printf("ingested %d docs from %s\n", len(docs), absRepo) + _, _ = fmt.Fprintf(commandStdout, "ingested %d docs from %s\n", len(docs), absRepo) return nil } func runAdd(args []string) error { fs := flag.NewFlagSet("add", flag.ContinueOnError) + fs.SetOutput(commandStderr) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") repoPath := fs.String("repo", "", "Repository root") kind := fs.String("kind", "", "Knowledge kind, e.g. term|chain|danger") @@ -237,12 +195,13 @@ func runAdd(args []string) error { return err } - fmt.Printf("upserted entry %d (%s:%s)\n", entryID, input.Kind, input.Key) + _, _ = fmt.Fprintf(commandStdout, "upserted entry %d (%s:%s)\n", entryID, input.Kind, input.Key) return nil } func runSearch(args []string) error { fs := flag.NewFlagSet("search", flag.ContinueOnError) + fs.SetOutput(commandStderr) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") query := fs.String("query", "", "Search query") repo := fs.String("repo", "", "Optional repo path filter (substring match)") @@ -269,20 +228,21 @@ func runSearch(args []string) error { return err } if len(results) == 0 { - fmt.Println("no results") + _, _ = fmt.Fprintln(commandStdout, "no results") return nil } for i, r := range results { - fmt.Printf("%d. [%s] %s:%s [%s]\n", i+1, filepath.Base(r.RepoPath), r.Kind, r.Key, r.Status) - fmt.Printf(" %s\n", r.Title) - fmt.Printf(" %s\n", r.Snippet) + _, _ = fmt.Fprintf(commandStdout, "%d. [%s] %s:%s [%s]\n", i+1, filepath.Base(r.RepoPath), r.Kind, r.Key, r.Status) + _, _ = fmt.Fprintf(commandStdout, " %s\n", r.Title) + _, _ = fmt.Fprintf(commandStdout, " %s\n", r.Snippet) } return nil } func runRepos(args []string) error { fs := flag.NewFlagSet("repos", flag.ContinueOnError) + fs.SetOutput(commandStderr) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") if err := fs.Parse(args); err != nil { return err @@ -299,18 +259,19 @@ func runRepos(args []string) error { return err } if len(repos) == 0 { - fmt.Println("no repos") + _, _ = fmt.Fprintln(commandStdout, "no repos") return nil } for _, repo := range repos { - fmt.Printf("- %s (%d entries, updated %s)\n", repo.Path, repo.EntryCount, repo.UpdatedAt.Format(time.RFC3339)) + _, _ = fmt.Fprintf(commandStdout, "- %s (%d entries, updated %s)\n", repo.Path, repo.EntryCount, repo.UpdatedAt.Format(time.RFC3339)) } return nil } func runList(args []string) error { fs := flag.NewFlagSet("list", flag.ContinueOnError) + fs.SetOutput(commandStderr) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") repo := fs.String("repo", "", "Optional repo path filter (substring match)") kind := fs.String("kind", "", "Optional knowledge kind filter") @@ -336,19 +297,20 @@ func runList(args []string) error { return err } if len(items) == 0 { - fmt.Println("no entries") + _, _ = fmt.Fprintln(commandStdout, "no entries") return nil } for _, item := range items { - fmt.Printf("- #%d [%s] %s:%s [%s]\n", item.ID, filepath.Base(item.RepoPath), item.Kind, item.Key, item.Status) - fmt.Printf(" %s\n", item.Summary) + _, _ = fmt.Fprintf(commandStdout, "- #%d [%s] %s:%s [%s]\n", item.ID, filepath.Base(item.RepoPath), item.Kind, item.Key, item.Status) + _, _ = fmt.Fprintf(commandStdout, " %s\n", item.Summary) } return nil } func runEvents(args []string) error { fs := flag.NewFlagSet("events", flag.ContinueOnError) + fs.SetOutput(commandStderr) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") id := fs.Int64("id", 0, "Entry id") repo := fs.String("repo", "", "Repo root when resolving by kind/key") @@ -378,19 +340,19 @@ func runEvents(args []string) error { return err } if len(events) == 0 { - fmt.Println("no events") + _, _ = fmt.Fprintln(commandStdout, "no events") return nil } - fmt.Printf("%s:%s [%s] #%d\n", entry.Kind, entry.Key, entry.Status, entry.ID) + _, _ = fmt.Fprintf(commandStdout, "%s:%s [%s] #%d\n", entry.Kind, entry.Key, entry.Status, entry.ID) for _, ev := range events { line := fmt.Sprintf("- %s %s", ev.CreatedAt.Format(time.RFC3339), ev.EventType) if ev.FromStatus != "" || ev.ToStatus != "" { line += fmt.Sprintf(" (%s -> %s)", emptyDash(ev.FromStatus), emptyDash(ev.ToStatus)) } - fmt.Println(line) + _, _ = fmt.Fprintln(commandStdout, line) if ev.Reason != "" { - fmt.Printf(" reason: %s\n", ev.Reason) + _, _ = fmt.Fprintf(commandStdout, " reason: %s\n", ev.Reason) } } return nil @@ -398,6 +360,7 @@ func runEvents(args []string) error { func runLink(args []string) error { fs := flag.NewFlagSet("link", flag.ContinueOnError) + fs.SetOutput(commandStderr) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") fromID := fs.Int64("from-id", 0, "From entry id") toID := fs.Int64("to-id", 0, "To entry id") @@ -416,12 +379,13 @@ func runLink(args []string) error { return err } - fmt.Printf("linked #%d -[%s]-> #%d\n", *fromID, *relation, *toID) + _, _ = fmt.Fprintf(commandStdout, "linked #%d -[%s]-> #%d\n", *fromID, *relation, *toID) return nil } func runVerify(args []string) error { fs := flag.NewFlagSet("verify", flag.ContinueOnError) + fs.SetOutput(commandStderr) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") repo := fs.String("repo", "", "Optional repo root to verify; if omitted, verify all known repos") if err := fs.Parse(args); err != nil { @@ -451,14 +415,14 @@ func runVerify(args []string) error { } } if len(repoRoots) == 0 { - fmt.Println("no repos") + _, _ = fmt.Fprintln(commandStdout, "no repos") return nil } for _, repoRoot := range repoRoots { gitState := detectGitState(repoRoot) if gitState.commit == "" { - fmt.Printf("%s: skipped (not a git repo or no HEAD)\n", repoRoot) + _, _ = fmt.Fprintf(commandStdout, "%s: skipped (not a git repo or no HEAD)\n", repoRoot) continue } if _, err := st.UpsertRepo(context.Background(), store.RepoState{ @@ -494,14 +458,14 @@ func runVerify(args []string) error { return err } } - fmt.Printf("%s: verified %d entries, %d downgraded, %d stale\n", repoRoot, len(candidates), changedCount, staleCount) + _, _ = fmt.Fprintf(commandStdout, "%s: verified %d entries, %d downgraded, %d stale\n", repoRoot, len(candidates), changedCount, staleCount) } return nil } func usage() { - fmt.Fprintf(os.Stderr, `repo-memory: repo memory CLI + _, _ = fmt.Fprintf(commandStderr, `repo-memory: repo memory CLI Usage: repo-memory init --db repo-memory.db @@ -516,11 +480,6 @@ Usage: `) } -func fatal(err error) { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) -} - type gitState struct { branch string commit string diff --git a/packages/repo-memory-runtime/cmd/repo-memory/repos_integration_test.go b/packages/repo-memory-runtime/cmd/repo-memory/repos_integration_test.go new file mode 100644 index 0000000..1524dc3 --- /dev/null +++ b/packages/repo-memory-runtime/cmd/repo-memory/repos_integration_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "path/filepath" + "testing" +) + +func TestReposListsTrackedRepositories(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + cupidRepo := filepath.Join(t.TempDir(), "cupid-service") + marsRepo := filepath.Join(t.TempDir(), "mars-service") + initGitRepo(t, cupidRepo) + initGitRepo(t, marsRepo) + + runRepoMemoryCommand(t, + "add", + "--db", fixture.DBPath, + "--repo", cupidRepo, + "--kind", "term", + "--key", "AITask", + "--summary", "Plan embedded task structure", + "--status", "confirmed", + ) + runRepoMemoryCommand(t, + "add", + "--db", fixture.DBPath, + "--repo", marsRepo, + "--kind", "term", + "--key", "DeployPlan", + "--summary", "Release plan", + "--status", "confirmed", + ) + + reposOut := runRepoMemoryCommand(t, + "repos", + "--db", fixture.DBPath, + ) + assertContains(t, reposOut, cupidRepo+" (1 entries, updated ") + assertContains(t, reposOut, marsRepo+" (1 entries, updated ") +} + +func TestReposPrintsNoReposWhenEmpty(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + + reposOut := runRepoMemoryCommand(t, + "repos", + "--db", fixture.DBPath, + ) + if stringsTrim(reposOut) != "no repos" { + t.Fatalf("expected no repos, got %q", reposOut) + } +} diff --git a/packages/repo-memory-runtime/cmd/repo-memory/search_integration_test.go b/packages/repo-memory-runtime/cmd/repo-memory/search_integration_test.go new file mode 100644 index 0000000..db433c2 --- /dev/null +++ b/packages/repo-memory-runtime/cmd/repo-memory/search_integration_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "path/filepath" + "testing" +) + +func TestSearchReturnsMatchingEntrySnippet(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + initGitRepo(t, fixture.RepoPath) + writeFile(t, fixture.RepoPath+"/foo.txt", "hello\n") + commitAll(t, fixture.RepoPath, "init") + + runRepoMemoryCommand( + t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "chain", + "--key", "ai-insight.get", + "--summary", "gateway -> app service -> cache/db", + "--detail", "The AI insight read path goes through gateway before app service reaches cache and database.", + "--status", "confirmed", + ) + searchOut := runRepoMemoryCommand( + t, + "search", + "--db", fixture.DBPath, + "--repo", "repo", + "--query", "insight gateway", + ) + + mustContain(t, searchOut, "1. [repo] chain:ai-insight.get [confirmed]") + mustContain(t, searchOut, "gateway") +} + +func TestSearchMatchesAliasWithRepoFilter(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + cupid := newRepoMemoryFixture(t) + cupid.RepoPath = filepath.Join(cupid.TempDir, "cupid-service") + mars := newRepoMemoryFixture(t) + mars.RepoPath = filepath.Join(mars.TempDir, "mars-service") + initGitRepo(t, cupid.RepoPath) + initGitRepo(t, mars.RepoPath) + writeFile(t, filepath.Join(cupid.RepoPath, "foo.txt"), "hello\n") + writeFile(t, filepath.Join(mars.RepoPath, "bar.txt"), "hello\n") + commitAll(t, cupid.RepoPath, "init") + commitAll(t, mars.RepoPath, "init") + + runRepoMemoryCommand( + t, + "add", + "--db", fixture.DBPath, + "--repo", cupid.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "Plan 内嵌任务结构", + "--status", "confirmed", + "--alias", "plan task", + ) + runRepoMemoryCommand( + t, + "add", + "--db", fixture.DBPath, + "--repo", mars.RepoPath, + "--kind", "term", + "--key", "DeployPlan", + "--summary", "发布计划", + "--status", "confirmed", + "--alias", "release plan", + ) + searchOut := runRepoMemoryCommand( + t, + "search", + "--db", fixture.DBPath, + "--repo", "cupid", + "--query", "plan task", + ) + + mustContain(t, searchOut, "[cupid-service] term:AITask [confirmed]") + mustNotContain(t, searchOut, "[mars-service]") +} + +func TestSearchReturnsNoResultsWhenEmpty(t *testing.T) { + fixture := initRepoMemoryTestDB(t) + + searchOut := runRepoMemoryCommand( + t, + "search", + "--db", fixture.DBPath, + "--query", "missing term", + ) + + if got, want := stringsTrim(searchOut), "no results"; got != want { + t.Fatalf("search output = %q, want %q", got, want) + } +} + +func TestSearchRejectsMissingQuery(t *testing.T) { + fixture := initRepoMemoryTestDB(t) + + _, stderr, exitCode := executeRepoMemoryCommand( + "search", + "--db", fixture.DBPath, + ) + + if exitCode != 1 { + t.Fatalf("search exit code = %d, want 1\nstderr:\n%s", exitCode, stderr) + } + mustContain(t, stderr, "--query is required") +} diff --git a/packages/repo-memory-runtime/cmd/repo-memory/verify_integration_test.go b/packages/repo-memory-runtime/cmd/repo-memory/verify_integration_test.go new file mode 100644 index 0000000..baf5839 --- /dev/null +++ b/packages/repo-memory-runtime/cmd/repo-memory/verify_integration_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestVerifyDowngradesChangedFileDependency(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + initGitRepo(t, fixture.RepoPath) + filePath := filepath.Join(fixture.RepoPath, "foo.txt") + writeFile(t, filePath, "hello\n") + commitAll(t, fixture.RepoPath, "add foo") + + runRepoMemoryCommand(t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "Plan embedded task structure", + "--status", "confirmed", + "--dep", "file:"+filePath+":hard", + ) + if err := os.WriteFile(filePath, []byte("changed\n"), 0o644); err != nil { + t.Fatalf("mutate dependency file: %v", err) + } + + verifyOut := runRepoMemoryCommand(t, + "verify", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + ) + assertContains(t, verifyOut, "verified 1 entries, 1 downgraded, 0 stale") + + listOut := runRepoMemoryCommand(t, + "list", + "--db", fixture.DBPath, + "--repo", "repo", + "--status", "needs_review", + ) + assertContains(t, listOut, "term:AITask [needs_review]") + + eventsOut := runRepoMemoryCommand(t, + "events", + "--db", fixture.DBPath, + "--id", "1", + ) + assertContains(t, eventsOut, "downgraded (confirmed -> needs_review)") +} + +func TestVerifyMarksMissingHardDependencyStale(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + initGitRepo(t, fixture.RepoPath) + runGit(t, fixture.RepoPath, "commit", "--allow-empty", "-m", "init") + missingPath := filepath.Join(fixture.RepoPath, "missing.txt") + + runRepoMemoryCommand(t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "Plan embedded task structure", + "--status", "confirmed", + "--dep", "file:"+missingPath+":hard", + ) + + verifyOut := runRepoMemoryCommand(t, + "verify", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + ) + assertContains(t, verifyOut, "verified 1 entries, 0 downgraded, 1 stale") + + listOut := runRepoMemoryCommand(t, + "list", + "--db", fixture.DBPath, + "--repo", "repo", + "--status", "stale", + ) + assertContains(t, listOut, "term:AITask [stale]") + + eventsOut := runRepoMemoryCommand(t, + "events", + "--db", fixture.DBPath, + "--id", "1", + ) + assertContains(t, eventsOut, "marked_stale (confirmed -> stale)") +} + +func TestVerifyPrintsNoReposWhenEmpty(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + + verifyOut := runRepoMemoryCommand(t, + "verify", + "--db", fixture.DBPath, + ) + if stringsTrim(verifyOut) != "no repos" { + t.Fatalf("expected no repos, got %q", verifyOut) + } +} + +func TestVerifySkipsExplicitRepoWithoutGitHead(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + repoPath := filepath.Join(t.TempDir(), "repo") + writeFile(t, filepath.Join(repoPath, ".keep"), "") + + verifyOut := runRepoMemoryCommand(t, + "verify", + "--db", fixture.DBPath, + "--repo", repoPath, + ) + assertContains(t, verifyOut, repoPath+": skipped (not a git repo or no HEAD)") + + reposOut := runRepoMemoryCommand(t, + "repos", + "--db", fixture.DBPath, + ) + if stringsTrim(reposOut) != "no repos" { + t.Fatalf("expected no repos after skipped verify, got %q", reposOut) + } +} + +func TestVerifyDowngradesEntryMissingVerifiedOnCommit(t *testing.T) { + fixture := newRepoMemoryFixture(t) + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + repoPath := filepath.Join(t.TempDir(), "repo") + if err := os.MkdirAll(repoPath, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + filePath := filepath.Join(repoPath, "foo.txt") + if err := os.WriteFile(filePath, []byte("hello\n"), 0o644); err != nil { + t.Fatalf("write dependency file: %v", err) + } + + runRepoMemoryCommand(t, + "add", + "--db", fixture.DBPath, + "--repo", repoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "Recorded before git init", + "--status", "confirmed", + "--dep", "file:"+filePath+":hard", + ) + + runCmd(t, repoPath, "git", "init") + runCmd(t, repoPath, "git", "config", "user.email", "test@example.com") + runCmd(t, repoPath, "git", "config", "user.name", "Tester") + runCmd(t, repoPath, "git", "add", ".") + runCmd(t, repoPath, "git", "commit", "-m", "init") + + verifyOut := runRepoMemoryCommand(t, + "verify", + "--db", fixture.DBPath, + "--repo", repoPath, + ) + assertContains(t, verifyOut, "verified 1 entries, 1 downgraded, 0 stale") + + listOut := runRepoMemoryCommand(t, + "list", + "--db", fixture.DBPath, + "--repo", "repo", + "--status", "needs_review", + ) + assertContains(t, listOut, "term:AITask [needs_review]") + + eventsOut := runRepoMemoryCommand(t, + "events", + "--db", fixture.DBPath, + "--id", "1", + ) + assertContains(t, eventsOut, "downgraded (confirmed -> needs_review)") + assertContains(t, eventsOut, "reason: missing verified_on_commit") +} diff --git a/packages/repo-memory-runtime/cmd/repo-memory/workflow_integration_test.go b/packages/repo-memory-runtime/cmd/repo-memory/workflow_integration_test.go new file mode 100644 index 0000000..e018091 --- /dev/null +++ b/packages/repo-memory-runtime/cmd/repo-memory/workflow_integration_test.go @@ -0,0 +1,195 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestWorkflowAddSearchEventsRoundtrip(t *testing.T) { + fixture := newRepoMemoryFixture(t) + evidencePath := filepath.Join(fixture.RepoPath, "app", "app", "src", "main", "java", "foo", "AITask.java") + writeFile(t, evidencePath, "class AITask {}\n") + initGitRepo(t, fixture.RepoPath) + commitAll(t, fixture.RepoPath, "init") + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + + addOut := runRepoMemoryCommand( + t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "Plan 内嵌任务结构,不是独立表", + "--status", "confirmed", + "--source-path", evidencePath, + "--source-line", "42", + "--alias", "AI Task", + "--dep", "file:"+evidencePath+":hard", + ) + searchOut := runRepoMemoryCommand( + t, + "search", + "--db", fixture.DBPath, + "--repo", "repo", + "--query", "AI Task", + ) + eventsOut := runRepoMemoryCommand( + t, + "events", + "--db", fixture.DBPath, + "--id", "1", + ) + + assertContains(t, addOut, "upserted entry 1 (term:AITask)") + assertContains(t, searchOut, "[repo] term:AITask [confirmed]") + assertContains(t, eventsOut, "term:AITask [confirmed] #1") + assertContains(t, eventsOut, "created") +} + +func TestWorkflowIngestSearchListAcrossSections(t *testing.T) { + fixture := newRepoMemoryFixture(t) + docPath := filepath.Join(fixture.RepoPath, "docs", "ai", "repo-memory.md") + writeFile(t, docPath, strings.TrimSpace(` +# Repo Memory + +## Module Map + +- gateway +- app/app + +## Danger Zones + +- shared libs first +`)+"\n") + initGitRepo(t, fixture.RepoPath) + commitAll(t, fixture.RepoPath, "init") + + ingestOut := runRepoMemoryCommand( + t, + "ingest", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + ) + searchOut := runRepoMemoryCommand( + t, + "search", + "--db", fixture.DBPath, + "--repo", "repo", + "--query", "gateway", + ) + listOut := runRepoMemoryCommand( + t, + "list", + "--db", fixture.DBPath, + "--repo", "repo", + ) + + assertContains(t, ingestOut, "ingested 1 docs from "+fixture.RepoPath) + assertContains(t, searchOut, "[repo] module:repo-memory:module-map [confirmed]") + assertContains(t, listOut, "module:repo-memory:module-map [confirmed]") + assertContains(t, listOut, "danger:repo-memory:danger-zones [confirmed]") +} + +func TestWorkflowAddLinkAndResolveRelatedEntry(t *testing.T) { + fixture := newRepoMemoryFixture(t) + evidencePath := filepath.Join(fixture.RepoPath, "docs", "term.md") + writeFile(t, evidencePath, "AITask reference\n") + initGitRepo(t, fixture.RepoPath) + commitAll(t, fixture.RepoPath, "init") + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + + runRepoMemoryCommand( + t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "Plan 内嵌任务结构,不是独立表", + "--status", "confirmed", + "--source-path", evidencePath, + ) + runRepoMemoryCommand( + t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "chain", + "--key", "ai-insight.get", + "--summary", "gateway -> app service -> cache/db", + "--status", "confirmed", + ) + + linkOut := runRepoMemoryCommand( + t, + "link", + "--db", fixture.DBPath, + "--from-id", "1", + "--to-id", "2", + "--relation", "related_to", + ) + relations := queryStrings( + t, + fixture.DBPath, + `SELECT relation FROM knowledge_links WHERE from_entry_id = ? AND to_entry_id = ?`, + 1, + 2, + ) + eventsOne := runRepoMemoryCommand(t, "events", "--db", fixture.DBPath, "--id", "1") + eventsTwo := runRepoMemoryCommand(t, "events", "--db", fixture.DBPath, "--id", "2") + + assertContains(t, linkOut, "linked #1 -[related_to]-> #2") + if len(relations) != 1 || relations[0] != "related_to" { + t.Fatalf("expected one stored relation related_to, got %#v", relations) + } + assertContains(t, eventsOne, "term:AITask [confirmed] #1") + assertContains(t, eventsTwo, "chain:ai-insight.get [confirmed] #2") +} + +func TestWorkflowVerifyDowngradesAfterRepoChange(t *testing.T) { + fixture := newRepoMemoryFixture(t) + evidencePath := filepath.Join(fixture.RepoPath, "foo.txt") + writeFile(t, evidencePath, "hello\n") + initGitRepo(t, fixture.RepoPath) + commitAll(t, fixture.RepoPath, "init") + runRepoMemoryCommand(t, "init", "--db", fixture.DBPath) + + runRepoMemoryCommand( + t, + "add", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + "--kind", "term", + "--key", "AITask", + "--summary", "Plan 内嵌任务结构", + "--status", "confirmed", + "--dep", "file:"+evidencePath+":hard", + ) + + writeFile(t, evidencePath, "changed\n") + + verifyOut := runRepoMemoryCommand( + t, + "verify", + "--db", fixture.DBPath, + "--repo", fixture.RepoPath, + ) + listOut := runRepoMemoryCommand( + t, + "list", + "--db", fixture.DBPath, + "--repo", "repo", + ) + eventsOut := runRepoMemoryCommand( + t, + "events", + "--db", fixture.DBPath, + "--id", "1", + ) + + assertContains(t, verifyOut, "verified 1 entries, 1 downgraded, 0 stale") + assertContains(t, listOut, "term:AITask [needs_review]") + assertContains(t, eventsOut, "downgraded (confirmed -> needs_review)") +} diff --git a/packages/repo-memory-runtime/internal/store/store.go b/packages/repo-memory-runtime/internal/store/store.go index 305fb99..0a290e8 100644 --- a/packages/repo-memory-runtime/internal/store/store.go +++ b/packages/repo-memory-runtime/internal/store/store.go @@ -674,22 +674,32 @@ func (s *Store) ListVerifyCandidates(ctx context.Context, repoRoot string) ([]Ve if err != nil { return nil, err } - defer rows.Close() var candidates []VerifyCandidate for rows.Next() { var c VerifyCandidate if err := rows.Scan(&c.ID, &c.RepoPath, &c.Kind, &c.Key, &c.Title, &c.Status, &c.VerifiedOnCommit); err != nil { + rows.Close() return nil, err } - deps, err := s.listDependencies(ctx, c.ID) + candidates = append(candidates, c) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + + for i := range candidates { + deps, err := s.listDependencies(ctx, candidates[i].ID) if err != nil { return nil, err } - c.Dependencies = deps - candidates = append(candidates, c) + candidates[i].Dependencies = deps } - return candidates, rows.Err() + return candidates, nil } func (s *Store) ApplyVerificationResult(ctx context.Context, entryID int64, currentCommit, nextStatus, reason string) error {