Add repo-memory integration tests
This commit is contained in:
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user