Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 77 additions & 29 deletions internal/cmd/skilldoc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func main() {
func genCmd() *cobra.Command {
return &cobra.Command{
Use: "gen [group]",
Short: "Rewrite the generated fence in skills/flashduty/reference/<group>.md (every card if no group given)",
Short: "Rewrite every GENERATED:<group> fence across the skills/flashduty cards (every group if none given)",
Args: cobra.MaximumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
base, err := cardBase()
Expand Down Expand Up @@ -81,40 +81,91 @@ func checkCmd() *cobra.Command {
// dump builds the command-tree dump from the live CLI root, in-process.
func dump() skilldoc.Dump { return skilldoc.Build(cli.RootForDump()) }

// runGen rewrites the GENERATED:<group> fence inside <base>/reference/<group>.md
// with a fresh render, leaving all hand-written content outside the fence
// untouched.
func runGen(d skilldoc.Dump, base, group string) error {
card := filepath.Join(base, "reference", group+".md")
raw, err := os.ReadFile(card)
if err != nil {
return fmt.Errorf("read card: %w", err)
// genGroup regenerates every GENERATED fence of group across the already-
// loaded docs, leaving hand-written content outside the fences untouched. A
// group may split its fences across cards (subset fences claiming verb
// prefixes, plus the catch-all for the rest — see skilldoc.RenderGroupFences),
// so the fresh render is computed for the group as a whole, then spliced per
// card. Rewritten bodies are written to disk AND updated in docs, so a caller
// looping over groups keeps seeing current content. found is false when no
// fence of the group exists anywhere.
func genGroup(d skilldoc.Dump, base string, docs []skilldoc.Doc, group string) (found bool, err error) {
var ids []string
perDoc := map[string][]string{}
for _, doc := range docs {
for _, fl := range skilldoc.FenceLocs(doc.Body) {
spec, err := skilldoc.ParseFenceID(fl.ID)
if err != nil {
return false, fmt.Errorf("%s: %w", doc.Path, err)
}
if spec.Group != group {
continue
}
perDoc[doc.Path] = append(perDoc[doc.Path], fl.ID)
ids = append(ids, fl.ID)
}
}
if len(ids) == 0 {
return false, nil
}
body := normalizeEOL(string(raw))

start, end := skilldoc.FenceStart(group), skilldoc.FenceEnd(group)
si := strings.Index(body, start)
ei := strings.Index(body, end)
if si < 0 || ei < 0 || ei < si {
return fmt.Errorf("%s: no GENERATED:%s fence to fill (add the start/end markers first)", card, group)
rendered, violations := skilldoc.RenderGroupFences(d, group, ids)
if len(violations) > 0 {
return true, fmt.Errorf("group %s fence topology: %s", group, strings.Join(violations, "; "))
}

fresh := skilldoc.GenerateFence(d, group)
updated := body[:si] + fresh + body[ei+len(end):]
if updated == body {
return nil // already fresh
for i, doc := range docs {
docIDs := perDoc[doc.Path]
if len(docIDs) == 0 {
continue
}
body := doc.Body
for _, id := range docIDs {
start, end, ok := skilldoc.FindFence(body, id)
if !ok {
return true, fmt.Errorf("%s: unterminated GENERATED:%s fence", doc.Path, id)
}
body = body[:start] + rendered[id] + body[end:]
}
if body == doc.Body {
continue // already fresh
}
if err := os.WriteFile(filepath.Join(base, doc.Path), []byte(body), 0o644); err != nil {
return true, fmt.Errorf("write card: %w", err)
}
docs[i].Body = body
}
if err := os.WriteFile(card, []byte(updated), 0o644); err != nil {
return fmt.Errorf("write card: %w", err)
return true, nil
}

// runGen regenerates one group's fences.
func runGen(d skilldoc.Dump, base, group string) error {
docs, err := loadDocs(base)
if err != nil {
return err
}
found, err := genGroup(d, base, docs, group)
if err != nil {
return err
}
if !found {
return fmt.Errorf("no GENERATED:%s fence found under %s (add the start/end markers first)", group, base)
}
return nil
}

// runGenAll regenerates the fence of every dump group that has a card file under
// <base>/reference. The group set is derived from the dump (intersected with the
// cards that actually exist), so it stays correct as domains are added or
// renamed — no hardcoded list. Groups without a card (e.g. webhook) are skipped.
// runGenAll regenerates the fences of every dump group that has at least one
// GENERATED marker in a card under <base>. The group set is derived from the
// dump (intersected with the fences that actually exist, which genGroup
// reports via found), so it stays correct as domains are added or renamed —
// no hardcoded list. Groups without any fence (e.g. webhook) are skipped.
// The corpus is loaded once and threaded through every group.
func runGenAll(d skilldoc.Dump, base string) error {
docs, err := loadDocs(base)
if err != nil {
return err
}

seen := map[string]bool{}
var groups []string
for _, c := range d.Commands {
Expand All @@ -125,10 +176,7 @@ func runGenAll(d skilldoc.Dump, base string) error {
}
sort.Strings(groups)
for _, g := range groups {
if _, err := os.Stat(filepath.Join(base, "reference", g+".md")); err != nil {
continue // no card for this group
}
if err := runGen(d, base, g); err != nil {
if _, err := genGroup(d, base, docs, g); err != nil {
return fmt.Errorf("gen %s: %w", g, err)
}
}
Expand Down
102 changes: 102 additions & 0 deletions internal/cmd/skilldoc/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,3 +337,105 @@ func TestRunGen_FillsFence(t *testing.T) {
t.Errorf("gen clobbered hand-written content:\n%s", updated)
}
}

// TestRunGen_SplitAcrossCards is the split-card path: one group whose subset
// fence and catch-all fence live in different files. gen must fill both from
// one group-wide render, and check must then be clean.
func TestRunGen_SplitAcrossCards(t *testing.T) {
dir := t.TempDir()
mk := func(verb string) skilldoc.Command {
return skilldoc.Command{Path: "svc " + verb, Group: "svc", Short: "S " + verb, Use: verb}
}
d := skilldoc.Dump{Commands: []skilldoc.Command{mk("list"), mk("rule-create"), mk("rule-delete")}}

rules := filepath.Join(dir, "reference", "rules.md")
svc := filepath.Join(dir, "reference", "svc.md")
writeFile(t, rules, "# rules\n\n"+skilldoc.FenceStart("svc[rule]")+"\n"+skilldoc.FenceEnd("svc[rule]")+"\n")
writeFile(t, svc, "# svc\n\nintro\n\n"+skilldoc.FenceStart("svc")+"\n"+skilldoc.FenceEnd("svc")+"\n")

if err := runGen(d, dir, "svc"); err != nil {
t.Fatalf("runGen: %v", err)
}

rulesBody, err := os.ReadFile(rules)
if err != nil {
t.Fatal(err)
}
svcBody, err := os.ReadFile(svc)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(rulesBody), "### rule-create") || strings.Contains(string(rulesBody), "### list") {
t.Errorf("rules card should carry exactly the claimed verbs:\n%s", rulesBody)
}
if !strings.Contains(string(svcBody), "### list") || strings.Contains(string(svcBody), "### rule-create") {
t.Errorf("svc card should carry exactly the unclaimed remainder:\n%s", svcBody)
}
if !strings.Contains(string(svcBody), "intro") {
t.Errorf("gen clobbered hand-written content:\n%s", svcBody)
}

var out bytes.Buffer
if n, _ := runCheck(d, dir, &out); n != 0 {
t.Errorf("after gen, check should be clean; got %d:\n%s", n, out.String())
}
}

// TestRunGen_TopologyViolationFails asserts gen refuses to write anything when
// the group's fences do not partition its verbs.
func TestRunGen_TopologyViolationFails(t *testing.T) {
dir := t.TempDir()
mk := func(verb string) skilldoc.Command {
return skilldoc.Command{Path: "svc " + verb, Group: "svc", Short: "S " + verb, Use: verb}
}
d := skilldoc.Dump{Commands: []skilldoc.Command{mk("list"), mk("rule-create")}}

// Subset fence only — "list" has no home.
writeFile(t, filepath.Join(dir, "reference", "rules.md"),
"# rules\n\n"+skilldoc.FenceStart("svc[rule]")+"\n"+skilldoc.FenceEnd("svc[rule]")+"\n")

err := runGen(d, dir, "svc")
if err == nil || !strings.Contains(err.Error(), "no catch-all") {
t.Fatalf("want topology error mentioning the missing catch-all, got %v", err)
}
}

// TestRunGen_TwoFencesInOneFile pins the sequential splice loop: a single card
// carrying both a subset fence and the catch-all fence of the same group must
// have both rewritten in one pass (offsets are re-resolved by marker text
// after each splice, so the first replacement must not derail the second).
func TestRunGen_TwoFencesInOneFile(t *testing.T) {
dir := t.TempDir()
mk := func(verb string) skilldoc.Command {
return skilldoc.Command{Path: "svc " + verb, Group: "svc", Short: "S " + verb, Use: verb}
}
d := skilldoc.Dump{Commands: []skilldoc.Command{mk("list"), mk("rule-create"), mk("rule-delete")}}

card := filepath.Join(dir, "reference", "svc.md")
writeFile(t, card, "# svc\n\nrules first\n\n"+
skilldoc.FenceStart("svc[rule]")+"\n"+skilldoc.FenceEnd("svc[rule]")+"\n\nthen the rest\n\n"+
skilldoc.FenceStart("svc")+"\n"+skilldoc.FenceEnd("svc")+"\n")

if err := runGen(d, dir, "svc"); err != nil {
t.Fatalf("runGen: %v", err)
}

body, err := os.ReadFile(card)
if err != nil {
t.Fatal(err)
}
got := string(body)
ruleAt := strings.Index(got, "### rule-create")
listAt := strings.Index(got, "### list")
if ruleAt < 0 || listAt < 0 || ruleAt > listAt {
t.Fatalf("both fences must be filled, subset before catch-all:\n%s", got)
}
if !strings.Contains(got, "rules first") || !strings.Contains(got, "then the rest") {
t.Errorf("gen clobbered hand-written content between fences:\n%s", got)
}

var out bytes.Buffer
if n, _ := runCheck(d, dir, &out); n != 0 {
t.Errorf("after gen, check should be clean; got %d:\n%s", n, out.String())
}
}
Loading