Phase 5: scaffold Crucible (sow-tools) — dispatcher + builder shims, container, PR-first CI, fail-closed (D11, D19).
This commit is contained in:
@@ -1,812 +0,0 @@
|
||||
# Generated Wiki Stale Purge Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add an opt-in `purge` stale policy that permanently removes only manifest-tracked generated NodeBB wiki topics that no longer exist in current generator output.
|
||||
|
||||
**Architecture:** Keep stale ownership and candidate selection inside the existing `deploy-wiki` manifest planner. Extend the deploy result/plan model with purge counts and purge actions, add a thin NodeBB topic purge client call, remove successfully purged entries from the next manifest, and surface the policy through existing toolkit validation and module release/docs paths.
|
||||
|
||||
**Tech Stack:** Go toolkit code and `httptest` deploy tests, YAML-backed toolkit project config validation, POSIX shell module release wrapper, Markdown docs.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
Toolkit files:
|
||||
|
||||
- Modify `internal/project/project.go`: validate `purge` as a supported stale policy and remove the unsupported `unpublish` config assumption.
|
||||
- Modify `internal/project/project_test.go`: prove project config accepts `purge` and rejects unsupported stale policies.
|
||||
- Modify `internal/topdata/wiki_deploy.go`: add purge deploy state, stale purge planning, manifest removal, and NodeBB topic purge requests.
|
||||
- Modify `internal/topdata/wiki_deploy_test.go`: cover dry-run/live purge and missing-topic-id refusal without changing archive/report behavior.
|
||||
- Modify `internal/app/app.go`: expose `purge` in deploy CLI help and print `purged` deploy counts.
|
||||
- Modify `internal/app/app_test.go`: cover CLI output/help parsing for the new count and policy text.
|
||||
- Modify `README.md`: document the destructive generated-manifest-scoped purge mode in the toolkit CLI docs.
|
||||
|
||||
Module files:
|
||||
|
||||
- Modify `../module/scripts/release-all.sh`: allow `SOW_MODULE_WIKI_DEPLOY_STALE_POLICY=purge`.
|
||||
- Modify `../module/README.md`: document release usage and the destructive boundary.
|
||||
- Modify `../module/topdata/README.md`: document `deploy-wiki --stale-policy purge`.
|
||||
- Modify `../module/tests/release-all-cache-cleanup.sh`: prove release automation forwards `purge` into both wiki deploy phases.
|
||||
|
||||
## Task 1: Align Stale Policy Validation
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/project/project.go`
|
||||
- Modify: `internal/project/project_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing project-config test**
|
||||
|
||||
Add a project validation case in `internal/project/project_test.go` that loads stale policy config with `purge` and expects validation to pass:
|
||||
|
||||
```go
|
||||
func TestProjectValidationAcceptsWikiStalePurgePolicy(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeProjectFile(t, filepath.Join(root, ConfigFile), `
|
||||
module:
|
||||
name: Test Module
|
||||
resref: testmod
|
||||
topdata:
|
||||
wiki:
|
||||
stale_pages:
|
||||
default: report
|
||||
live_cleanup: purge
|
||||
`)
|
||||
|
||||
proj, err := Load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("load project: %v", err)
|
||||
}
|
||||
if err := proj.ValidateLayout(); err != nil {
|
||||
t.Fatalf("validate purge stale policy: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/project -run TestProjectValidationAcceptsWikiStalePurgePolicy -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because `topdata.wiki.stale_pages.live_cleanup "purge" is not supported`.
|
||||
|
||||
- [ ] **Step 3: Replace unsupported project validation vocabulary**
|
||||
|
||||
In `internal/project/project.go`, update the stale-policy switch from:
|
||||
|
||||
```go
|
||||
switch policy {
|
||||
case "report", "archive", "unpublish":
|
||||
default:
|
||||
failures = append(failures, fmt.Errorf("%s %q is not supported", key, policy))
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```go
|
||||
switch policy {
|
||||
case "report", "archive", "purge":
|
||||
default:
|
||||
failures = append(failures, fmt.Errorf("%s %q is not supported", key, policy))
|
||||
}
|
||||
```
|
||||
|
||||
This removes the config-only `unpublish` value that the deploy command cannot execute.
|
||||
|
||||
- [ ] **Step 4: Add the unsupported-policy regression**
|
||||
|
||||
Add or extend a project validation test so `unpublish` and an arbitrary bad value fail:
|
||||
|
||||
```go
|
||||
func TestProjectValidationRejectsUnsupportedWikiStalePolicies(t *testing.T) {
|
||||
for _, policy := range []string{"unpublish", "remove-everything"} {
|
||||
t.Run(policy, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeProjectFile(t, filepath.Join(root, ConfigFile), fmt.Sprintf(`
|
||||
module:
|
||||
name: Test Module
|
||||
resref: testmod
|
||||
topdata:
|
||||
wiki:
|
||||
stale_pages:
|
||||
live_cleanup: %s
|
||||
`, policy))
|
||||
|
||||
proj, err := Load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("load project: %v", err)
|
||||
}
|
||||
err = proj.ValidateLayout()
|
||||
if err == nil || !strings.Contains(err.Error(), policy) {
|
||||
t.Fatalf("expected %q stale policy validation failure, got %v", policy, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `fmt` to the existing `internal/project/project_test.go` imports for the `Sprintf` YAML fixture.
|
||||
|
||||
- [ ] **Step 5: Run project tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/project -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit the validation change**
|
||||
|
||||
```bash
|
||||
git add internal/project/project.go internal/project/project_test.go
|
||||
git commit -m "feat: validate wiki stale purge policy"
|
||||
```
|
||||
|
||||
## Task 2: Plan Manifest-Scoped Purge Actions
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/topdata/wiki_deploy.go`
|
||||
- Modify: `internal/topdata/wiki_deploy_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing deploy tests for dry-run purge planning**
|
||||
|
||||
In `internal/topdata/wiki_deploy_test.go`, add:
|
||||
|
||||
```go
|
||||
func TestDeployWikiDryRunPlansTrackedStalePagePurge(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sourceDir := filepath.Join(root, "pages")
|
||||
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
|
||||
t.Fatalf("create source dir: %v", err)
|
||||
}
|
||||
manifestPath := filepath.Join(root, "deploy-manifest.json")
|
||||
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
|
||||
Version: "nodebb-v1",
|
||||
Pages: map[string]wikiDeployManifestPage{
|
||||
"skills:retired": {Hash: "old-hash", Title: "Retired Skill", Namespace: "skills", TID: 7, PID: 42, CID: 3},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("write deploy manifest: %v", err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("dry-run purge must not call NodeBB, got %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
|
||||
SourceDir: sourceDir,
|
||||
Endpoint: server.URL,
|
||||
Token: "nodebb-token",
|
||||
ManifestPath: manifestPath,
|
||||
DryRun: true,
|
||||
StalePolicy: "purge",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployWikiWithOptions stale purge dry run failed: %v", err)
|
||||
}
|
||||
if result.Stale != 1 || result.Purged != 1 {
|
||||
t.Fatalf("expected one planned stale purge, got %#v", result)
|
||||
}
|
||||
if _, ok := loadDeployManifest(manifestPath).Pages["skills:retired"]; !ok {
|
||||
t.Fatalf("dry-run purge must not change the deploy manifest")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing missing-topic-id test**
|
||||
|
||||
Add:
|
||||
|
||||
```go
|
||||
func TestDeployWikiPurgeRefusesStaleManifestEntryWithoutTopicID(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sourceDir := filepath.Join(root, "pages")
|
||||
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
|
||||
t.Fatalf("create source dir: %v", err)
|
||||
}
|
||||
manifestPath := filepath.Join(root, "deploy-manifest.json")
|
||||
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
|
||||
Version: "nodebb-v1",
|
||||
Pages: map[string]wikiDeployManifestPage{
|
||||
"skills:retired": {Hash: "old-hash", Title: "Retired Skill", Namespace: "skills", PID: 42, CID: 3},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("write deploy manifest: %v", err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("missing-topic-id purge must not call NodeBB, got %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
|
||||
SourceDir: sourceDir,
|
||||
Endpoint: server.URL,
|
||||
Token: "nodebb-token",
|
||||
ManifestPath: manifestPath,
|
||||
DryRun: true,
|
||||
StalePolicy: "purge",
|
||||
}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "topic id") {
|
||||
t.Fatalf("expected stale purge to require tracked topic id, got %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the focused tests to verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/topdata -run 'TestDeployWiki.*Purge' -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because `purge` is not yet accepted and `deployResult` has no `Purged` field.
|
||||
|
||||
- [ ] **Step 4: Add purge plan/result model fields**
|
||||
|
||||
In `internal/topdata/wiki_deploy.go`, extend `deployResult`:
|
||||
|
||||
```go
|
||||
type deployResult struct {
|
||||
LocalPages int
|
||||
Created int
|
||||
Updated int
|
||||
Stale int
|
||||
Archived int
|
||||
Purged int
|
||||
Skipped int
|
||||
Drifted int
|
||||
Renamed int
|
||||
Manifest string
|
||||
}
|
||||
```
|
||||
|
||||
Allow `purge` during deploy option validation:
|
||||
|
||||
```go
|
||||
if opts.StalePolicy != "" &&
|
||||
opts.StalePolicy != "report" &&
|
||||
opts.StalePolicy != "archive" &&
|
||||
opts.StalePolicy != "purge" {
|
||||
return deployResult{}, fmt.Errorf("wiki stale policy %q is not supported", opts.StalePolicy)
|
||||
}
|
||||
```
|
||||
|
||||
Add the purge count to the deploy plan progress line so dry-run logs show the destructive action explicitly:
|
||||
|
||||
```go
|
||||
progress(fmt.Sprintf("NodeBB wiki plan: create %d, update %d, rename %d, skip %d, stale %d, archive %d, purge %d, drift %d",
|
||||
result.Created,
|
||||
result.Updated,
|
||||
result.Renamed,
|
||||
result.Skipped,
|
||||
result.Stale,
|
||||
result.Archived,
|
||||
result.Purged,
|
||||
result.Drifted,
|
||||
))
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add stale purge planning**
|
||||
|
||||
In the stale manifest loop inside `planNodeBBDeploy`, keep the current report/archive branches and add the purge branch:
|
||||
|
||||
```go
|
||||
case "purge":
|
||||
if entry.TID == 0 {
|
||||
return nil, result, next, fmt.Errorf("stale generated wiki page %q cannot be purged without tracked NodeBB topic id", pageID)
|
||||
}
|
||||
result.Purged++
|
||||
delete(next.Pages, pageID)
|
||||
plans = append(plans, wikiDeployPlan{
|
||||
Page: wikiDeployPage{PageID: pageID, Title: entry.Title, Namespace: entry.Namespace},
|
||||
Entry: entry,
|
||||
Action: "purge",
|
||||
})
|
||||
```
|
||||
|
||||
Use a `switch policy` or equivalent structure so `report`, `archive`, and `purge` remain explicit. Do not add namespace search or title matching to find purge targets.
|
||||
|
||||
- [ ] **Step 6: Run the focused purge planning tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/topdata -run 'TestDeployWiki.*Purge' -count=1
|
||||
```
|
||||
|
||||
Expected: the dry-run and missing-topic-id purge tests pass once they compile.
|
||||
|
||||
- [ ] **Step 7: Commit purge planning**
|
||||
|
||||
```bash
|
||||
git add internal/topdata/wiki_deploy.go internal/topdata/wiki_deploy_test.go
|
||||
git commit -m "feat: plan stale generated wiki purges"
|
||||
```
|
||||
|
||||
## Task 3: Execute NodeBB Topic Purges
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/topdata/wiki_deploy.go`
|
||||
- Modify: `internal/topdata/wiki_deploy_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing live purge test**
|
||||
|
||||
Add a live stale purge test in `internal/topdata/wiki_deploy_test.go`:
|
||||
|
||||
```go
|
||||
func TestDeployWikiPurgesTrackedStaleGeneratedTopic(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sourceDir := filepath.Join(root, "pages")
|
||||
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
|
||||
t.Fatalf("create source dir: %v", err)
|
||||
}
|
||||
manifestPath := filepath.Join(root, "deploy-manifest.json")
|
||||
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
|
||||
Version: "nodebb-v1",
|
||||
Pages: map[string]wikiDeployManifestPage{
|
||||
"skills:retired": {Hash: "old-hash", Title: "Retired Skill", Namespace: "skills", TID: 7, PID: 42, CID: 3},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("write deploy manifest: %v", err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete || r.URL.Path != "/api/v3/topics/7" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
|
||||
SourceDir: sourceDir,
|
||||
Endpoint: server.URL,
|
||||
Token: "nodebb-token",
|
||||
ManifestPath: manifestPath,
|
||||
StalePolicy: "purge",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployWikiWithOptions stale purge failed: %v", err)
|
||||
}
|
||||
if result.Stale != 1 || result.Purged != 1 {
|
||||
t.Fatalf("expected one stale purge, got %#v", result)
|
||||
}
|
||||
if _, ok := loadDeployManifest(manifestPath).Pages["skills:retired"]; ok {
|
||||
t.Fatalf("expected purged stale manifest entry to be removed")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This route is the NodeBB core purge route. `DELETE /api/v3/topics/:tid/state` is the separate soft-delete route and must not be used here.
|
||||
|
||||
- [ ] **Step 2: Run the live purge test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/topdata -run 'TestDeployWiki.*Live.*Purge|TestDeployWiki.*Purges.*Stale' -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because purge plans are not yet executed by the NodeBB client.
|
||||
|
||||
- [ ] **Step 3: Add the NodeBB purge client method**
|
||||
|
||||
Place a thin method near `updatePost` and `renameWikiPage` in `internal/topdata/wiki_deploy.go`:
|
||||
|
||||
```go
|
||||
func (c *nodeBBClient) purgeTopic(tid int) error {
|
||||
if tid == 0 {
|
||||
return fmt.Errorf("NodeBB topic purge requires topic id")
|
||||
}
|
||||
return c.request(http.MethodDelete, fmt.Sprintf("/api/v3/topics/%d", tid), nil, nil)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Execute purge plans**
|
||||
|
||||
In the live plan execution switch, add:
|
||||
|
||||
```go
|
||||
case "purge":
|
||||
if err := client.purgeTopic(plan.Entry.TID); err != nil {
|
||||
return result, fmt.Errorf("deploy wiki page %q: purge NodeBB topic %d: %w", plan.Page.PageID, plan.Entry.TID, err)
|
||||
}
|
||||
```
|
||||
|
||||
Do not acquire wiki edit locks for purge: the NodeBB topic purge endpoint owns its own privilege checks.
|
||||
|
||||
- [ ] **Step 5: Keep current generated pages out of purge coverage**
|
||||
|
||||
Add this test so a current generated page with the same manifest page id is skipped instead of purged:
|
||||
|
||||
```go
|
||||
func TestDeployWikiPurgeDoesNotTargetCurrentGeneratedPages(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sourceDir := filepath.Join(root, "pages")
|
||||
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
|
||||
t.Fatalf("create source dir: %v", err)
|
||||
}
|
||||
generated := "[//]: # (sow-topdata-wiki:managed:start)\n# Athletics\n\nGenerated athletics page\n[//]: # (sow-topdata-wiki:managed:end)\n"
|
||||
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "athletics.md"), []byte(generated), 0644); err != nil {
|
||||
t.Fatalf("write source page: %v", err)
|
||||
}
|
||||
manifestPath := filepath.Join(root, "deploy-manifest.json")
|
||||
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
|
||||
Version: "nodebb-v1",
|
||||
Pages: map[string]wikiDeployManifestPage{
|
||||
"skills:athletics": {
|
||||
Hash: computeManagedHash(generated),
|
||||
Title: "Athletics",
|
||||
Namespace: "skills",
|
||||
TID: 7,
|
||||
PID: 42,
|
||||
CID: 3,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("write deploy manifest: %v", err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("current generated page must not call NodeBB during matching-hash deploy, got %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
|
||||
SourceDir: sourceDir,
|
||||
Endpoint: server.URL,
|
||||
Token: "nodebb-token",
|
||||
ManifestPath: manifestPath,
|
||||
StalePolicy: "purge",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployWikiWithOptions current page purge deploy failed: %v", err)
|
||||
}
|
||||
if result.Stale != 0 || result.Purged != 0 || result.Skipped != 1 {
|
||||
t.Fatalf("expected current generated page to skip purge, got %#v", result)
|
||||
}
|
||||
if _, ok := loadDeployManifest(manifestPath).Pages["skills:athletics"]; !ok {
|
||||
t.Fatalf("expected current generated page to remain in deploy manifest")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run deploy tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/topdata -run 'TestDeployWiki' -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit purge execution**
|
||||
|
||||
```bash
|
||||
git add internal/topdata/wiki_deploy.go internal/topdata/wiki_deploy_test.go
|
||||
git commit -m "feat: purge stale generated wiki topics"
|
||||
```
|
||||
|
||||
## Task 4: Surface Purge In Toolkit CLI And Docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/app/app.go`
|
||||
- Modify: `internal/app/app_test.go`
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Write failing CLI-output coverage**
|
||||
|
||||
In `internal/app/app_test.go`, extend `TestTopdataConsoleDebugProgressAndRelativePaths` so the sample progress and emit call include a purge count:
|
||||
|
||||
```go
|
||||
console.progress("NodeBB wiki plan: create 1, update 2, skip 3, stale 4, archive 5, purge 6, drift 0")
|
||||
console.emitWikiDeployResult(10, 1, 2, 3, 4, 5, 6, 0, "/workspace/project/build/wiki/deploy-manifest.json")
|
||||
```
|
||||
|
||||
Assert:
|
||||
|
||||
```go
|
||||
if !strings.Contains(output, "purged: 6") {
|
||||
t.Fatalf("expected purged deploy count, got %q", output)
|
||||
}
|
||||
```
|
||||
|
||||
Update the debug progress expectation to include `purge 6`.
|
||||
|
||||
- [ ] **Step 2: Run focused app tests to verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/app -run TestTopdataConsoleDebugProgressAndRelativePaths -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because `emitWikiDeployResult` does not yet take a purge count.
|
||||
|
||||
- [ ] **Step 3: Print purge counts**
|
||||
|
||||
Update the emit signature and output in `internal/app/app.go`:
|
||||
|
||||
```go
|
||||
func (c *topdataConsole) emitWikiDeployResult(localPages, created, updated, skipped, stale, archived, purged, drifted int, manifest string) {
|
||||
spin.linebreak()
|
||||
fmt.Fprintln(c.stdout, "Deploy Wiki ----------")
|
||||
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
||||
fmt.Fprintf(c.stdout, "local pages: %d\n", localPages)
|
||||
fmt.Fprintf(c.stdout, "created: %d\n", created)
|
||||
fmt.Fprintf(c.stdout, "updated: %d\n", updated)
|
||||
fmt.Fprintf(c.stdout, "skipped: %d\n", skipped)
|
||||
fmt.Fprintf(c.stdout, "stale: %d\n", stale)
|
||||
fmt.Fprintf(c.stdout, "archived: %d\n", archived)
|
||||
fmt.Fprintf(c.stdout, "purged: %d\n", purged)
|
||||
fmt.Fprintf(c.stdout, "drifted: %d\n", drifted)
|
||||
fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(manifest))
|
||||
}
|
||||
```
|
||||
|
||||
Pass `result.Purged` from the deploy command call site.
|
||||
|
||||
- [ ] **Step 4: Update CLI usage text**
|
||||
|
||||
Change deploy-wiki help text in `internal/app/app.go` from:
|
||||
|
||||
```go
|
||||
[--stale-policy <report|archive>]
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```go
|
||||
[--stale-policy <report|archive|purge>]
|
||||
```
|
||||
|
||||
Add this focused help assertion in `internal/app/app_test.go`:
|
||||
|
||||
```go
|
||||
func TestParseDeployWikiHelpListsPurgeStalePolicy(t *testing.T) {
|
||||
_, err := parseDeployWikiArgs("deploy-wiki", []string{"--help"})
|
||||
if err == nil || !strings.Contains(err.Error(), "--stale-policy <report|archive|purge>") {
|
||||
t.Fatalf("expected deploy-wiki help to list purge stale policy, got %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update toolkit README**
|
||||
|
||||
In `README.md`, update the `deploy-wiki` usage and stale-page explanation so it states:
|
||||
|
||||
```markdown
|
||||
`--stale-policy purge` permanently removes only stale generated topics already
|
||||
tracked in the wiki deploy manifest. Dry-run that policy before the live run;
|
||||
it never discovers manual wiki pages from NodeBB for purge.
|
||||
```
|
||||
|
||||
Keep `report` as the documented default and `archive` as the nondestructive cleanup option.
|
||||
|
||||
- [ ] **Step 6: Run app tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/app -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit toolkit CLI/docs**
|
||||
|
||||
```bash
|
||||
git add internal/app/app.go internal/app/app_test.go README.md
|
||||
git commit -m "docs: expose wiki stale purge policy"
|
||||
```
|
||||
|
||||
## Task 5: Expose Purge Through Module Release Workflow
|
||||
|
||||
**Files:**
|
||||
- Modify: `../module/scripts/release-all.sh`
|
||||
- Modify: `../module/README.md`
|
||||
- Modify: `../module/topdata/README.md`
|
||||
- Modify: `../module/tests/release-all-cache-cleanup.sh`
|
||||
|
||||
- [ ] **Step 1: Write failing release wrapper coverage**
|
||||
|
||||
In `../module/tests/release-all-cache-cleanup.sh`, set the stale policy in the existing release invocation:
|
||||
|
||||
```bash
|
||||
SOW_MODULE_WIKI_DEPLOY_STALE_POLICY="purge" \
|
||||
TAG_NAME="v-test" \
|
||||
"${CHECKOUT}/scripts/release-all.sh" >"${TEST_TMP}/stdout.log" 2>"${TEST_TMP}/stderr.log"
|
||||
```
|
||||
|
||||
Update the wiki log expectations from:
|
||||
|
||||
```bash
|
||||
grep -Fxq "deploy-wiki --dry-run --create" "${TEST_TMP}/wiki.log" \
|
||||
|| fail "wiki dry run was not invoked"
|
||||
grep -Fxq "deploy-wiki --create" "${TEST_TMP}/wiki.log" \
|
||||
|| fail "wiki deploy was not invoked"
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```bash
|
||||
grep -Fxq "deploy-wiki --dry-run --create --stale-policy purge" "${TEST_TMP}/wiki.log" \
|
||||
|| fail "wiki stale purge dry run was not invoked"
|
||||
grep -Fxq "deploy-wiki --create --stale-policy purge" "${TEST_TMP}/wiki.log" \
|
||||
|| fail "wiki stale purge deploy was not invoked"
|
||||
```
|
||||
|
||||
Update the API ordering assertion to match the new dry-run log line:
|
||||
|
||||
```awk
|
||||
$0 == "WIKI deploy-wiki --dry-run --create --stale-policy purge" { wiki_dry_run = NR }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the release wrapper test to verify it fails**
|
||||
|
||||
Run from `../module`:
|
||||
|
||||
```bash
|
||||
./tests/release-all-cache-cleanup.sh
|
||||
```
|
||||
|
||||
Expected: FAIL because `scripts/release-all.sh` rejects `SOW_MODULE_WIKI_DEPLOY_STALE_POLICY=purge`.
|
||||
|
||||
- [ ] **Step 3: Update release wrapper validation**
|
||||
|
||||
In `../module/scripts/release-all.sh`, change:
|
||||
|
||||
```bash
|
||||
if [[ "${SOW_MODULE_WIKI_DEPLOY_STALE_POLICY}" != "report" && "${SOW_MODULE_WIKI_DEPLOY_STALE_POLICY}" != "archive" ]]; then
|
||||
echo "Unsupported SOW_MODULE_WIKI_DEPLOY_STALE_POLICY=${SOW_MODULE_WIKI_DEPLOY_STALE_POLICY}; expected report or archive" >&2
|
||||
return 1
|
||||
fi
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```bash
|
||||
if [[ "${SOW_MODULE_WIKI_DEPLOY_STALE_POLICY}" != "report" && "${SOW_MODULE_WIKI_DEPLOY_STALE_POLICY}" != "archive" && "${SOW_MODULE_WIKI_DEPLOY_STALE_POLICY}" != "purge" ]]; then
|
||||
echo "Unsupported SOW_MODULE_WIKI_DEPLOY_STALE_POLICY=${SOW_MODULE_WIKI_DEPLOY_STALE_POLICY}; expected report, archive, or purge" >&2
|
||||
return 1
|
||||
fi
|
||||
```
|
||||
|
||||
The existing dry-run/live deploy argument plumbing already forwards every non-`report` stale policy through `--stale-policy`.
|
||||
|
||||
- [ ] **Step 4: Run the release wrapper test**
|
||||
|
||||
Run from `../module`:
|
||||
|
||||
```bash
|
||||
./tests/release-all-cache-cleanup.sh
|
||||
```
|
||||
|
||||
Expected: PASS and both release wiki deploy phases include `--stale-policy purge`.
|
||||
|
||||
- [ ] **Step 5: Document release controls**
|
||||
|
||||
In `../module/README.md`, revise the environment variable bullet to name both destructive modes explicitly:
|
||||
|
||||
```markdown
|
||||
- `SOW_MODULE_WIKI_DEPLOY_STALE_POLICY` (defaults to `report`; use `archive`
|
||||
after a controlled dry run to retire stale generated pages in place, or
|
||||
`purge` after a controlled dry run to permanently remove stale generated
|
||||
topics already tracked in the wiki deploy manifest)
|
||||
```
|
||||
|
||||
In the wiki deployment paragraph, replace the archive-only sentence with a sentence that keeps these boundaries:
|
||||
|
||||
- `report` is default
|
||||
- `archive` rewrites stale generated pages in place
|
||||
- `purge` permanently removes only manifest-tracked stale generated topics
|
||||
- manual NodeBB wiki pages are not purge candidates
|
||||
|
||||
- [ ] **Step 6: Document local deploy controls**
|
||||
|
||||
In `../module/topdata/README.md`, replace:
|
||||
|
||||
```markdown
|
||||
reports stale manifest entries by default and can archive them in place with `--stale-policy archive`; it does not delete topics
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```markdown
|
||||
reports stale manifest entries by default, can archive them in place with
|
||||
`--stale-policy archive`, and can permanently purge only manifest-tracked stale
|
||||
generated topics with `--stale-policy purge` after a dry run
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Check module diffs**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git -C ../module diff --check
|
||||
git -C ../module diff -- scripts/release-all.sh tests/release-all-cache-cleanup.sh README.md topdata/README.md
|
||||
```
|
||||
|
||||
Expected: no whitespace failures; diff shows only wrapper policy vocabulary, wrapper coverage, and docs.
|
||||
|
||||
- [ ] **Step 8: Commit module wrapper/docs**
|
||||
|
||||
Run from `../module`:
|
||||
|
||||
```bash
|
||||
git add scripts/release-all.sh tests/release-all-cache-cleanup.sh README.md topdata/README.md
|
||||
git commit -m "docs: allow generated wiki stale purges"
|
||||
```
|
||||
|
||||
## Task 6: Full Verification And Operator Notes
|
||||
|
||||
**Files:**
|
||||
- Verify: toolkit and module working trees
|
||||
|
||||
- [ ] **Step 1: Format Go changes**
|
||||
|
||||
Run from toolkit:
|
||||
|
||||
```bash
|
||||
gofmt -w internal/project/project.go internal/project/project_test.go internal/topdata/wiki_deploy.go internal/topdata/wiki_deploy_test.go internal/app/app.go internal/app/app_test.go
|
||||
```
|
||||
|
||||
Expected: command exits 0.
|
||||
|
||||
- [ ] **Step 2: Run toolkit verification**
|
||||
|
||||
Run from toolkit:
|
||||
|
||||
```bash
|
||||
go test ./internal/project ./internal/topdata ./internal/app
|
||||
go test ./...
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: all tests pass and diff check emits no whitespace errors.
|
||||
|
||||
- [ ] **Step 3: Build the toolkit development binary**
|
||||
|
||||
Run from toolkit:
|
||||
|
||||
```bash
|
||||
./build-tool.sh
|
||||
```
|
||||
|
||||
Expected: `tools/sow-toolkit` is rebuilt successfully for module wrapper verification.
|
||||
|
||||
- [ ] **Step 4: Run module verification with the development binary**
|
||||
|
||||
Run from module:
|
||||
|
||||
```bash
|
||||
SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./validate-topdata.sh
|
||||
SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./build-wiki.sh --force
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: topdata validation and wiki build pass; module diff check emits no whitespace errors.
|
||||
|
||||
- [ ] **Step 5: Record the cleanup command in the final handoff**
|
||||
|
||||
The final implementation response must state that destructive cleanup is opt-in and show the operator sequence:
|
||||
|
||||
```bash
|
||||
./deploy-wiki.sh --dry-run --stale-policy purge
|
||||
./deploy-wiki.sh --stale-policy purge
|
||||
```
|
||||
|
||||
For tag-triggered module release automation, state the equivalent variable:
|
||||
|
||||
```bash
|
||||
SOW_MODULE_WIKI_DEPLOY_STALE_POLICY=purge
|
||||
```
|
||||
|
||||
The handoff must mention that purge targets only stale generated pages already tracked in the deploy manifest and permanently removes their NodeBB topics/posts.
|
||||
@@ -1,200 +0,0 @@
|
||||
# Class Progression Feat Projections Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make class progression feat grouping configurable from wiki YAML so the module template can render granted feats and level-gated available feats separately.
|
||||
|
||||
**Architecture:** Extend the `class_progression` data provider config with named class-feat projection fields. The toolkit evaluates each configured projection against class feat rows and groups resolved feat links onto progression rows by level, while the module wiki config declares the project-specific `List` rules and the class template owns output wording.
|
||||
|
||||
**Tech Stack:** Go `sow-toolkit`, native topdata wiki YAML, HTML wiki templates, Go tests.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- `internal/topdata/wiki_data_providers.go`: parse and validate data-provider projection configuration and pass it into class progression row assembly.
|
||||
- `internal/topdata/wiki_tables.go`: share class progression row assembly with configured class feat projections and keep default compatibility behavior.
|
||||
- `internal/topdata/wiki_native_test.go`: regression coverage for provider projection parsing, filtering, grouping, ordering, invalid config, and rendered template fields.
|
||||
- `../module/topdata/wiki/data.yaml`: declare module-owned `GrantedFeats` and `AvailableFeats` projections for class progression.
|
||||
- `../module/topdata/wiki/templates/pages/classes.html`: render bonus feats inline and use projected available feats in the Notes cell.
|
||||
- `../module/topdata/wiki/README.md`: document configurable data-provider projections for template authors.
|
||||
|
||||
### Task 1: Add Configured Class Feat Projection Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/topdata/wiki_native_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing provider projection tests**
|
||||
|
||||
Add tests that build `wikiDataProviderDefinition` from YAML such as:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
ClassProgression:
|
||||
source: class_progression
|
||||
levels: 1-4
|
||||
class_feats:
|
||||
fields:
|
||||
GrantedFeats:
|
||||
when: "{{GrantedOnLevel > 0 && List == 3}}"
|
||||
AvailableFeats:
|
||||
when: "{{GrantedOnLevel > 0 && List != 3}}"
|
||||
```
|
||||
|
||||
Assert the parsed provider can render a progression row where `GrantedFeats`
|
||||
contains `List == 3` feat links, `AvailableFeats` contains positive-level
|
||||
non-`3` feat links, matched link order follows fixture source row order, and
|
||||
`BonusFeat` is still present.
|
||||
|
||||
- [ ] **Step 2: Add failing invalid configuration coverage**
|
||||
|
||||
Add a test that loads a provider field with an empty projection name or empty
|
||||
`when` expression and expects an error containing `data.yaml`, the provider
|
||||
name, and the projection field context.
|
||||
|
||||
- [ ] **Step 3: Run focused tests to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/topdata -run 'TestWiki.*ClassProgression.*Projection|TestWikiData.*Provider.*Projection' -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because `wikiDataProviderDefinition` does not yet accept
|
||||
`class_feats.fields` and progression rows do not expose `AvailableFeats`.
|
||||
|
||||
### Task 2: Implement Generic Provider Projections
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/topdata/wiki_data_providers.go`
|
||||
- Modify: `internal/topdata/wiki_tables.go`
|
||||
- Test: `internal/topdata/wiki_native_test.go`
|
||||
|
||||
- [ ] **Step 1: Extend provider config types and validation**
|
||||
|
||||
Add typed YAML support for:
|
||||
|
||||
```go
|
||||
type wikiDataProviderClassFeatConfig struct {
|
||||
Fields map[string]wikiDataProviderProjectionField `json:"fields" yaml:"fields"`
|
||||
}
|
||||
|
||||
type wikiDataProviderProjectionField struct {
|
||||
When string `json:"when" yaml:"when"`
|
||||
}
|
||||
```
|
||||
|
||||
Validate each configured projection field name and `when` expression before
|
||||
provider execution.
|
||||
|
||||
- [ ] **Step 2: Carry projections into progression row assembly**
|
||||
|
||||
Change the provider path so `ClassProgression` passes provider-owned class feat
|
||||
projection fields into progression row assembly. Preserve table-based
|
||||
`class_progression` callers by giving them the current default `GrantedFeats`
|
||||
behavior.
|
||||
|
||||
- [ ] **Step 3: Evaluate and group projections**
|
||||
|
||||
In progression row assembly, resolve class feat links once per matching row,
|
||||
evaluate configured `when` expressions against class feat rows, and append
|
||||
matched links to the configured progression field for each positive
|
||||
`GrantedOnLevel` value in source row order.
|
||||
|
||||
- [ ] **Step 4: Run focused tests to verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/topdata -run 'TestWiki.*ClassProgression.*Projection|TestWikiData.*Provider.*Projection' -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Configure Module Wiki Output
|
||||
|
||||
**Files:**
|
||||
- Modify: `../module/topdata/wiki/data.yaml`
|
||||
- Modify: `../module/topdata/wiki/templates/pages/classes.html`
|
||||
- Modify: `../module/topdata/wiki/README.md`
|
||||
|
||||
- [ ] **Step 1: Declare module projections**
|
||||
|
||||
Update `ClassProgression` in `../module/topdata/wiki/data.yaml` so module YAML
|
||||
owns `GrantedFeats` and `AvailableFeats` selection:
|
||||
|
||||
```yaml
|
||||
ClassProgression:
|
||||
source: class_progression
|
||||
levels: 1-12
|
||||
class_feats:
|
||||
fields:
|
||||
GrantedFeats:
|
||||
when: "{{GrantedOnLevel > 0 && List == 3}}"
|
||||
AvailableFeats:
|
||||
when: "{{GrantedOnLevel > 0 && List != 3}}"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Render projected fields in the class template**
|
||||
|
||||
Keep bonus feats in the Feats cell with a conditional comma. Render
|
||||
`AvailableFeats` in the Notes cell with module-owned “becomes available”
|
||||
wording using supported template loop/filter syntax.
|
||||
|
||||
- [ ] **Step 3: Document the projection surface**
|
||||
|
||||
Add a concise `data.yaml` note to the wiki README explaining that data-provider
|
||||
projection fields can expose configured per-level class feat lists to page
|
||||
templates and that wording stays in templates.
|
||||
|
||||
### Task 4: Verify The Cross-Repo Wiki Path
|
||||
|
||||
**Files:**
|
||||
- Verify: `internal/topdata/...`
|
||||
- Verify: `../module/topdata/wiki/...`
|
||||
|
||||
- [ ] **Step 1: Run toolkit regression tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/topdata -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Build the local toolkit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./build-tool.sh
|
||||
```
|
||||
|
||||
Expected: exit `0` and refresh `tools/sow-toolkit` when source changes require
|
||||
it.
|
||||
|
||||
- [ ] **Step 3: Build module wiki with the local toolkit**
|
||||
|
||||
Run from `../module`:
|
||||
|
||||
```bash
|
||||
SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./build-wiki.sh --force
|
||||
```
|
||||
|
||||
Expected: exit `0` and regenerate class wiki pages under `.cache/wiki/pages/`.
|
||||
|
||||
- [ ] **Step 4: Inspect fighter output**
|
||||
|
||||
Check generated fighter progression output for inline bonus feat text in Feats
|
||||
cells and `becomes available` notes for level-gated selectable feat links.
|
||||
|
||||
- [ ] **Step 5: Run diff checks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
in both toolkit and module repos.
|
||||
@@ -1,47 +0,0 @@
|
||||
# Wiki List Columns Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a template-driven topdata wiki option for splitting provider-backed lists into configured columns with a fixed item count and optional forced empty columns.
|
||||
|
||||
**Architecture:** The toolkit wiki renderer gains a generic `columns` data-provider source that wraps another provider and exposes one row per rendered column. Each column row exposes `Items`, `Index`, and count metadata so page templates own markup while YAML owns row selection and chunking behavior. The module class page uses this provider for `ClassSkills`.
|
||||
|
||||
**Tech Stack:** Go `sow-toolkit`, YAML topdata wiki config, local HTML templates, Go tests.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Renderer Provider
|
||||
|
||||
**Files:**
|
||||
- Modify: `toolkit/internal/topdata/wiki_data_providers.go`
|
||||
- Modify: `toolkit/internal/topdata/wiki_template.go`
|
||||
- Test: `toolkit/internal/topdata/wiki_native_test.go`
|
||||
|
||||
- [ ] Write failing tests for a `columns` provider that chunks `ClassSkills` into rows with nested `Items`, including forced empty columns.
|
||||
- [ ] Run `go test ./internal/topdata -run 'TestWikiTemplate.*Column|TestBuildNativeWikiRendersClassSkillColumns'` and confirm the new assertions fail because `columns` is not supported.
|
||||
- [ ] Add `provider`, `columns`, `items_per_column`, and `force_columns` fields to `wikiDataProviderDefinition`.
|
||||
- [ ] Validate that `columns` providers name an existing provider, use positive integer `columns` and `items_per_column`, and do not point at themselves.
|
||||
- [ ] Allow nested `{{#each Items}}` loops by teaching template `#each` to iterate an array field from the current row before falling back to named providers.
|
||||
- [ ] Implement deterministic chunking: each column receives up to `items_per_column`; extra source items continue into additional overflow columns; `force_columns: true` emits empty configured columns when there are fewer items.
|
||||
|
||||
### Task 2: Module Template
|
||||
|
||||
**Files:**
|
||||
- Modify: `module/topdata/wiki/data.yaml`
|
||||
- Modify: `module/topdata/wiki/templates/pages/classes.html`
|
||||
- Modify: `module/topdata/wiki/README.md`
|
||||
|
||||
- [ ] Add `ClassSkillColumns` using `source: columns`, `provider: ClassSkills`, `columns: 3`, `items_per_column: 12`, and `force_columns: true`.
|
||||
- [ ] Replace the flat class skill `<ul>` with a `.wiki-list-columns .wiki-list-columns--3` wrapper and nested `{{#each Items}}` loops.
|
||||
- [ ] Document the generic `columns` provider fields for template authors.
|
||||
|
||||
### Task 3: Verification
|
||||
|
||||
**Files:**
|
||||
- Verify: `toolkit/internal/topdata/...`
|
||||
- Verify: `module/topdata/wiki/...`
|
||||
|
||||
- [ ] Run `go test ./internal/topdata -run 'TestWikiTemplate|TestBuildNativeWiki'`.
|
||||
- [ ] Run `./build-tool.sh` from `toolkit/`.
|
||||
- [ ] Run `SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./build-wiki.sh --force` from `module/`.
|
||||
- [ ] Inspect generated class HTML for `wiki-list-columns--3`, three configured class-skill lists for shorter classes, and overflow columns for long lists.
|
||||
Reference in New Issue
Block a user