Add repo-memory integration tests

This commit is contained in:
2026-03-20 16:01:48 +08:00
parent dd6b9c2c1f
commit 9915e12a30
16 changed files with 1394 additions and 79 deletions
+12 -1
View File
@@ -70,11 +70,22 @@ Unless a case says otherwise:
The current executable references are: 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 - [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 - [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 - [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: When this Markdown plan expands:
+12 -1
View File
@@ -24,12 +24,13 @@ Snapshot date:
Current state: Current state:
- `repo-memory` CLI is implemented for `init`, `add`, `ingest`, `search`, `list`, `events`, `link`, `verify`, and `repos` - `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` - 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 - 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 - 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 - `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 - 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`: 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 automated tests already exist and should be used as source material when
writing the docs: 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#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#L67) `TestUpsertEntryWithAliasesAndDependencies`
- [store_test.go](../../../packages/repo-memory-runtime/internal/store/store_test.go#L178) `TestApplyVerificationResult` - [store_test.go](../../../packages/repo-memory-runtime/internal/store/store_test.go#L178) `TestApplyVerificationResult`
@@ -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)
}
}
@@ -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")
}
@@ -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])
}
}
@@ -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")
}
@@ -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)
}
}
@@ -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
}
@@ -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")
}
@@ -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)
}
}
@@ -15,52 +15,7 @@ import (
) )
func main() { func main() {
if len(os.Args) < 2 { os.Exit(Execute(os.Args[1:], os.Stdout, os.Stderr))
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)
}
} }
type stringSliceFlag []string type stringSliceFlag []string
@@ -76,6 +31,7 @@ func (s *stringSliceFlag) Set(value string) error {
func runInit(args []string) error { func runInit(args []string) error {
fs := flag.NewFlagSet("init", flag.ContinueOnError) fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(commandStderr)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path") dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
@@ -91,12 +47,13 @@ func runInit(args []string) error {
return err return err
} }
fmt.Printf("initialized %s\n", *dbPath) _, _ = fmt.Fprintf(commandStdout, "initialized %s\n", *dbPath)
return nil return nil
} }
func runIngest(args []string) error { func runIngest(args []string) error {
fs := flag.NewFlagSet("ingest", flag.ContinueOnError) fs := flag.NewFlagSet("ingest", flag.ContinueOnError)
fs.SetOutput(commandStderr)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path") dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
repoPath := fs.String("repo", "", "Repository root") repoPath := fs.String("repo", "", "Repository root")
scanPath := fs.String("path", "docs/ai", "Relative path under repo to scan for markdown") 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 return nil
} }
func runAdd(args []string) error { func runAdd(args []string) error {
fs := flag.NewFlagSet("add", flag.ContinueOnError) fs := flag.NewFlagSet("add", flag.ContinueOnError)
fs.SetOutput(commandStderr)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path") dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
repoPath := fs.String("repo", "", "Repository root") repoPath := fs.String("repo", "", "Repository root")
kind := fs.String("kind", "", "Knowledge kind, e.g. term|chain|danger") kind := fs.String("kind", "", "Knowledge kind, e.g. term|chain|danger")
@@ -237,12 +195,13 @@ func runAdd(args []string) error {
return err 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 return nil
} }
func runSearch(args []string) error { func runSearch(args []string) error {
fs := flag.NewFlagSet("search", flag.ContinueOnError) fs := flag.NewFlagSet("search", flag.ContinueOnError)
fs.SetOutput(commandStderr)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path") dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
query := fs.String("query", "", "Search query") query := fs.String("query", "", "Search query")
repo := fs.String("repo", "", "Optional repo path filter (substring match)") repo := fs.String("repo", "", "Optional repo path filter (substring match)")
@@ -269,20 +228,21 @@ func runSearch(args []string) error {
return err return err
} }
if len(results) == 0 { if len(results) == 0 {
fmt.Println("no results") _, _ = fmt.Fprintln(commandStdout, "no results")
return nil return nil
} }
for i, r := range results { 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.Fprintf(commandStdout, "%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.Fprintf(commandStdout, " %s\n", r.Title)
fmt.Printf(" %s\n", r.Snippet) _, _ = fmt.Fprintf(commandStdout, " %s\n", r.Snippet)
} }
return nil return nil
} }
func runRepos(args []string) error { func runRepos(args []string) error {
fs := flag.NewFlagSet("repos", flag.ContinueOnError) fs := flag.NewFlagSet("repos", flag.ContinueOnError)
fs.SetOutput(commandStderr)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path") dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
@@ -299,18 +259,19 @@ func runRepos(args []string) error {
return err return err
} }
if len(repos) == 0 { if len(repos) == 0 {
fmt.Println("no repos") _, _ = fmt.Fprintln(commandStdout, "no repos")
return nil return nil
} }
for _, repo := range repos { 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 return nil
} }
func runList(args []string) error { func runList(args []string) error {
fs := flag.NewFlagSet("list", flag.ContinueOnError) fs := flag.NewFlagSet("list", flag.ContinueOnError)
fs.SetOutput(commandStderr)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path") dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
repo := fs.String("repo", "", "Optional repo path filter (substring match)") repo := fs.String("repo", "", "Optional repo path filter (substring match)")
kind := fs.String("kind", "", "Optional knowledge kind filter") kind := fs.String("kind", "", "Optional knowledge kind filter")
@@ -336,19 +297,20 @@ func runList(args []string) error {
return err return err
} }
if len(items) == 0 { if len(items) == 0 {
fmt.Println("no entries") _, _ = fmt.Fprintln(commandStdout, "no entries")
return nil return nil
} }
for _, item := range items { 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.Fprintf(commandStdout, "- #%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, " %s\n", item.Summary)
} }
return nil return nil
} }
func runEvents(args []string) error { func runEvents(args []string) error {
fs := flag.NewFlagSet("events", flag.ContinueOnError) fs := flag.NewFlagSet("events", flag.ContinueOnError)
fs.SetOutput(commandStderr)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path") dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
id := fs.Int64("id", 0, "Entry id") id := fs.Int64("id", 0, "Entry id")
repo := fs.String("repo", "", "Repo root when resolving by kind/key") repo := fs.String("repo", "", "Repo root when resolving by kind/key")
@@ -378,19 +340,19 @@ func runEvents(args []string) error {
return err return err
} }
if len(events) == 0 { if len(events) == 0 {
fmt.Println("no events") _, _ = fmt.Fprintln(commandStdout, "no events")
return nil 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 { for _, ev := range events {
line := fmt.Sprintf("- %s %s", ev.CreatedAt.Format(time.RFC3339), ev.EventType) line := fmt.Sprintf("- %s %s", ev.CreatedAt.Format(time.RFC3339), ev.EventType)
if ev.FromStatus != "" || ev.ToStatus != "" { if ev.FromStatus != "" || ev.ToStatus != "" {
line += fmt.Sprintf(" (%s -> %s)", emptyDash(ev.FromStatus), emptyDash(ev.ToStatus)) line += fmt.Sprintf(" (%s -> %s)", emptyDash(ev.FromStatus), emptyDash(ev.ToStatus))
} }
fmt.Println(line) _, _ = fmt.Fprintln(commandStdout, line)
if ev.Reason != "" { if ev.Reason != "" {
fmt.Printf(" reason: %s\n", ev.Reason) _, _ = fmt.Fprintf(commandStdout, " reason: %s\n", ev.Reason)
} }
} }
return nil return nil
@@ -398,6 +360,7 @@ func runEvents(args []string) error {
func runLink(args []string) error { func runLink(args []string) error {
fs := flag.NewFlagSet("link", flag.ContinueOnError) fs := flag.NewFlagSet("link", flag.ContinueOnError)
fs.SetOutput(commandStderr)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path") dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
fromID := fs.Int64("from-id", 0, "From entry id") fromID := fs.Int64("from-id", 0, "From entry id")
toID := fs.Int64("to-id", 0, "To entry id") toID := fs.Int64("to-id", 0, "To entry id")
@@ -416,12 +379,13 @@ func runLink(args []string) error {
return err 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 return nil
} }
func runVerify(args []string) error { func runVerify(args []string) error {
fs := flag.NewFlagSet("verify", flag.ContinueOnError) fs := flag.NewFlagSet("verify", flag.ContinueOnError)
fs.SetOutput(commandStderr)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path") 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") repo := fs.String("repo", "", "Optional repo root to verify; if omitted, verify all known repos")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
@@ -451,14 +415,14 @@ func runVerify(args []string) error {
} }
} }
if len(repoRoots) == 0 { if len(repoRoots) == 0 {
fmt.Println("no repos") _, _ = fmt.Fprintln(commandStdout, "no repos")
return nil return nil
} }
for _, repoRoot := range repoRoots { for _, repoRoot := range repoRoots {
gitState := detectGitState(repoRoot) gitState := detectGitState(repoRoot)
if gitState.commit == "" { 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 continue
} }
if _, err := st.UpsertRepo(context.Background(), store.RepoState{ if _, err := st.UpsertRepo(context.Background(), store.RepoState{
@@ -494,14 +458,14 @@ func runVerify(args []string) error {
return err 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 return nil
} }
func usage() { func usage() {
fmt.Fprintf(os.Stderr, `repo-memory: repo memory CLI _, _ = fmt.Fprintf(commandStderr, `repo-memory: repo memory CLI
Usage: Usage:
repo-memory init --db repo-memory.db 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 { type gitState struct {
branch string branch string
commit string commit string
@@ -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)
}
}
@@ -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")
}
@@ -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")
}
@@ -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)")
}
@@ -674,22 +674,32 @@ func (s *Store) ListVerifyCandidates(ctx context.Context, repoRoot string) ([]Ve
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var candidates []VerifyCandidate var candidates []VerifyCandidate
for rows.Next() { for rows.Next() {
var c VerifyCandidate var c VerifyCandidate
if err := rows.Scan(&c.ID, &c.RepoPath, &c.Kind, &c.Key, &c.Title, &c.Status, &c.VerifiedOnCommit); err != nil { 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 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 { if err != nil {
return nil, err return nil, err
} }
c.Dependencies = deps candidates[i].Dependencies = deps
candidates = append(candidates, c)
} }
return candidates, rows.Err() return candidates, nil
} }
func (s *Store) ApplyVerificationResult(ctx context.Context, entryID int64, currentCommit, nextStatus, reason string) error { func (s *Store) ApplyVerificationResult(ctx context.Context, entryID int64, currentCommit, nextStatus, reason string) error {