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:
- [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:
+12 -1
View File
@@ -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`
@@ -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() {
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
@@ -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 {
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 {