@@ -158,53 +158,16 @@ func TestRunGenAll_FillsEveryCardAndSkipsCardless(t *testing.T) {
158158 }
159159}
160160
161- // sectionFor returns the slice of a generated fence from "### <verb>" up to
162- // the next "### " heading (or end of string). Local copy of the helper
163- // skilldoc's own tests use — kept private to each package since exporting it
164- // would blur GenerateFence's real API surface just for test convenience.
165- func sectionFor (fence , verb string ) string {
166- start := strings .Index (fence , "### " + verb )
167- if start < 0 {
168- return ""
169- }
170- rest := fence [start + len ("### " + verb ):]
171- if next := strings .Index (rest , "\n ### " ); next >= 0 {
172- return fence [start : start + len ("### " + verb )+ next ]
173- }
174- return fence [start :]
175- }
176-
177- // TestGenerateFence_ScheduleList_ResponseShapeMatchesRealLong is the
178- // ground-truth cross-check the response-shape feature exists for. Before this
179- // change, skills/flashduty/reference/schedule.md carried zero envelope
180- // guidance for any of its verbs (unlike incident.md/change.md/automation.md/
181- // enrichment.md/monit.md, the only 5 hand-written cards that happened to
182- // note their envelope shape). `schedule list` in particular is real,
183- // commonly-invoked, and — per the actual live CLI tree, not a fixture —
184- // documents an `{items: [...]}` page wrapper, NOT a bare top-level array
185- // (that phrasing belongs to the deprecated `oncall schedule list` twin, which
186- // calls the same SDK method but is a different command path with no card).
187- //
188- // The expected shape/fields below are derived by independently re-scanning
189- // that real Long text with throwaway logic — not by calling skilldoc's own
190- // extractor and not by pasting a literal expected string — so this fails if
191- // the generator's extraction ever silently drifts from what cligen actually
192- // wrote for this command.
193- func TestGenerateFence_ScheduleList_ResponseShapeMatchesRealLong (t * testing.T ) {
194- d := dump ()
195-
196- var long string
197- found := false
198- for _ , c := range d .Commands {
199- if c .Path == "schedule list" {
200- long , found = c .Long , true
201- break
202- }
203- }
204- if ! found {
205- t .Fatal ("schedule list not found in the real CLI dump — has it been renamed?" )
206- }
207-
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 ) {
208171 lines := strings .Split (long , "\n " )
209172 headerLine := - 1
210173 for i , l := range lines {
@@ -214,44 +177,132 @@ func TestGenerateFence_ScheduleList_ResponseShapeMatchesRealLong(t *testing.T) {
214177 }
215178 }
216179 if headerLine < 0 {
217- t . Fatal ( "schedule list's real Long carries no Response fields block — has cligen's output changed?" )
180+ return "" , nil , false
218181 }
219- if ! strings .Contains (lines [headerLine ], "nested under items[]" ) {
220- t .Fatalf ("expected schedule list to be an items[]-wrapped page response; real header was:\n %s" , lines [headerLine ])
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
221195 }
222- var wantFields []string
223196 for _ , l := range lines [headerLine + 1 :] {
224197 if strings .TrimSpace (l ) == "" {
225198 break
226199 }
227- if strings .HasPrefix (l , " - " ) { // one level under the sole top-level "items" row
228- name := strings .TrimPrefix (l , " - " )
229- if sp := strings .IndexAny (name , " (" ); sp >= 0 {
230- name = name [:sp ]
231- }
232- wantFields = append (wantFields , name )
200+ if ! strings .HasPrefix (l , prefix ) {
201+ continue
233202 }
203+ name := strings .TrimPrefix (l , prefix )
204+ if sp := strings .IndexAny (name , " (" ); sp >= 0 {
205+ name = name [:sp ]
206+ }
207+ fields = append (fields , name )
234208 }
235- if len (wantFields ) == 0 {
236- t .Fatal ("independent scan of the real Long found no row fields under items — test logic is broken" )
237- }
209+ return shape , fields , len (fields ) > 0
210+ }
238211
239- fresh := skilldoc .GenerateFence (d , "schedule" )
240- listSection := sectionFor (fresh , "list" )
241- if listSection == "" {
242- t .Fatal ("generated schedule fence has no `list` section" )
243- }
244- if ! strings .Contains (listSection , "page wrapper" ) || ! strings .Contains (listSection , "jq '.items[]'" ) {
245- t .Errorf ("schedule list card section must document the items[] page wrapper, got:\n %s" , listSection )
246- }
247- if strings .Contains (listSection , "TOP-LEVEL array" ) {
248- t .Errorf ("schedule list is NOT a top-level array — must not carry that phrasing:\n %s" , listSection )
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+ }
249226 }
250- for _ , f := range wantFields {
251- if ! strings .Contains (listSection , f + " (" ) {
252- t .Errorf ("schedule list card section missing real row field %q (from live Long):\n %s" , f , listSection )
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+ }
253295 }
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 )
254304 }
305+ t .Logf ("cross-checked response shape/fields for %d real commands" , checked )
255306}
256307
257308func TestRunGen_FillsFence (t * testing.T ) {
0 commit comments