Skip to content

Commit 09607ff

Browse files
authored
Merge pull request #114 from flashcatcloud/fix/card-response-shapes
Fix/card response shapes
2 parents 6ce330c + e5f021f commit 09607ff

27 files changed

Lines changed: 787 additions & 21 deletions

internal/cmd/skilldoc/main_test.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,153 @@ func TestRunGenAll_FillsEveryCardAndSkipsCardless(t *testing.T) {
158158
}
159159
}
160160

161+
// independentlyClassifyResponse re-derives a command's response envelope
162+
// shape ("object" | "array" | "wrapped") and its top-level (or, for the
163+
// wrapped shape, per-row) field names straight from its raw Long text, using
164+
// logic deliberately NOT shared with skilldoc's own responseShapeLine
165+
// extractor (internal/skilldoc/generate.go) — a from-scratch re-read of the
166+
// same ground truth, not a call into the code under test. ok is false when
167+
// Long documents no Response fields block, or the block yields zero fields
168+
// at the target depth (skilldoc's own extractor also emits nothing for that
169+
// case — nothing to cross-check).
170+
func independentlyClassifyResponse(long string) (shape string, fields []string, ok bool) {
171+
lines := strings.Split(long, "\n")
172+
headerLine := -1
173+
for i, l := range lines {
174+
if strings.HasPrefix(l, "Response fields (") {
175+
headerLine = i
176+
break
177+
}
178+
}
179+
if headerLine < 0 {
180+
return "", nil, false
181+
}
182+
183+
header := lines[headerLine]
184+
switch {
185+
case strings.Contains(header, "nested under items[]"):
186+
shape = "wrapped"
187+
case strings.Contains(header, "TOP-LEVEL array"):
188+
shape = "array"
189+
default:
190+
shape = "object"
191+
}
192+
prefix := " - "
193+
if shape == "wrapped" {
194+
prefix = " - " // one level under the sole top-level "items" row
195+
}
196+
for _, l := range lines[headerLine+1:] {
197+
if strings.TrimSpace(l) == "" {
198+
break
199+
}
200+
if !strings.HasPrefix(l, prefix) {
201+
continue
202+
}
203+
name := strings.TrimPrefix(l, prefix)
204+
if sp := strings.IndexAny(name, " ("); sp >= 0 {
205+
name = name[:sp]
206+
}
207+
fields = append(fields, name)
208+
}
209+
return shape, fields, len(fields) > 0
210+
}
211+
212+
// isCligenWrapperWireName mirrors (independently — not by import) the three
213+
// wire names cligen's own listEnvelope (internal/cmd/cligen/main.go) treats
214+
// as a paginated-list envelope field.
215+
func isCligenWrapperWireName(name string) bool {
216+
return name == "items" || name == "docs" || name == "list"
217+
}
218+
219+
// responseLineOf returns the "- response: ..." bullet inside a rendered fence
220+
// section, and whether one was present.
221+
func responseLineOf(section string) (string, bool) {
222+
for _, l := range strings.Split(section, "\n") {
223+
if strings.HasPrefix(strings.TrimSpace(l), "- response: ") {
224+
return l, true
225+
}
226+
}
227+
return "", false
228+
}
229+
230+
// TestGenerateFence_ResponseShapeMatchesRealLong_AllCommands is the
231+
// coverage-complete ground-truth cross-check: for every real command whose
232+
// live Long (built from the actual CLI tree, not a fixture) documents a
233+
// Response fields block, independently reclassify its envelope shape and
234+
// field names (independentlyClassifyResponse, above — separate logic from
235+
// the generator) and assert the fence GenerateFence renders for that verb
236+
// agrees. A single hand-picked example (`schedule list`) proved the
237+
// mechanism works but only ever covered one of ~200 documented commands;
238+
// this walks the whole real dump, so a classification drift ANYWHERE in the
239+
// generator fails the build, not just for the one verb someone happened to
240+
// write a test against.
241+
func TestGenerateFence_ResponseShapeMatchesRealLong_AllCommands(t *testing.T) {
242+
d := dump()
243+
244+
checked := 0
245+
for _, c := range d.Commands {
246+
wantShape, wantFields, hasBlock := independentlyClassifyResponse(c.Long)
247+
if !hasBlock {
248+
continue
249+
}
250+
checked++
251+
252+
// Render this ONE command in isolation (a single-command dump filtered
253+
// to its own group) rather than slicing a section out of the whole
254+
// group's fence: several real groups (e.g. "incident") flatten
255+
// same-named leaves from different subgroups — "incident get" and
256+
// "incident war-room get" both render as a "### get" heading — so a
257+
// substring/heading search across the full group fence can grab the
258+
// wrong command's section. A single-command fence has exactly one
259+
// response line, unambiguously.
260+
fence := skilldoc.GenerateFence(skilldoc.Dump{Commands: []skilldoc.Command{c}}, c.Group)
261+
gotLine, hasLine := responseLineOf(fence)
262+
263+
// Mirrors the wrapper-drift guard in responseShapeLine: a response
264+
// this classifier reads as a top-level object whose sole field is one
265+
// of cligen's own list-envelope wire names (items/docs/list, array
266+
// type) is a case the generator deliberately suppresses rather than
267+
// assert a possibly-wrong shape. No real command hits this today
268+
// (cligen's own header would already say "wrapped" for it), but nothing
269+
// here should hard-fail if drift ever makes one — that is the guard
270+
// working as designed, not a bug.
271+
if wantShape == "object" && len(wantFields) == 1 && isCligenWrapperWireName(wantFields[0]) {
272+
if hasLine {
273+
t.Errorf("%s: expected the wrapper-drift guard to suppress this line (sole field %q looks like a list envelope), got:\n%s", c.Path, wantFields[0], gotLine)
274+
}
275+
continue
276+
}
277+
278+
if !hasLine {
279+
t.Errorf("%s: generated fence has no response line for a documented Response fields block", c.Path)
280+
continue
281+
}
282+
switch wantShape {
283+
case "wrapped":
284+
if !strings.Contains(gotLine, "page wrapper") || !strings.Contains(gotLine, "jq '.items[]'") {
285+
t.Errorf("%s: want items[] page wrapper, got:\n%s", c.Path, gotLine)
286+
}
287+
case "array":
288+
if !strings.Contains(gotLine, "TOP-LEVEL array") {
289+
t.Errorf("%s: want TOP-LEVEL array, got:\n%s", c.Path, gotLine)
290+
}
291+
default: // "object"
292+
if !strings.Contains(gotLine, "single object") {
293+
t.Errorf("%s: want single object, got:\n%s", c.Path, gotLine)
294+
}
295+
}
296+
for _, f := range wantFields {
297+
if !strings.Contains(gotLine, f+" (") {
298+
t.Errorf("%s: missing real field %q (from live Long) in generated line:\n%s", c.Path, f, gotLine)
299+
}
300+
}
301+
}
302+
if checked < 100 {
303+
t.Fatalf("only cross-checked %d commands — expected on the order of 200; did Response-fields detection break, or has the real CLI shrunk?", checked)
304+
}
305+
t.Logf("cross-checked response shape/fields for %d real commands", checked)
306+
}
307+
161308
func TestRunGen_FillsFence(t *testing.T) {
162309
dir := t.TempDir()
163310
d := fixtureDump()

internal/skilldoc/generate.go

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ const (
1717
// GenerateFence renders the factual fenced block for one command group: a
1818
// section per leaf verb with its short description and a flag table (name,
1919
// type, required, usage + enum), plus a body-only (--data) note when the
20-
// command has nested JSON-only fields. Required-ness and enums are sourced from
21-
// the authoritative "Request fields:" text in each command's Long; the flag
22-
// list falls back to the dump's Flags when no such block exists (read-only
20+
// command has nested JSON-only fields, plus a one-line response-shape summary
21+
// (top-level object vs. bare array vs. `{items: [...]}` page wrapper, and the
22+
// field names at that level) when the command documents one. Required-ness
23+
// and enums are sourced from the authoritative "Request fields:" text in each
24+
// command's Long; the response shape is likewise sourced from that same
25+
// Long's "Response fields (...):" block (cligen's own ground truth — see
26+
// responseShapeLine), not re-derived or hand-curated. The flag list falls
27+
// back to the dump's Flags when no Request-fields block exists (read-only
2328
// verbs). Output is deterministic.
2429
func GenerateFence(d Dump, group string) string {
2530
cmds := groupCommands(d, group)
@@ -84,6 +89,9 @@ func writeCommand(b *strings.Builder, c Command) {
8489
if len(fields.bodyOnly) > 0 {
8590
fmt.Fprintf(b, "- body-only (`--data`): %s\n", strings.Join(fields.bodyOnly, "; "))
8691
}
92+
if shape := responseShapeLine(c.Long); shape != "" {
93+
b.WriteString(shape)
94+
}
8795
}
8896

8997
// positionalsOf returns the placeholder tokens after the leaf verb in a Use
@@ -295,3 +303,119 @@ func cleanUsage(tail string) string {
295303
s = strings.TrimPrefix(s, "—")
296304
return strings.TrimSpace(s)
297305
}
306+
307+
// --- Long "Response fields:" parser -----------------------------------------
308+
//
309+
// cligen classifies every documented response into exactly one of three
310+
// envelope shapes and says so verbatim in the "Response fields (...):" header
311+
// it writes into Long — this parser only ever recognizes those three; it does
312+
// not infer a shape of its own. That header (and the field list under it) is
313+
// authoritative today but surfaces only via `--help`, which an agent that
314+
// reads just the card fence never invokes. responseShapeLine folds a
315+
// one-line summary of it into the fence so every generated verb — not only
316+
// the handful some earlier hand-written card happened to cover — tells the
317+
// agent up front whether `--json` is a bare array, a single object, or a
318+
// `{items: [...]}` page wrapper, and exactly which field names exist at that
319+
// level. Guessing a field name silently returns null instead of an error, so
320+
// this is the difference between an agent noticing its own mistake and not.
321+
322+
// responseHeaderRe matches the header line, capturing the parenthetical shape
323+
// description cligen wrote (verbatim, no leading indent — it starts a new
324+
// paragraph in Long).
325+
var responseHeaderRe = regexp.MustCompile(`^Response fields \((.*)\):$`)
326+
327+
// responseFieldRe matches one Response-fields bullet row at any indent depth,
328+
// e.g. " - account_id (integer) (required) — ..." or, one level deeper,
329+
// " - person_ids (array<integer>) ...". Capture groups: indent, name, type.
330+
var responseFieldRe = regexp.MustCompile(`^( *)- ([a-zA-Z0-9_]+) \(([^)]*)\)`)
331+
332+
// wrapperWireNames are the exact wire names cligen's own listEnvelope
333+
// (internal/cmd/cligen/main.go) treats as a paginated-list envelope field:
334+
// a sole array-typed sibling named items, docs, or list. Mirrored here as a
335+
// sanity check, not a duplicate classifier — see the guard in
336+
// responseShapeLine below.
337+
var wrapperWireNames = map[string]bool{"items": true, "docs": true, "list": true}
338+
339+
// respField is one parsed Response-fields bullet row.
340+
type respField struct{ name, typ string }
341+
342+
// responseShapeLine renders the one-line response-shape summary for a
343+
// command's Long, or "" when Long documents no Response fields block (mutation
344+
// verbs with an empty body, and a few hand-written commands that predate
345+
// cligen). The three shapes cligen's header can name:
346+
//
347+
// - top-level object: the block's own fields are the response.
348+
// - top-level array: `--json` is a bare array of these row objects — pipe
349+
// `jq '.[]'`, never `.items[]`.
350+
// - `{items: [...]}` page wrapper: the block's sole top-level field is
351+
// `items`; the row fields are nested one level (2 spaces) deeper under it.
352+
//
353+
// Field names (with their documented type) are read from whichever indent
354+
// depth holds the actual row/object fields for the detected shape, so the
355+
// summary always names the fields an agent would pipe `jq` at — not the
356+
// wrapper key.
357+
func responseShapeLine(long string) string {
358+
lines := strings.Split(long, "\n")
359+
headerIdx, header := -1, ""
360+
for i, line := range lines {
361+
if m := responseHeaderRe.FindStringSubmatch(line); m != nil {
362+
headerIdx, header = i, m[1]
363+
break
364+
}
365+
}
366+
if headerIdx < 0 {
367+
return ""
368+
}
369+
370+
wrapped := strings.Contains(header, "nested under items[]")
371+
fieldIndent := " "
372+
if wrapped {
373+
fieldIndent = " " // one level under the sole top-level "items" row
374+
}
375+
376+
var fields []respField
377+
for _, line := range lines[headerIdx+1:] {
378+
if strings.TrimSpace(line) == "" {
379+
break
380+
}
381+
m := responseFieldRe.FindStringSubmatch(line)
382+
if m == nil || m[1] != fieldIndent {
383+
continue
384+
}
385+
fields = append(fields, respField{m[2], m[3]})
386+
}
387+
if len(fields) == 0 {
388+
return ""
389+
}
390+
391+
// Safety net: this parser only ever recognizes the wrapped shape by the
392+
// literal substring "nested under items[]" in the header (see the doc
393+
// comment above the const block). If cligen's wording for that header
394+
// ever drifts without this parser being updated to match, `wrapped` goes
395+
// false here even though the response really is a page wrapper — and the
396+
// sole top-level field is then exactly one of cligen's own wrapper wire
397+
// names (items/docs/list, from listEnvelope in cligen/main.go), holding
398+
// an array. Asserting "single object" in that case would be confidently
399+
// WRONG about the one thing this whole feature exists to get right, so
400+
// refuse to guess: say nothing rather than assert a shape we can no
401+
// longer be sure of. A missing line is recoverable via `--help`; a
402+
// wrong one isn't.
403+
if !wrapped && len(fields) == 1 && wrapperWireNames[fields[0].name] && strings.HasPrefix(fields[0].typ, "array") {
404+
return ""
405+
}
406+
407+
var shape string
408+
switch {
409+
case wrapped:
410+
shape = "`{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`)"
411+
case strings.Contains(header, "TOP-LEVEL array"):
412+
shape = "TOP-LEVEL array — pipe `--json | jq '.[]'` (NOT `.items[]`)"
413+
default:
414+
shape = "single object (`data` unwrapped to the top level)"
415+
}
416+
names := make([]string, len(fields))
417+
for i, f := range fields {
418+
names[i] = f.name + " (" + f.typ + ")"
419+
}
420+
return fmt.Sprintf("- response: %s — fields: %s\n", shape, strings.Join(names, "; "))
421+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package skilldoc
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// TestSkillCardBindsEvidenceToScopeNotJustVerb locks in the generalized
11+
// evidence-binding rule: a claim about a specific time window or entity may
12+
// only be made if a tool call this turn actually covered that window or
13+
// entity, not merely the same verb. This patches the gap behind three
14+
// production failures — reporting per-day/WoW figures from a rolling-window
15+
// call, inventing a baseline window no call touched, and generalizing one
16+
// entity's config from its siblings' — all of which passed the older,
17+
// narrower "did you run the command at all" check.
18+
func TestSkillCardBindsEvidenceToScopeNotJustVerb(t *testing.T) {
19+
card, err := os.ReadFile(filepath.Join("..", "..", "skills", "flashduty", "SKILL.md"))
20+
if err != nil {
21+
t.Fatal(err)
22+
}
23+
body := string(card)
24+
25+
_, section, found := strings.Cut(body, "## Output — prefer `toon`")
26+
if !found {
27+
t.Fatal("SKILL.md is missing the Output section that carries the evidence-binding rule")
28+
}
29+
section, _, found = strings.Cut(section, "## Command names")
30+
if !found {
31+
t.Fatal("SKILL.md is missing the section after Output — prefer `toon`")
32+
}
33+
34+
// The rule must bind a claim to the scope actually queried (time window,
35+
// entity) — not just to having run the right verb at some point this
36+
// turn. A call for one window or entity must not license a claim about
37+
// a different one.
38+
if !strings.Contains(section, "scope") {
39+
t.Error("SKILL.md evidence-binding rule must talk about matching the queried scope, not just the verb")
40+
}
41+
if !strings.Contains(section, "window") || !strings.Contains(section, "entity") {
42+
t.Error("SKILL.md evidence-binding rule must name both axes it covers: time window and entity")
43+
}
44+
// It must forbid generalizing from adjacent evidence (a wider/different
45+
// window, a sibling entity) — the specific failure mode this rule exists
46+
// to stop, not just "don't invent from nothing".
47+
if !strings.Contains(section, "does not transfer") && !strings.Contains(section, "extrapolat") {
48+
t.Error("SKILL.md evidence-binding rule must forbid extrapolating a claim from a window or entity you queried differently")
49+
}
50+
// It must give a concrete fallback action, mirroring incident.md's
51+
// established phrasing, not just a prohibition with nothing to do
52+
// instead.
53+
if !strings.Contains(section, "未查询") || !strings.Contains(section, "<command>") {
54+
t.Error("SKILL.md evidence-binding rule must give a concrete fallback action (未查询 — 可运行 <command>), not just a prohibition")
55+
}
56+
}
57+
58+
// TestInsightCardTiesWindowComparisonsToSkillRule locks in the insight-card
59+
// instance of the same rule: day-over-day / week-over-week claims require a
60+
// single call spanning every window compared, with --aggregate-unit as the
61+
// concrete way to get one. This is the domain-specific reinforcement, not a
62+
// duplicate of the general SKILL.md rule.
63+
func TestInsightCardTiesWindowComparisonsToSkillRule(t *testing.T) {
64+
card, err := os.ReadFile(filepath.Join("..", "..", "skills", "flashduty", "reference", "insight.md"))
65+
if err != nil {
66+
t.Fatal(err)
67+
}
68+
body := string(card)
69+
70+
_, gotchas, found := strings.Cut(body, "## Gotchas")
71+
if !found {
72+
t.Fatal("insight card is missing the Gotchas section")
73+
}
74+
gotchas, _, found = strings.Cut(gotchas, "## Worked example")
75+
if !found {
76+
t.Fatal("insight card is missing the section after Gotchas")
77+
}
78+
79+
if !strings.Contains(gotchas, "window") {
80+
t.Error("insight Gotchas must warn that a window-over-window claim needs a call spanning every window compared")
81+
}
82+
if !strings.Contains(gotchas, "--aggregate-unit") {
83+
t.Error("insight Gotchas must point to --aggregate-unit as the concrete way to get real per-bucket figures in one call")
84+
}
85+
// It should point back to the general rule rather than re-deriving it —
86+
// SKILL.md and insight.md must not carry two competing versions of the
87+
// same rule.
88+
if !strings.Contains(gotchas, "SKILL.md") {
89+
t.Error("insight Gotchas should reference the general SKILL.md evidence-binding rule instead of restating it")
90+
}
91+
}

0 commit comments

Comments
 (0)