Skip to content

Commit 8dfdac3

Browse files
committed
feat(skilldoc): subset fences — split one command group across cards
A fence id can now be group[verb-prefix,...]: a subset fence claiming every verb starting with one of the prefixes, while the bare-group fence renders the unclaimed remainder as the catch-all. gen/check discover fences by scanning the cards for markers instead of deriving one filename per group, so a large command group's reference card can be split along task lines. Topology violations (a verb claimed twice, a prefix claiming nothing, unclaimed verbs with no catch-all, a duplicated fence) fail gen and are reported by check as fence-topology issues.
1 parent 96eecd0 commit 8dfdac3

7 files changed

Lines changed: 559 additions & 62 deletions

File tree

‎internal/cmd/skilldoc/main.go‎

Lines changed: 75 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func main() {
3939
func genCmd() *cobra.Command {
4040
return &cobra.Command{
4141
Use: "gen [group]",
42-
Short: "Rewrite the generated fence in skills/flashduty/reference/<group>.md (every card if no group given)",
42+
Short: "Rewrite every GENERATED:<group> fence across the skills/flashduty cards (every group if none given)",
4343
Args: cobra.MaximumNArgs(1),
4444
RunE: func(_ *cobra.Command, args []string) error {
4545
base, err := cardBase()
@@ -81,40 +81,90 @@ func checkCmd() *cobra.Command {
8181
// dump builds the command-tree dump from the live CLI root, in-process.
8282
func dump() skilldoc.Dump { return skilldoc.Build(cli.RootForDump()) }
8383

84-
// runGen rewrites the GENERATED:<group> fence inside <base>/reference/<group>.md
85-
// with a fresh render, leaving all hand-written content outside the fence
86-
// untouched.
84+
// runGen regenerates every GENERATED fence of group across the cards under
85+
// <base>, leaving hand-written content outside the fences untouched. A group
86+
// may split its fences across cards (subset fences claiming verb prefixes,
87+
// plus the catch-all for the rest — see skilldoc.RenderGroupFences), so the
88+
// fresh render is computed for the group as a whole, then spliced per card.
8789
func runGen(d skilldoc.Dump, base, group string) error {
88-
card := filepath.Join(base, "reference", group+".md")
89-
raw, err := os.ReadFile(card)
90+
docs, err := loadDocs(base)
9091
if err != nil {
91-
return fmt.Errorf("read card: %w", err)
92+
return err
93+
}
94+
95+
var ids []string
96+
perDoc := map[string][]string{}
97+
var docOrder []string
98+
for _, doc := range docs {
99+
for _, fl := range skilldoc.FenceLocs(doc.Body) {
100+
spec, err := skilldoc.ParseFenceID(fl.ID)
101+
if err != nil {
102+
return fmt.Errorf("%s: %w", doc.Path, err)
103+
}
104+
if spec.Group != group {
105+
continue
106+
}
107+
if len(perDoc[doc.Path]) == 0 {
108+
docOrder = append(docOrder, doc.Path)
109+
}
110+
perDoc[doc.Path] = append(perDoc[doc.Path], fl.ID)
111+
ids = append(ids, fl.ID)
112+
}
113+
}
114+
if len(ids) == 0 {
115+
return fmt.Errorf("no GENERATED:%s fence found under %s (add the start/end markers first)", group, base)
92116
}
93-
body := normalizeEOL(string(raw))
94117

95-
start, end := skilldoc.FenceStart(group), skilldoc.FenceEnd(group)
96-
si := strings.Index(body, start)
97-
ei := strings.Index(body, end)
98-
if si < 0 || ei < 0 || ei < si {
99-
return fmt.Errorf("%s: no GENERATED:%s fence to fill (add the start/end markers first)", card, group)
118+
rendered, violations := skilldoc.RenderGroupFences(d, group, ids)
119+
if len(violations) > 0 {
120+
return fmt.Errorf("group %s fence topology: %s", group, strings.Join(violations, "; "))
100121
}
101122

102-
fresh := skilldoc.GenerateFence(d, group)
103-
updated := body[:si] + fresh + body[ei+len(end):]
104-
if updated == body {
105-
return nil // already fresh
123+
byPath := map[string]skilldoc.Doc{}
124+
for _, doc := range docs {
125+
byPath[doc.Path] = doc
106126
}
107-
if err := os.WriteFile(card, []byte(updated), 0o644); err != nil {
108-
return fmt.Errorf("write card: %w", err)
127+
for _, p := range docOrder {
128+
doc := byPath[p]
129+
body := doc.Body
130+
for _, id := range perDoc[p] {
131+
start, end := skilldoc.FenceStart(id), skilldoc.FenceEnd(id)
132+
si := strings.Index(body, start)
133+
ei := strings.Index(body[si:], end)
134+
if si < 0 || ei < 0 {
135+
return fmt.Errorf("%s: unterminated GENERATED:%s fence", p, id)
136+
}
137+
body = body[:si] + rendered[id] + body[si+ei+len(end):]
138+
}
139+
if body == doc.Body {
140+
continue // already fresh
141+
}
142+
if err := os.WriteFile(filepath.Join(base, p), []byte(body), 0o644); err != nil {
143+
return fmt.Errorf("write card: %w", err)
144+
}
109145
}
110146
return nil
111147
}
112148

113-
// runGenAll regenerates the fence of every dump group that has a card file under
114-
// <base>/reference. The group set is derived from the dump (intersected with the
115-
// cards that actually exist), so it stays correct as domains are added or
116-
// renamed — no hardcoded list. Groups without a card (e.g. webhook) are skipped.
149+
// runGenAll regenerates the fences of every dump group that has at least one
150+
// GENERATED marker in a card under <base>. The group set is derived from the
151+
// dump (intersected with the fences that actually exist), so it stays correct
152+
// as domains are added or renamed — no hardcoded list. Groups without any
153+
// fence (e.g. webhook) are skipped.
117154
func runGenAll(d skilldoc.Dump, base string) error {
155+
docs, err := loadDocs(base)
156+
if err != nil {
157+
return err
158+
}
159+
withFence := map[string]bool{}
160+
for _, doc := range docs {
161+
for _, fl := range skilldoc.FenceLocs(doc.Body) {
162+
if spec, err := skilldoc.ParseFenceID(fl.ID); err == nil {
163+
withFence[spec.Group] = true
164+
}
165+
}
166+
}
167+
118168
seen := map[string]bool{}
119169
var groups []string
120170
for _, c := range d.Commands {
@@ -125,8 +175,8 @@ func runGenAll(d skilldoc.Dump, base string) error {
125175
}
126176
sort.Strings(groups)
127177
for _, g := range groups {
128-
if _, err := os.Stat(filepath.Join(base, "reference", g+".md")); err != nil {
129-
continue // no card for this group
178+
if !withFence[g] {
179+
continue
130180
}
131181
if err := runGen(d, base, g); err != nil {
132182
return fmt.Errorf("gen %s: %w", g, err)

‎internal/cmd/skilldoc/main_test.go‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,65 @@ func TestRunGen_FillsFence(t *testing.T) {
337337
t.Errorf("gen clobbered hand-written content:\n%s", updated)
338338
}
339339
}
340+
341+
// TestRunGen_SplitAcrossCards is the split-card path: one group whose subset
342+
// fence and catch-all fence live in different files. gen must fill both from
343+
// one group-wide render, and check must then be clean.
344+
func TestRunGen_SplitAcrossCards(t *testing.T) {
345+
dir := t.TempDir()
346+
mk := func(verb string) skilldoc.Command {
347+
return skilldoc.Command{Path: "svc " + verb, Group: "svc", Short: "S " + verb, Use: verb}
348+
}
349+
d := skilldoc.Dump{Commands: []skilldoc.Command{mk("list"), mk("rule-create"), mk("rule-delete")}}
350+
351+
rules := filepath.Join(dir, "reference", "rules.md")
352+
svc := filepath.Join(dir, "reference", "svc.md")
353+
writeFile(t, rules, "# rules\n\n"+skilldoc.FenceStart("svc[rule-]")+"\n"+skilldoc.FenceEnd("svc[rule-]")+"\n")
354+
writeFile(t, svc, "# svc\n\nintro\n\n"+skilldoc.FenceStart("svc")+"\n"+skilldoc.FenceEnd("svc")+"\n")
355+
356+
if err := runGen(d, dir, "svc"); err != nil {
357+
t.Fatalf("runGen: %v", err)
358+
}
359+
360+
rulesBody, err := os.ReadFile(rules)
361+
if err != nil {
362+
t.Fatal(err)
363+
}
364+
svcBody, err := os.ReadFile(svc)
365+
if err != nil {
366+
t.Fatal(err)
367+
}
368+
if !strings.Contains(string(rulesBody), "### rule-create") || strings.Contains(string(rulesBody), "### list") {
369+
t.Errorf("rules card should carry exactly the claimed verbs:\n%s", rulesBody)
370+
}
371+
if !strings.Contains(string(svcBody), "### list") || strings.Contains(string(svcBody), "### rule-create") {
372+
t.Errorf("svc card should carry exactly the unclaimed remainder:\n%s", svcBody)
373+
}
374+
if !strings.Contains(string(svcBody), "intro") {
375+
t.Errorf("gen clobbered hand-written content:\n%s", svcBody)
376+
}
377+
378+
var out bytes.Buffer
379+
if n, _ := runCheck(d, dir, &out); n != 0 {
380+
t.Errorf("after gen, check should be clean; got %d:\n%s", n, out.String())
381+
}
382+
}
383+
384+
// TestRunGen_TopologyViolationFails asserts gen refuses to write anything when
385+
// the group's fences do not partition its verbs.
386+
func TestRunGen_TopologyViolationFails(t *testing.T) {
387+
dir := t.TempDir()
388+
mk := func(verb string) skilldoc.Command {
389+
return skilldoc.Command{Path: "svc " + verb, Group: "svc", Short: "S " + verb, Use: verb}
390+
}
391+
d := skilldoc.Dump{Commands: []skilldoc.Command{mk("list"), mk("rule-create")}}
392+
393+
// Subset fence only — "list" has no home.
394+
writeFile(t, filepath.Join(dir, "reference", "rules.md"),
395+
"# rules\n\n"+skilldoc.FenceStart("svc[rule-]")+"\n"+skilldoc.FenceEnd("svc[rule-]")+"\n")
396+
397+
err := runGen(d, dir, "svc")
398+
if err == nil || !strings.Contains(err.Error(), "no catch-all") {
399+
t.Fatalf("want topology error mentioning the missing catch-all, got %v", err)
400+
}
401+
}

‎internal/skilldoc/fence.go‎

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package skilldoc
2+
3+
// Fence topology: which GENERATED fence carries which commands of a group.
4+
//
5+
// A fence id is either the bare group name ("channel") — the group's
6+
// catch-all fence — or the group plus a bracketed verb-prefix claim list
7+
// ("channel[silence-rule,inhibit-rule]") — a subset fence that claims every
8+
// verb starting with one of the prefixes. A group's fences may live in
9+
// different cards; together they must cover the group exactly: every verb
10+
// lands in exactly one fence, each prefix claims at least one verb, and any
11+
// unclaimed remainder requires the catch-all fence to exist.
12+
13+
import (
14+
"fmt"
15+
"regexp"
16+
"sort"
17+
"strings"
18+
)
19+
20+
// FenceSpec is one parsed fence id.
21+
type FenceSpec struct {
22+
Group string
23+
Prefixes []string // empty → the group's catch-all fence
24+
}
25+
26+
// ID renders the spec back to its marker id ("group" or "group[p1,p2]").
27+
func (s FenceSpec) ID() string {
28+
if len(s.Prefixes) == 0 {
29+
return s.Group
30+
}
31+
return s.Group + "[" + strings.Join(s.Prefixes, ",") + "]"
32+
}
33+
34+
// fenceIDRe accepts "group" or "group[prefix,prefix,...]". Group and prefix
35+
// share the verb charset; no spaces, so a malformed claim list fails loudly
36+
// instead of silently truncating at the first space.
37+
var fenceIDRe = regexp.MustCompile(`^([a-z0-9-]+)(?:\[([a-z0-9-]+(?:,[a-z0-9-]+)*)\])?$`)
38+
39+
// ParseFenceID parses a fence id as found in a GENERATED marker.
40+
func ParseFenceID(id string) (FenceSpec, error) {
41+
m := fenceIDRe.FindStringSubmatch(id)
42+
if m == nil {
43+
return FenceSpec{}, fmt.Errorf("malformed fence id %q (want group or group[verb-prefix,…])", id)
44+
}
45+
spec := FenceSpec{Group: m[1]}
46+
if m[2] != "" {
47+
spec.Prefixes = strings.Split(m[2], ",")
48+
}
49+
return spec, nil
50+
}
51+
52+
// FenceLoc is one GENERATED start marker found in a doc body.
53+
type FenceLoc struct {
54+
ID string
55+
Offset int // byte offset of the start marker
56+
}
57+
58+
// fenceStartRe matches a start marker and captures its fence id; the literal
59+
// " START " cannot appear in an end marker, so ends never match.
60+
var fenceStartRe = regexp.MustCompile(`<!-- GENERATED:([^ ]+) START `)
61+
62+
// FenceLocs returns every GENERATED start marker in body, in document order.
63+
func FenceLocs(body string) []FenceLoc {
64+
var locs []FenceLoc
65+
for _, m := range fenceStartRe.FindAllStringSubmatchIndex(body, -1) {
66+
locs = append(locs, FenceLoc{ID: body[m[2]:m[3]], Offset: m[0]})
67+
}
68+
return locs
69+
}
70+
71+
// RenderGroupFences renders the fenced block for every fence of one command
72+
// group. ids must be the complete set of fence ids that exist for the group
73+
// across all cards — the catch-all fence renders whatever its sibling subset
74+
// fences leave unclaimed, so a fence cannot be rendered in isolation.
75+
// Topology problems (a verb claimed twice, a prefix claiming nothing, verbs
76+
// left over with no catch-all, a duplicated id) come back as violations;
77+
// rendered blocks are still returned for the fences that parsed.
78+
func RenderGroupFences(d Dump, group string, ids []string) (map[string]string, []string) {
79+
var violations []string
80+
var specs []FenceSpec
81+
seen := map[string]bool{}
82+
for _, id := range ids {
83+
spec, err := ParseFenceID(id)
84+
if err != nil {
85+
violations = append(violations, err.Error())
86+
continue
87+
}
88+
if spec.Group != group {
89+
violations = append(violations, fmt.Sprintf("fence %q does not belong to group %q", id, group))
90+
continue
91+
}
92+
if seen[spec.ID()] {
93+
violations = append(violations, fmt.Sprintf("fence %q appears more than once", id))
94+
continue
95+
}
96+
seen[spec.ID()] = true
97+
specs = append(specs, spec)
98+
}
99+
100+
byID := make(map[string][]Command, len(specs))
101+
catchAll := ""
102+
hasCatchAll := false
103+
for _, s := range specs {
104+
byID[s.ID()] = nil
105+
if len(s.Prefixes) == 0 {
106+
catchAll = s.ID()
107+
hasCatchAll = true
108+
}
109+
}
110+
111+
// Partition the group's verbs among the specs. A verb matching several
112+
// prefixes of ONE spec is fine (all count as live); matching prefixes of
113+
// two specs is a double claim.
114+
prefixHit := map[string]bool{} // spec id + "\x00" + prefix → claimed something
115+
var unclaimed []string
116+
for _, c := range groupCommands(d, group) {
117+
verb := verbOf(c.Path)
118+
var owners []string
119+
for _, s := range specs {
120+
matched := false
121+
for _, p := range s.Prefixes {
122+
if strings.HasPrefix(verb, p) {
123+
prefixHit[s.ID()+"\x00"+p] = true
124+
matched = true
125+
}
126+
}
127+
if matched {
128+
owners = append(owners, s.ID())
129+
}
130+
}
131+
switch {
132+
case len(owners) > 1:
133+
violations = append(violations, fmt.Sprintf("verb %q claimed by %s", verb, strings.Join(owners, " and ")))
134+
case len(owners) == 1:
135+
byID[owners[0]] = append(byID[owners[0]], c)
136+
case hasCatchAll:
137+
byID[catchAll] = append(byID[catchAll], c)
138+
default:
139+
unclaimed = append(unclaimed, verb)
140+
}
141+
}
142+
if len(unclaimed) > 0 {
143+
violations = append(violations, fmt.Sprintf("group %q has no catch-all fence for unclaimed verbs: %s", group, strings.Join(unclaimed, ", ")))
144+
}
145+
for _, s := range specs {
146+
for _, p := range s.Prefixes {
147+
if !prefixHit[s.ID()+"\x00"+p] {
148+
violations = append(violations, fmt.Sprintf("fence %q: prefix %q claims no verb", s.ID(), p))
149+
}
150+
}
151+
}
152+
153+
out := make(map[string]string, len(specs))
154+
for _, s := range specs {
155+
out[s.ID()] = renderFence(s.ID(), byID[s.ID()])
156+
}
157+
sort.Strings(violations)
158+
return out, violations
159+
}

0 commit comments

Comments
 (0)