Skip to content

Commit c22c17a

Browse files
fix: snip TUI select-all/deselect bug; clean reachability and tag cleanup; docs (#65)
1 parent 1390319 commit c22c17a

File tree

10 files changed

+957
-89
lines changed

10 files changed

+957
-89
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ The CLI provides three main command groups:
9898
- **`openapi spec`** - Commands for working with OpenAPI specifications ([documentation](./cmd/openapi/commands/openapi/README.md))
9999
- `bootstrap` - Create a new OpenAPI document with best practice examples
100100
- `bundle` - Bundle external references into components section
101-
- `clean` - Remove unused components from an OpenAPI specification
101+
- `clean` - Remove unused components and unused top-level tags from an OpenAPI specification
102102
- `explore` - Interactively explore an OpenAPI specification in the terminal
103103
- `inline` - Inline all references in an OpenAPI specification
104104
- `join` - Join multiple OpenAPI documents into a single document

cmd/openapi/commands/openapi/README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ paths:
135135

136136
### `clean`
137137

138-
Remove unused components from an OpenAPI specification to create a cleaner, more maintainable document.
138+
Remove unused components and unused top‑level tags from an OpenAPI specification using reachability seeded from /paths and top‑level security.
139139

140140
```bash
141141
# Clean to stdout (pipe-friendly)
@@ -150,10 +150,12 @@ openapi spec clean -w ./spec.yaml
150150

151151
What cleaning does:
152152

153-
- Removes unused components from all component types (schemas, responses, parameters, etc.)
154-
- Tracks all references throughout the document including `$ref` and security scheme name references
155-
- Preserves all components that are actually used in the specification
156-
- Handles complex reference patterns including circular references and nested components
153+
- Performs reachability-based cleanup seeded only from API surface areas (/paths and top‑level security)
154+
- Expands through $ref links to components until a fixed point is reached
155+
- Preserves security schemes referenced by name in security requirement objects (global and operation‑level)
156+
- Removes unused components from all component types (schemas, responses, parameters, examples, request bodies, headers, links, callbacks, path items)
157+
- Removes unused top‑level tags that are not referenced by any operation
158+
- Handles complex reference patterns; only components reachable from the API surface are kept
157159

158160
**Before cleaning:**
159161

@@ -823,13 +825,15 @@ openapi spec snip -w --operation /internal/debug:GET ./spec.yaml
823825
**Two Operation Modes:**
824826

825827
**Interactive Mode** (no operation flags):
828+
826829
- Launch a terminal UI to browse all operations
827830
- Select operations with Space key
828831
- Press 'a' to select all, 'A' to deselect all
829832
- Press 'w' to write the result (prompts for file path)
830833
- Press 'q' or Esc to cancel
831834

832835
**Command-Line Mode** (operation flags specified):
836+
833837
- Remove operations specified via flags without UI
834838
- Supports `--operationId` for operation IDs
835839
- Supports `--operation` for path:method pairs
@@ -951,7 +955,7 @@ openapi spec clean | \
951955
openapi spec upgrade | \
952956
openapi spec validate
953957
954-
# Alternative: Clean after bundling to remove unused components
958+
# Alternative: Clean after bundling to remove unused components and unused top-level tags
955959
openapi spec bundle ./spec.yaml ./bundled.yaml
956960
openapi spec clean ./bundled.yaml ./clean-bundled.yaml
957961
openapi spec validate ./clean-bundled.yaml

cmd/openapi/commands/openapi/clean.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,19 @@ import (
1212

1313
var cleanCmd = &cobra.Command{
1414
Use: "clean <input-file> [output-file]",
15-
Short: "Remove unused components from an OpenAPI specification",
16-
Long: `Remove unused components from an OpenAPI specification to create a cleaner, more focused document.
15+
Short: "Remove unused components and unused top-level tags from an OpenAPI specification",
16+
Long: `Remove unused components and unused top-level tags from an OpenAPI specification to create a cleaner, more focused document.
1717
18-
This command analyzes an OpenAPI document to identify which components are actually referenced
19-
and removes any unused components, reducing document size and improving clarity.
18+
This command uses reachability-based analysis to keep only what is actually used by the API surface:
19+
- Seeds reachability exclusively from API surface areas: entries under /paths and the top-level security section
20+
- Expands through $ref links across component sections until a fixed point is reached
21+
- Preserves security schemes referenced by name in security requirement objects (global or operation-level)
22+
- Prunes any components that are not reachable from the API surface
23+
- Removes unused top-level tags that are not referenced by any operation
2024
2125
What gets cleaned:
2226
- Unused schemas in components/schemas
23-
- Unused responses in components/responses
27+
- Unused responses in components/responses
2428
- Unused parameters in components/parameters
2529
- Unused examples in components/examples
2630
- Unused request bodies in components/requestBodies
@@ -29,14 +33,13 @@ What gets cleaned:
2933
- Unused links in components/links
3034
- Unused callbacks in components/callbacks
3135
- Unused path items in components/pathItems
36+
- Unused top-level tags (global tags not referenced by any operation)
3237
3338
Special handling for security schemes:
3439
Security schemes can be referenced in two ways:
3540
1. By $ref (like other components)
3641
2. By name in security requirement objects (global or operation-level)
37-
38-
The clean command correctly handles both cases and preserves security schemes
39-
that are referenced by name in security blocks.
42+
The clean command correctly handles both cases and preserves security schemes that are referenced by name in security blocks.
4043
4144
Benefits of cleaning:
4245
- Reduce document size by removing dead code

cmd/openapi/commands/openapi/snip.go

Lines changed: 147 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import (
1313
)
1414

1515
var (
16-
snipWriteInPlace bool
17-
snipOperationIDs []string
18-
snipOperations []string
16+
snipWriteInPlace bool
17+
snipOperationIDs []string
18+
snipOperations []string
19+
snipKeepOperationIDs []string
20+
snipKeepOperations []string
1921
)
2022

2123
var snipCmd = &cobra.Command{
@@ -73,6 +75,9 @@ func init() {
7375
snipCmd.Flags().BoolVarP(&snipWriteInPlace, "write", "w", false, "write result in-place to input file")
7476
snipCmd.Flags().StringSliceVar(&snipOperationIDs, "operationId", nil, "operation ID to remove (can be comma-separated or repeated)")
7577
snipCmd.Flags().StringSliceVar(&snipOperations, "operation", nil, "operation as path:method to remove (can be comma-separated or repeated)")
78+
// Keep-mode flags (mutually exclusive with remove-mode flags)
79+
snipCmd.Flags().StringSliceVar(&snipKeepOperationIDs, "keepOperationId", nil, "operation ID to keep (can be comma-separated or repeated)")
80+
snipCmd.Flags().StringSliceVar(&snipKeepOperations, "keepOperation", nil, "operation as path:method to keep (can be comma-separated or repeated)")
7681
}
7782

7883
func runSnip(cmd *cobra.Command, args []string) error {
@@ -84,20 +89,29 @@ func runSnip(cmd *cobra.Command, args []string) error {
8489
outputFile = args[1]
8590
}
8691

87-
// Check if any operations were specified via flags
88-
hasOperationFlags := len(snipOperationIDs) > 0 || len(snipOperations) > 0
92+
// Check which flag sets were specified
93+
hasRemoveFlags := len(snipOperationIDs) > 0 || len(snipOperations) > 0
94+
hasKeepFlags := len(snipKeepOperationIDs) > 0 || len(snipKeepOperations) > 0
8995

90-
// If -w is specified without operation flags, error
91-
if snipWriteInPlace && !hasOperationFlags {
92-
return fmt.Errorf("--write flag requires specifying operations via --operationId or --operation flags")
96+
// If -w is specified without any operation selection flags, error
97+
if snipWriteInPlace && !(hasRemoveFlags || hasKeepFlags) {
98+
return fmt.Errorf("--write flag requires specifying operations via --operationId/--operation or --keepOperationId/--keepOperation")
9399
}
94100

95-
if !hasOperationFlags {
96-
// No flags - interactive mode
101+
// Interactive mode when no flags provided
102+
if !hasRemoveFlags && !hasKeepFlags {
97103
return runSnipInteractive(ctx, inputFile, outputFile)
98104
}
99105

100-
// Flags specified - CLI mode
106+
// Disallow mixing keep + remove flags; ambiguous intent
107+
if hasRemoveFlags && hasKeepFlags {
108+
return fmt.Errorf("cannot combine keep and remove flags; use either --operationId/--operation or --keepOperationId/--keepOperation")
109+
}
110+
111+
// CLI mode
112+
if hasKeepFlags {
113+
return runSnipCLIKeep(ctx, inputFile, outputFile)
114+
}
101115
return runSnipCLI(ctx, inputFile, outputFile)
102116
}
103117

@@ -139,6 +153,87 @@ func runSnipCLI(ctx context.Context, inputFile, outputFile string) error {
139153
return processor.WriteDocument(ctx, doc)
140154
}
141155

156+
func runSnipCLIKeep(ctx context.Context, inputFile, outputFile string) error {
157+
// Create processor
158+
processor, err := NewOpenAPIProcessor(inputFile, outputFile, snipWriteInPlace)
159+
if err != nil {
160+
return err
161+
}
162+
163+
// Load document
164+
doc, validationErrors, err := processor.LoadDocument(ctx)
165+
if err != nil {
166+
return err
167+
}
168+
169+
// Report validation errors (if any)
170+
processor.ReportValidationErrors(validationErrors)
171+
172+
// Parse keep flags
173+
keepOps, err := parseKeepOperationFlags()
174+
if err != nil {
175+
return err
176+
}
177+
if len(keepOps) == 0 {
178+
return fmt.Errorf("no operations specified to keep")
179+
}
180+
181+
// Collect all operations from the document
182+
allOps, err := explore.CollectOperations(ctx, doc)
183+
if err != nil {
184+
return fmt.Errorf("failed to collect operations: %w", err)
185+
}
186+
if len(allOps) == 0 {
187+
return fmt.Errorf("no operations found in the OpenAPI document")
188+
}
189+
190+
// Build lookup sets for keep filters
191+
keepByID := map[string]bool{}
192+
keepByPathMethod := map[string]bool{}
193+
for _, k := range keepOps {
194+
if k.OperationID != "" {
195+
keepByID[k.OperationID] = true
196+
}
197+
if k.Path != "" && k.Method != "" {
198+
key := strings.ToUpper(k.Method) + " " + k.Path
199+
keepByPathMethod[key] = true
200+
}
201+
}
202+
203+
// Compute removal list = all - keep
204+
var operationsToRemove []openapi.OperationIdentifier
205+
for _, op := range allOps {
206+
if op.OperationID != "" && keepByID[op.OperationID] {
207+
continue
208+
}
209+
key := strings.ToUpper(op.Method) + " " + op.Path
210+
if keepByPathMethod[key] {
211+
continue
212+
}
213+
operationsToRemove = append(operationsToRemove, openapi.OperationIdentifier{
214+
Path: op.Path,
215+
Method: strings.ToUpper(op.Method),
216+
})
217+
}
218+
219+
// If nothing to remove, write as-is
220+
if len(operationsToRemove) == 0 {
221+
processor.PrintSuccess("No operations to remove based on keep filters; writing document unchanged")
222+
return processor.WriteDocument(ctx, doc)
223+
}
224+
225+
// Perform the snip
226+
removed, err := openapi.Snip(ctx, doc, operationsToRemove)
227+
if err != nil {
228+
return fmt.Errorf("failed to snip operations: %w", err)
229+
}
230+
231+
processor.PrintSuccess(fmt.Sprintf("Successfully kept %d operation(s) and removed %d operation(s) with cleanup", len(allOps)-removed, removed))
232+
233+
// Write the snipped document
234+
return processor.WriteDocument(ctx, doc)
235+
}
236+
142237
func runSnipInteractive(ctx context.Context, inputFile, outputFile string) error {
143238
// Load the OpenAPI document
144239
doc, err := loadOpenAPIDocument(ctx, inputFile)
@@ -306,6 +401,47 @@ func parseOperationFlags() ([]openapi.OperationIdentifier, error) {
306401
return operations, nil
307402
}
308403

404+
// parseKeepOperationFlags parses the keep flags into operation identifiers
405+
// Handles both repeated flags and comma-separated values
406+
func parseKeepOperationFlags() ([]openapi.OperationIdentifier, error) {
407+
var operations []openapi.OperationIdentifier
408+
409+
// Parse keep operation IDs
410+
for _, opID := range snipKeepOperationIDs {
411+
if opID != "" {
412+
operations = append(operations, openapi.OperationIdentifier{
413+
OperationID: opID,
414+
})
415+
}
416+
}
417+
418+
// Parse keep path:method operations
419+
for _, op := range snipKeepOperations {
420+
if op == "" {
421+
continue
422+
}
423+
424+
parts := strings.SplitN(op, ":", 2)
425+
if len(parts) != 2 {
426+
return nil, fmt.Errorf("invalid keep operation format: %s (expected path:METHOD format, e.g., /users:GET)", op)
427+
}
428+
429+
path := parts[0]
430+
method := strings.ToUpper(parts[1])
431+
432+
if path == "" || method == "" {
433+
return nil, fmt.Errorf("invalid keep operation format: %s (path and method cannot be empty)", op)
434+
}
435+
436+
operations = append(operations, openapi.OperationIdentifier{
437+
Path: path,
438+
Method: method,
439+
})
440+
}
441+
442+
return operations, nil
443+
}
444+
309445
// GetSnipCommand returns the snip command for external use
310446
func GetSnipCommand() *cobra.Command {
311447
return snipCmd

cmd/openapi/internal/explore/tui/model.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,8 @@ func NewModelWithConfig(operations []explore.OperationInfo, docTitle, docVersion
140140
// Only relevant when selectionConfig.Enabled is true
141141
func (m Model) GetSelectedOperations() []explore.OperationInfo {
142142
var selected []explore.OperationInfo
143-
for idx := range m.selected {
144-
if idx < len(m.operations) {
143+
for idx, isSelected := range m.selected {
144+
if isSelected && idx < len(m.operations) {
145145
selected = append(selected, m.operations[idx])
146146
}
147147
}

0 commit comments

Comments
 (0)