feat(migrate): plan and apply the move out of the retired wiki layout (#205 S5)

Sources are placed by the wing Resolver; concepts/entities inherit the strict
majority wing of the sources that cite them. Collisions, junk (auto-tunnel
logs) and anything unplaceable are Exceptions left in place. Apply writes the
destination O_EXCL then removes the source, only for Auto items.
Mutation-checked (junk guard, majority, slug identity, status gate,
no-overwrite, destination-exists).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 06:14:52 +02:00
co-authored by Claude Sonnet 5.5
parent b4b36c9bfd
commit dd4a825f0a
2 changed files with 552 additions and 0 deletions
+243
View File
@@ -0,0 +1,243 @@
package migrate_test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/migrate"
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
)
// keywordClassifier picks a wing by a marker word in the text, and the scorer
// agrees by the same marker, so a page "belongs" to the wing whose marker it holds.
type keywordClassifier struct{}
func (keywordClassifier) Classify(_ context.Context, text string, _ []string) (wing.Suggestion, error) {
for _, w := range []string{"alpha", "beta"} {
if strings.Contains(text, strings.ToUpper(w)) {
return wing.Suggestion{Wing: w, Confidence: 0.95, Reason: "marker"}, nil
}
}
return wing.Suggestion{}, nil
}
type keywordScorer struct{}
func (keywordScorer) Score(text string) map[string]float64 {
out := map[string]float64{}
for _, w := range []string{"alpha", "beta"} {
if strings.Contains(text, strings.ToUpper(w)) {
out[w] = 1
}
}
return out
}
func resolverFor(string) *wing.Resolver {
return &wing.Resolver{
Known: []string{"alpha", "beta", "client"},
Eligible: func(w string) bool { return w != "client" },
Classifier: keywordClassifier{},
Scorer: keywordScorer{},
Threshold: 0.8,
}
}
func page(t *testing.T, dir, rel, body string) {
t.Helper()
full := filepath.Join(dir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
}
func src(title, marker string) string {
return "---\ntitle: " + title + "\ntype: video\n---\n\n## Summary\n\n" + marker + " content.\n"
}
func entity(title string, sources ...string) string {
var b strings.Builder
b.WriteString("---\ntitle: " + title + "\ntype: tool\n---\n\n## Definition\n\nA thing.\n\n## Sources\n\n")
for _, s := range sources {
b.WriteString("- [[" + s + "|" + s + "]]\n")
}
return b.String()
}
func byFrom(items []migrate.Item) map[string]migrate.Item {
m := map[string]migrate.Item{}
for _, it := range items {
m[it.From] = it
}
return m
}
func TestPlan_AResolvedSourceGoesToItsWingsSourcesHall(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA"))
items, err := migrate.BuildPlan(context.Background(), dir, resolverFor)
require.NoError(t, err)
it := byFrom(items)["wiki/sources/talk-a.md"]
assert.Equal(t, migrate.Auto, it.Status)
assert.Equal(t, "wiki/alpha/sources/talk-a.md", it.To)
}
func TestPlan_AutoTunnelLogsAreJunkNotSources(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", src("t", "ALPHA"))
items, err := migrate.BuildPlan(context.Background(), dir, resolverFor)
require.NoError(t, err)
it := byFrom(items)["wiki/sources/tunnel-candidates-2026-06-14.md"]
assert.Equal(t, migrate.Exception, it.Status, "never auto-moved, whatever the classifier says")
assert.Equal(t, "junk", it.Method)
}
func TestPlan_AnEntityFollowsTheMajorityWingOfItsSources(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a1.md", src("A1", "ALPHA"))
page(t, dir, "wiki/sources/a2.md", src("A2", "ALPHA"))
page(t, dir, "wiki/sources/b1.md", src("B1", "BETA"))
page(t, dir, "wiki/entities/jev.md", entity("Jev", "a1", "a2", "b1"))
page(t, dir, "wiki/concepts/factory.md", "---\ntitle: Factory\n---\n\n## Definition\n\nx\n\n## Sources\n\n- [[a1|A1]]\n")
items, err := migrate.BuildPlan(context.Background(), dir, resolverFor)
require.NoError(t, err)
m := byFrom(items)
assert.Equal(t, "wiki/alpha/facts/jev.md", m["wiki/entities/jev.md"].To)
assert.Equal(t, "entity", m["wiki/entities/jev.md"].Kind)
assert.Equal(t, "wiki/alpha/facts/factory.md", m["wiki/concepts/factory.md"].To)
assert.Equal(t, "concept", m["wiki/concepts/factory.md"].Kind)
}
func TestPlan_ATieBetweenWingsIsAnException(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a1.md", src("A1", "ALPHA"))
page(t, dir, "wiki/sources/b1.md", src("B1", "BETA"))
page(t, dir, "wiki/entities/jev.md", entity("Jev", "a1", "b1"))
items, err := migrate.BuildPlan(context.Background(), dir, resolverFor)
require.NoError(t, err)
it := byFrom(items)["wiki/entities/jev.md"]
assert.Equal(t, migrate.Exception, it.Status)
assert.Contains(t, it.Reason, "disagree")
}
func TestPlan_AnEntityWhoseSourcesWereNotPlacedIsAnException(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/mystery.md", src("Mystery", "nothing recognisable"))
page(t, dir, "wiki/entities/orphan.md", entity("Orphan", "mystery"))
page(t, dir, "wiki/entities/nosources.md", "---\ntitle: No Sources\n---\n\n## Definition\n\nx\n")
items, err := migrate.BuildPlan(context.Background(), dir, resolverFor)
require.NoError(t, err)
m := byFrom(items)
assert.Equal(t, migrate.Exception, m["wiki/entities/orphan.md"].Status)
assert.Equal(t, migrate.Exception, m["wiki/entities/nosources.md"].Status)
}
func TestPlan_ADestinationThatAlreadyExistsIsAnException(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA"))
page(t, dir, "wiki/alpha/sources/talk-a.md", "someone else's page")
items, err := migrate.BuildPlan(context.Background(), dir, resolverFor)
require.NoError(t, err)
it := byFrom(items)["wiki/sources/talk-a.md"]
assert.Equal(t, migrate.Exception, it.Status)
assert.Contains(t, it.Reason, "already exists", "the occupied destination is named as the reason, not a generic slug clash")
}
func TestPlan_ASlugAlreadyUsedInAnotherWingIsAnException(t *testing.T) {
// Graph identity is the slug alone; two pages with one slug would merge.
dir := t.TempDir()
page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA"))
page(t, dir, "wiki/beta/facts/talk-a.md", "a beta page with the same slug")
items, err := migrate.BuildPlan(context.Background(), dir, resolverFor)
require.NoError(t, err)
it := byFrom(items)["wiki/sources/talk-a.md"]
assert.Equal(t, migrate.Exception, it.Status)
assert.Contains(t, it.Reason, "slug")
}
func TestApply_MovesOnlyAutoItemsAndStampsFrontmatterWithoutTouchingTheBody(t *testing.T) {
dir := t.TempDir()
body := src("Talk A", "ALPHA") + "\nSee [[jev|Jev]] and [[unknown-slug]].\n"
page(t, dir, "wiki/sources/talk-a.md", body)
page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", src("t", "ALPHA"))
page(t, dir, "wiki/entities/jev.md", entity("Jev", "talk-a"))
items, err := migrate.BuildPlan(context.Background(), dir, resolverFor)
require.NoError(t, err)
n, err := migrate.Apply(dir, items)
require.NoError(t, err)
assert.Equal(t, 2, n)
moved, err := os.ReadFile(filepath.Join(dir, "wiki/alpha/sources/talk-a.md"))
require.NoError(t, err)
assert.Contains(t, string(moved), "wing: alpha")
assert.Contains(t, string(moved), "hall: sources")
assert.Contains(t, string(moved), "See [[jev|Jev]] and [[unknown-slug]].", "links and body are byte-preserved")
ent, err := os.ReadFile(filepath.Join(dir, "wiki/alpha/facts/jev.md"))
require.NoError(t, err)
assert.Contains(t, string(ent), "kind: entity")
_, err = os.Stat(filepath.Join(dir, "wiki/sources/talk-a.md"))
assert.ErrorIs(t, err, os.ErrNotExist, "the original is removed after the copy is written")
_, err = os.Stat(filepath.Join(dir, "wiki/sources/tunnel-candidates-2026-06-14.md"))
assert.NoError(t, err, "an exception is left exactly where it is")
}
func TestApply_NeverOverwritesAndKeepsTheSourceWhenTheDestinationAppearsLate(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA"))
items, err := migrate.BuildPlan(context.Background(), dir, resolverFor)
require.NoError(t, err)
page(t, dir, "wiki/alpha/sources/talk-a.md", "appeared after the plan")
n, err := migrate.Apply(dir, items)
require.Error(t, err)
assert.Zero(t, n)
got, _ := os.ReadFile(filepath.Join(dir, "wiki/alpha/sources/talk-a.md"))
assert.Equal(t, "appeared after the plan", string(got))
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/talk-a.md"))
assert.NoError(t, statErr, "the source survives a failed write")
}
func TestPlan_IsEmptyOnceEverythingIsMigrated(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA"))
items, err := migrate.BuildPlan(context.Background(), dir, resolverFor)
require.NoError(t, err)
_, err = migrate.Apply(dir, items)
require.NoError(t, err)
again, err := migrate.BuildPlan(context.Background(), dir, resolverFor)
require.NoError(t, err)
assert.Empty(t, again)
}
// An exception that blocked on a collision still carries a destination; Apply
// must treat Status, not the presence of a path, as the decision.
func TestApply_LeavesACollisionExceptionAloneEvenThoughItHasADestination(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA"))
page(t, dir, "wiki/alpha/sources/talk-a.md", "occupied")
items, err := migrate.BuildPlan(context.Background(), dir, resolverFor)
require.NoError(t, err)
require.Equal(t, migrate.Exception, byFrom(items)["wiki/sources/talk-a.md"].Status)
n, err := migrate.Apply(dir, items)
require.NoError(t, err)
assert.Zero(t, n)
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/talk-a.md"))
assert.NoError(t, statErr)
}