249 Commits
v0.1.0 ... main

Author SHA1 Message Date
230187296f Stop flag interpretation at unknown flags 2026-10-26 14:37:33 +00:00
473fb027a1 Avoid retaining hidden simple deferred messages 2026-10-26 14:35:44 +00:00
135d8da2b9 Resolve flag aliases in failed command routes 2026-10-26 14:33:31 +00:00
de2266af75 Restore cancellation checks before report delivery 2026-10-26 14:32:03 +00:00
13a4d4eacd Explain invocation presentation failures 2026-10-26 14:28:58 +00:00
215fc34d51 Preserve joined rendering error evidence 2026-10-26 14:27:24 +00:00
3338b902a2 Present native PromptKit operation failures 2026-10-26 14:18:21 +00:00
41c6ca8070 Record reporting efficiency opportunities 2026-10-26 14:14:50 +00:00
ee76b0f5f1 Record reporting integration audit findings 2026-10-26 14:01:28 +00:00
1e532cdfa5 Record reporting audit findings 2026-10-26 12:41:36 +00:00
7c035ccc54 Validate invocation reporting implementation 2026-10-26 03:59:28 +00:00
96aa6b5ca0 Document invocation reporting contracts 2026-10-26 03:57:11 +00:00
a6c0fb5b0d Reconcile reporting tests and boundaries 2026-10-26 03:51:14 +00:00
6264a87183 Migrate maintained examples to invocation reporting 2026-10-26 03:44:19 +00:00
b6f22462d3 Add a core reporting example 2026-10-26 03:38:48 +00:00
401232a1b0 Migrate PromptKit validation and smoke reporting 2026-10-26 03:35:39 +00:00
4ace616bc2 Fix PromptKit inspection reporter assertions 2026-10-26 03:34:50 +00:00
7c010e945a Update requirements inspection reporting tests 2026-10-26 03:31:06 +00:00
7fc89768ef Migrate PromptKit inspection reports to the reporter 2026-10-26 03:30:19 +00:00
37dfea5b25 Separate PromptKit messages from terminal context 2026-10-26 03:26:14 +00:00
c0e0010683 Migrate ConfigKit reports to the reporter 2026-10-26 03:20:17 +00:00
5f37aac70f Render ConfigKit show and validate through reporter 2026-10-26 03:16:02 +00:00
9a4d1db955 Separate ConfigKit messages from explanations 2026-10-26 03:10:18 +00:00
f3e9326544 Add debug events to command execution 2026-10-26 03:03:57 +00:00
7a1067d387 Route help and version through the reporter 2026-10-26 03:02:08 +00:00
49a10690c6 Connect invocation execution to one reporter 2026-10-26 02:59:28 +00:00
df21b75f9c Preserve reporting flag occurrence order 2026-10-26 02:55:08 +00:00
f3bd6dc2ae Install shared reporting flags 2026-10-26 02:50:54 +00:00
6ba9338c0a Add deferred reporting finalization 2026-10-26 02:44:41 +00:00
5df88a6d94 Add atomic reporting and message categories 2026-10-26 02:42:22 +00:00
f5a5bbac58 Add managed reporter output state 2026-10-26 02:39:16 +00:00
e56537690b Define invocation outcome reporting types 2026-10-26 02:37:19 +00:00
2a1096bd1f Plan invocation reporting implementation 2026-10-26 01:39:40 +00:00
1d29cdd029 Clarify provider diagnostics and reporting edge cases 2026-10-26 01:25:11 +00:00
e48187d822 Accept invocation reporting architecture and clarify error ownership 2026-10-26 01:18:28 +00:00
e951a48902 Specify invocation reporting API signatures 2026-10-26 00:10:36 +00:00
976ed89403 Clarify reporting lifecycle and concurrency contracts 2026-10-26 00:00:25 +00:00
a5a92c7f08 Settle reporting delivery lifecycle and quiet rendering 2026-10-24 02:01:59 +00:00
514c4d47bb Settle failure presentation and reporting retention boundaries 2026-10-24 01:56:41 +00:00
ec29d658b7 Clarify application error handling and reporting ownership 2026-10-24 01:50:38 +00:00
085c296d89 Define terminal failure presentation for invocation reporting 2026-10-24 01:19:16 +00:00
0869b17b43 Select unified invocation reporting API direction 2026-10-24 01:01:48 +00:00
810333e376 Record progress controls and reporting prefixes 2026-10-24 00:42:35 +00:00
2e337155ce Settle reporting flag grammar and core debug guidance 2026-10-23 18:42:21 +00:00
fa0aeace36 Expand reporting categories and define failure-bound errors 2026-10-23 18:08:13 +00:00
09d4d7992a Draft output policy and invocation reporting roadmap 2026-10-23 14:08:10 +00:00
c70e29d3fd Remove completed alias interaction remediation document 2026-10-20 04:12:02 +00:00
e47ae799d7 Keep empty and dash operands with runnable parents 2026-10-20 01:07:23 +00:00
5c26d80228 Retain discovery prefixes in usage diagnostics 2026-10-20 01:04:56 +00:00
bafd365115 Discover help routes through intermediate aliases 2026-10-20 01:03:54 +00:00
4ddf960836 Record alias interaction audit findings 2026-10-20 00:39:24 +00:00
08ad7427e2 Remove completed alias planning and remediation documents 2026-10-20 00:33:38 +00:00
f42aefe02d Reconcile alias documentation with the completed implementation 2026-10-20 00:30:53 +00:00
520d7e125b Retain entered flag spellings in parser failures 2026-10-20 00:29:49 +00:00
eb92f7be7f Preserve double dash values during flag alias parsing 2026-10-20 00:28:38 +00:00
744b8135f2 Show aliases beneath empty command groups 2026-10-20 00:26:07 +00:00
c231a41665 Compose discovery help through group aliases 2026-10-20 00:25:33 +00:00
fa09edf8cc Require exact resolved paths for explicit help 2026-10-20 00:23:19 +00:00
7dad20680e Keep command routing independent of operand counts 2026-10-20 00:22:25 +00:00
ccf8acd861 Protect canonical operand prefixes from aliases 2026-10-20 00:20:40 +00:00
c225a66c97 Record alias implementation review findings 2026-10-20 00:03:54 +00:00
a54a9354b1 Verify and complete alias implementation 2026-10-19 20:02:07 +00:00
42d9989533 Prepare core alias release notes 2026-10-19 20:00:09 +00:00
333a630cf1 Document application-owned alias contracts 2026-10-19 19:59:41 +00:00
666c006a89 Add aliases to maintained examples 2026-10-19 19:56:33 +00:00
c3bb3941d3 Verify typed alias composition in integrations 2026-10-19 19:53:33 +00:00
f34ab12ca5 Verify public alias registration flows 2026-10-19 19:48:57 +00:00
1fa026e5bd Preserve alias context in usage errors 2026-10-19 19:46:22 +00:00
a504efbad7 Render help through command aliases 2026-10-19 19:40:55 +00:00
451a0c483f Expose canonical paths on command invocations 2026-10-19 19:33:49 +00:00
75fc45f072 Interpret command flag aliases in the parser 2026-10-19 19:31:23 +00:00
58b71c390f Resolve entered command routes to canonical paths 2026-10-19 17:37:59 +00:00
6ea8c4dd3f Compile effective flag aliases during installation 2026-10-19 17:33:39 +00:00
2ab87e9333 Install command aliases atomically 2026-10-19 17:27:17 +00:00
7a4efddab8 Add captured alias declaration types 2026-10-19 17:23:32 +00:00
854426a0c9 Validate command alias termination 2026-10-19 15:16:07 +00:00
80d4d6e1bd Model command alias prefix rewriting 2026-10-19 15:11:43 +00:00
b3fb795224 Add shared command path primitives 2026-10-19 15:08:00 +00:00
d7cc12a347 Plan application alias implementation 2026-10-19 14:41:10 +00:00
f3b8a129d9 Correct alias examples to respect smoke-test operand grammar 2026-10-19 14:26:49 +00:00
fae8c4dafa Accept ADR 0013 for application alias routing 2026-10-19 14:21:12 +00:00
ca85d5b012 Record future shell completion direction for aliases 2026-10-19 14:16:05 +00:00
93ee328779 Clarify alias routing interactions and implementation checks 2026-10-19 14:06:10 +00:00
c6c4b01902 Resolve alias declaration API and shared path handling 2026-10-19 13:48:07 +00:00
55330d2817 Settle short alias grammar and empty-value correction 2026-10-19 12:19:30 +00:00
fbef0f2ecc Record flag alias chains short spellings and help decisions 2026-10-19 01:44:38 +00:00
573a22a38e Record flag alias scope and target availability decisions 2026-10-19 01:21:33 +00:00
f526198059 Record alias invocation metadata decision 2026-10-19 01:18:13 +00:00
4b53c96cf3 Record alias help and discovery decisions 2026-10-19 01:16:50 +00:00
50c54c229e Record alias chain and group composition decisions 2026-10-19 00:20:47 +00:00
0dfeac3022 Record alias prefix and operand collision decisions 2026-10-19 00:19:28 +00:00
8d5966a868 Record alias registration conventions in roadmap 2026-10-18 23:51:39 +00:00
a4cf55f7a5 Record canonical collision and deterministic alias rules 2026-10-18 21:12:53 +00:00
1f3d1594c9 Add flag alias direction to alias roadmap 2026-10-18 16:09:15 +00:00
ae6af1dc65 Record application alias routing roadmap 2026-10-18 15:40:31 +00:00
df6ed89a97 Record published dependencies for ckprompt 2026-10-18 13:54:21 +00:00
a320c76574 Record published core dependency checksums for ckconfig 2026-10-18 13:53:48 +00:00
841e60fed3 Prepare core and integration release notes 2026-10-18 13:51:15 +00:00
654d1b4a6d Retire completed upgrade planning documents 2026-10-18 13:48:31 +00:00
a6278a6d9f Mark the upgrade roadmap as implemented 2026-10-18 13:32:56 +00:00
114c7da7be Keep aggregate smoke tests focused on behavior 2026-10-18 13:30:02 +00:00
94c352dbd6 Test smoke forwarding without private PromptKit assumptions 2026-10-18 13:29:13 +00:00
1d0085450a Preserve empty smoke error messages in aggregate reports 2026-10-18 13:28:29 +00:00
0fe77baa29 Record dependency and smoke review findings 2026-10-18 03:52:35 +00:00
730f930b47 Verify dependency upgrade and smoke integration 2026-10-17 19:19:04 +00:00
e653b7d6ec Document PromptKit catalogs and smoke testing 2026-10-17 19:15:58 +00:00
8d1f33a40e Reconcile integration dependency graphs and notices 2026-10-17 19:08:04 +00:00
35e958c210 Demonstrate PromptKit catalogs and smoke tests 2026-10-17 19:06:05 +00:00
079576361b Verify PromptKit smoke outcomes and delivery 2026-10-17 19:01:34 +00:00
80f31d2ec0 Verify PromptKit smoke command lifecycles 2026-10-17 19:00:03 +00:00
2e14d973ee Coordinate aggregate PromptKit smoke tests 2026-10-17 18:57:37 +00:00
20ef7bb8c4 Add PromptKit single smoke test command 2026-10-17 18:55:41 +00:00
1650680723 Project native smoke reports to YAML 2026-10-17 18:51:17 +00:00
3e9c871cdc Add PromptKit smoke test adapter 2026-10-17 18:49:18 +00:00
7a91f722fd Verify catalog capability lifecycles 2026-10-17 18:46:47 +00:00
504ea03574 Add filtered PromptKit catalog commands 2026-10-17 18:45:39 +00:00
dc4261a1ca Extend native catalog adapters 2026-10-17 18:44:10 +00:00
c1fa18629d Migrate prompt fixtures to direct definitions 2026-10-17 18:39:38 +00:00
66b2994e48 Migrate prompt integration to PromptKit 0.16 2026-10-17 18:34:03 +00:00
b56cae70b9 Expose ConfigKit source evidence in reports 2026-10-17 18:31:11 +00:00
3124d572cf Adopt ConfigKit v0.8.0 in both integrations 2026-10-17 18:29:39 +00:00
b2caf8371a Adopt ReleaseKit v0.4.2 2026-10-17 18:28:01 +00:00
29582dd6b7 Plan dependency upgrades and PromptKit diagnostics 2026-10-17 18:10:34 +00:00
38b49f29fb Record PromptKit test commands and aggregate behavior 2026-10-17 16:34:07 +00:00
f173fdbc1b Keep upgrade adapters and examples focused 2026-10-17 15:54:19 +00:00
7eb859d036 Record upgrade decisions and include smoke testing 2026-10-17 15:52:15 +00:00
502ea5eca8 Clarify ownership of upstream integration tests 2026-10-17 15:37:20 +00:00
7b9b945baa Plan ConfigKit, PromptKit, and ReleaseKit upgrades 2026-10-17 15:02:03 +00:00
5f633e3fee Check validation diagnostics without fixing their layout 2026-10-12 23:43:16 +00:00
775c32479d Allow early missing-selector failures in validation tests 2026-10-12 23:42:18 +00:00
99e2089e75 Record PromptKit validation review findings 2026-10-12 23:27:27 +00:00
420483b2d4 Mark PromptKit validation implementation complete 2026-10-12 22:27:28 +00:00
88d4059e8d Document PromptKit request validation 2026-10-12 22:25:43 +00:00
be8bd9c1ea Extend composed consumer with request validation 2026-10-12 22:22:51 +00:00
9e6431be63 Verify validation outcomes and report delivery 2026-10-12 22:20:07 +00:00
346bcaeddf Cover validation command lifecycle and grammar 2026-10-12 22:18:50 +00:00
686a7ba0ab Add PromptKit requirements validation command 2026-10-12 22:16:44 +00:00
f5c1c69ccb Verify native PromptKit request validation 2026-10-12 22:15:06 +00:00
7fc5423688 Add PromptKit request validation adapter 2026-10-12 22:13:16 +00:00
1c542505b7 Adopt PromptKit v0.15.0 in ckprompt 2026-10-12 22:11:38 +00:00
3218222e53 Plan PromptKit request validation implementation 2026-10-12 22:05:16 +00:00
1119bda6b7 Settle PromptKit request validation roadmap 2026-10-12 21:53:52 +00:00
87391903f4 Plan PromptKit request validation adoption 2026-10-12 21:37:43 +00:00
700f10d78e Remove completed requirements planning records 2026-10-12 21:08:42 +00:00
40499a4360 Check default YAML output semantically 2026-10-12 17:45:35 +00:00
e5faa1855d Document requirements variable report fields 2026-10-12 17:43:54 +00:00
92c1d49ade Clarify requirements flag repetition 2026-10-12 17:42:32 +00:00
5dc83d9460 Record requirements inspection review findings 2026-10-12 17:39:38 +00:00
cfc2d1dfd0 Complete PromptKit requirements implementation 2026-10-12 17:30:37 +00:00
d3cbfc4e9a Document PromptKit requirements discovery 2026-10-12 17:26:11 +00:00
3710492d2d Demonstrate requirements discovery in composed example 2026-10-12 17:17:49 +00:00
b27cf02c0e Verify PromptKit requirements command behavior 2026-10-12 17:14:12 +00:00
7dce7b816b Add PromptKit requirements command 2026-10-12 17:05:33 +00:00
0276930b12 Add PromptKit requirements YAML report 2026-10-12 16:57:23 +00:00
ebd29598db Add PromptKit requirements adapter 2026-10-12 16:54:00 +00:00
3935dcdbda Plan requirements inspection implementation 2026-10-12 16:44:39 +00:00
49b7e686b3 Settle PromptKit requirements inspection roadmap 2026-10-12 16:34:56 +00:00
fa0cd9a73b Plan PromptKit requirements inspection 2026-10-12 16:24:43 +00:00
12b52b3174 Remove completed roadmap and remediation records 2026-10-12 15:27:27 +00:00
8a82620dfc Check PromptKit capabilities without assuming their order 2026-10-12 15:11:13 +00:00
61f0d8806d Use the declared YAML dependency in comparison tests 2026-10-12 15:08:44 +00:00
b9d5ea4b1b Record PromptKit implementation review findings 2026-10-12 14:02:26 +00:00
f8f8d27e71 Validate PromptKit comparison and catalogs 2026-10-12 02:47:08 +00:00
def9b90b7c Update PromptKit notices and release notes 2026-10-12 02:42:52 +00:00
ee038df530 Document PromptKit comparison and catalogs 2026-10-12 02:38:27 +00:00
4bc0689c44 Demonstrate PromptKit comparison and catalogs 2026-10-12 02:28:20 +00:00
526a033da5 Cover PromptKit catalog command behavior 2026-10-12 02:24:25 +00:00
e4cdedd6db Add PromptKit catalog commands 2026-10-12 02:17:54 +00:00
c75d371ecd Add PromptKit catalog YAML encoders 2026-10-12 02:12:47 +00:00
330017fd75 Add PromptKit catalog providers 2026-10-12 02:10:45 +00:00
f7b1d2c583 Verify PromptKit comparison through config commands 2026-10-12 02:07:28 +00:00
ddd14b70a8 Add PromptKit configuration comparison 2026-10-12 02:01:10 +00:00
c6b4a42287 Retain PromptKit diagnostics after cancellation 2026-10-12 01:55:08 +00:00
ec4eb9d3a6 Upgrade ckprompt to PromptKit v0.14.0 2026-10-12 01:46:28 +00:00
d825538921 Plan PromptKit implementation stages 2026-10-12 01:40:15 +00:00
20e19b9569 Settle PromptKit roadmap API and presentation decisions 2026-10-12 01:22:50 +00:00
603b6b3758 Plan PromptKit comparison and catalog adoption 2026-10-12 01:12:59 +00:00
ab270e2c82 Align the feature roadmap with completed validation 2026-10-12 00:17:34 +00:00
c7fb5a27bb Test comparison preparation through provider behavior 2026-10-12 00:16:47 +00:00
251d3adeee Clarify comparison provider and delivery guarantees 2026-10-12 00:15:04 +00:00
98c9c3f00d Index mappings while assembling configuration reports 2026-10-12 00:14:20 +00:00
7bf89e9b56 Record configuration comparison review findings 2026-10-11 23:08:58 +00:00
28aa3b04ad Mark configuration comparison roadmap complete 2026-10-11 22:06:20 +00:00
c69a75d1c9 Validate configuration comparison sprint 2026-10-11 22:06:08 +00:00
976b55309c Reconcile integration dependency notices 2026-10-11 22:03:58 +00:00
8b46e12da7 Document configuration comparison behavior 2026-10-11 22:00:22 +00:00
f0853d1000 Demonstrate composed configuration comparison 2026-10-11 21:54:39 +00:00
3cbb1f35b0 Demonstrate config diff in simple consumer 2026-10-11 21:52:47 +00:00
18ee987a88 Execute configuration comparisons 2026-10-11 21:51:54 +00:00
783f17f274 Render native configuration differences as YAML 2026-10-11 21:49:50 +00:00
30787b2806 Add safe configuration report helpers 2026-10-11 21:48:49 +00:00
3542422b2f Add ConfigKit comparison acquisition 2026-10-11 21:47:16 +00:00
d2434b9580 Prepare both configuration comparison inputs 2026-10-11 21:43:30 +00:00
d1591463ee Add comparison selection and format flags 2026-10-11 21:39:19 +00:00
018f90a23c Add configuration comparison capability contract 2026-10-11 21:36:34 +00:00
c883c64be9 Adopt ConfigKit v0.7.1 in integrations 2026-10-11 21:33:22 +00:00
e8b44ca765 Split the implementation plan into smaller stages 2026-10-11 21:09:53 +00:00
7fc425ede3 Plan the ConfigKit comparison implementation 2026-10-11 20:35:38 +00:00
f8bb78924e Clarify the scope of architectural decision records 2026-10-11 20:26:11 +00:00
92727ecf2b Clarify CommandKit ownership of upstream integration and presentation 2026-10-11 19:14:23 +00:00
21c2fde8d5 Finalize native comparison ADRs and clarify declaration contracts 2026-10-11 18:59:57 +00:00
fbee871731 Organize the ConfigKit roadmap around the selected end state 2026-10-11 18:28:14 +00:00
f67ee235d7 Record the ConfigKit upgrade and comparison plan 2026-10-11 18:23:55 +00:00
ecd713ef2f Retire completed implementation and remediation roadmaps 2026-10-11 13:22:18 +00:00
bb2528532b Correct integration attribution dependency scopes 2026-10-11 13:10:18 +00:00
5aac2a0ee1 Remove unused flag clearing closures 2026-10-11 13:09:06 +00:00
960a7c963e Describe release file inputs and checks accurately 2026-10-11 13:08:31 +00:00
4ba5a56e21 Align release note policy with independent modules 2026-10-11 13:07:45 +00:00
7ce2fc0bf2 Clarify inspection input ownership and documentation 2026-10-11 13:07:00 +00:00
cec4da69d3 Compile configuration selector metadata during capture 2026-10-11 13:05:22 +00:00
fba917f5bd Capture PromptKit selectors before retaining callbacks 2026-10-11 13:02:54 +00:00
6d660585d1 Link the finalized backend design from the roadmap 2026-10-11 13:01:35 +00:00
dc252d9fb0 Record the implementation review findings 2026-10-11 05:20:14 +00:00
cc574915ee Validate independent module boundaries 2026-10-11 02:13:16 +00:00
d2b5b0f3a8 Reconcile module docs and release materials 2026-10-11 02:06:06 +00:00
18e81a5579 Adopt ReleaseKit module publishing 2026-10-11 01:54:04 +00:00
e89cc2220f Finish maintained consumer examples 2026-10-11 01:50:48 +00:00
f7174a078e Add PromptKit inspection commands 2026-10-11 01:46:54 +00:00
eeeaca6c5e Add PromptKit engine adapters 2026-10-11 01:43:02 +00:00
c0f3a21fc7 Port native configuration reports 2026-10-11 01:39:23 +00:00
809a36bc46 Assemble configuration families and inputs 2026-10-11 01:34:25 +00:00
1e1eca4a88 Add ConfigKit scope contracts and adapters 2026-10-11 01:30:18 +00:00
7e5f84bf16 Route executions through neutral snapshots 2026-10-11 01:25:45 +00:00
24e8cd8629 Install command batches through one path 2026-10-11 01:22:47 +00:00
74f6bdeade Compile typed command runners 2026-10-11 01:21:52 +00:00
4e0611bc13 Add captured flag value application 2026-10-11 01:20:43 +00:00
f63b096525 Unify typed binding preparation 2026-10-11 01:18:07 +00:00
de9bfdcc4c Capture immutable command declarations 2026-10-11 01:15:13 +00:00
476e98a463 Move integration code into optional modules 2026-10-11 01:06:21 +00:00
0d23676144 Plan the multi-module implementation sprint 2026-10-11 00:06:31 +00:00
bb849ff3e1 Link the finalized registration backend design 2026-10-10 23:44:28 +00:00
336d489727 Resolve unused binding validation for empty integration suites 2026-10-10 23:33:23 +00:00
3b69cf85e7 Refine backend composition and input lifecycle decisions 2026-10-10 22:56:40 +00:00
14c304b6f9 Plan shared registration and execution internals 2026-10-10 16:45:14 +00:00
b8aada3b6e Align documentation with accepted ADR 0011 2026-10-10 16:23:02 +00:00
160604095c Accept the captured declaration architecture and align documentation 2026-10-10 16:19:45 +00:00
8f86852456 Finalize the registration API roadmap 2026-10-10 15:44:53 +00:00
892186d4ac Clarify registration validation and invocation state contracts 2026-10-10 15:40:08 +00:00
0b5c1422ed Complete the registration proposal declarations and examples 2026-10-10 15:21:16 +00:00
48469ea509 Record the unified command registration direction 2026-10-10 14:46:56 +00:00
58ee418249 Complete the registration API design mockups 2026-10-10 14:21:11 +00:00
d9f60d2d5a Refine registration alternatives and list remaining decisions 2026-10-10 14:10:59 +00:00
eeb9fdd66c Compare integration registration API alternatives 2026-10-10 13:55:22 +00:00
4ca0e9fdae Record the forthcoming module release support 2026-10-10 13:22:38 +00:00
ecc0ad4e70 Clarify the final API design and module decisions 2026-10-09 15:51:58 +00:00
76f923e619 Accept optional integration modules architecture 2026-10-09 14:11:02 +00:00
9456bef1c1 Record integration module paths and package names 2026-10-09 13:28:45 +00:00
085302d78a Clarify ConfigKit integration scope and module naming options 2026-10-09 13:03:07 +00:00
06859b1b09 Document the optional integration modules roadmap 2026-10-09 12:54:28 +00:00
185 changed files with 28719 additions and 6377 deletions

View File

@@ -1,19 +1,37 @@
# CommandKit # CommandKit
CommandKit is a Go library project for consistent CLI inspection, command syntax, CommandKit is a Go library for typed command declarations, shared CLI behavior,
and shared command infrastructure across related applications. and application-owned execution. The core `commandkit` module is usable by
itself. ConfigKit and PromptKit support lives in separately versioned optional
modules, so applications choose which upstream libraries they add.
The current Go library provides an instance-owned command registry, typed flag The core captures reusable command declarations, validates installation batches
and operand bindings, Cobra-backed routing, invocation outcomes, generated help, before changing an application's registry, converts typed inputs before
application or CommandKit version reporting, configuration scope discovery, constructing them, and returns outcomes without exiting the process. Its
ConfigKit-backed validation, native effective YAML display, source inventory, [consumer guide](docs/consumers/guide.md) covers declaration, invocation
and path explanation reports. Applications can register a PromptKit engine reporting, and migration. Each handler receives one invocation reporter for
configuration scope independently of PromptKit definition commands, and can substantive output, categorized messages, and terminal explanations; `Execute`
install optional prompt and profile inspection commands. returns delivery observations with the outcome. The [CLI reference](docs/cli.md)
defines the shared visibility policy.
The [consumer guide](docs/consumers/guide.md) links to two maintained examples: ## Modules
[simple](examples/simple/README.md) and
[composed](examples/composed/README.md). | Module | Package | Provides |
| --- | --- | --- |
| `gitea.maximumdirect.net/eric/CommandKit` | `commandkit` | Typed commands, bindings, execution reporting and outcomes, help, and version. |
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | `ckconfig` | ConfigKit-backed `config` scopes, including configuration source evidence. |
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | `ckprompt` | PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, concrete request validation, and native smoke tests. |
Importing an integration does not install commands. Pass its declaration to
`commandkit.New` or `App.Register` to install it. The integrations depend on the
core and their selected upstream libraries; the core does not depend on either
integration.
## Examples
- [Core-only example](examples/core/README.md)
- [ConfigKit example](integrations/ckconfig/examples/simple/README.md)
- [Composed ConfigKit and PromptKit example](integrations/ckprompt/examples/composed/README.md)
## Documentation ## Documentation
@@ -24,11 +42,7 @@ The [consumer guide](docs/consumers/guide.md) links to two maintained examples:
- [Maintainer release procedure](docs/release.md) - [Maintainer release procedure](docs/release.md)
- [Architecture policy](docs/policy/architecture.md) - [Architecture policy](docs/policy/architecture.md)
- [Policy provenance](docs/policy/meta.md) - [Policy provenance](docs/policy/meta.md)
## Future work and history
- [Deferred capabilities and proposals](docs/roadmap/deferred.md) - [Deferred capabilities and proposals](docs/roadmap/deferred.md)
- [Completed planning and implementation records in Git history](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap)
CommandKit is licensed under the [LGPL v3.0 or later](COPYRIGHT). CommandKit is licensed under the [LGPL v3.0 or later](COPYRIGHT).

70
app.go
View File

@@ -3,47 +3,59 @@ package commandkit
import ( import (
"fmt" "fmt"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" "gitea.maximumdirect.net/eric/CommandKit/internal/alias"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
) )
// App owns one command registry and its application identity. // App owns one command registry and its application identity. Each App has an
// independent registry; command declarations may be reused across Apps.
type App struct { type App struct {
identity Identity identity Identity
commands map[string]*commandNode commands map[string]*commandNode
commandTree *internalcobra.Command routingRules []RoutingRule
configRegistered bool aliases *alias.Resolver
flagAliases map[string]resolvedFlagAliases
discoveryFlags map[string]resolvedFlagAliases
snapshot *routingSnapshot
}
type routingSnapshot struct {
metadata *commandmeta.Command
runners map[commandmeta.Key]commandPayload
aliases *alias.Resolver
canonical map[string]*canonicalNode
commandAliases []commandmeta.CommandAlias
flagAliases map[string]resolvedFlagAliases
discoveryFlags map[string]resolvedFlagAliases
} }
type commandNode struct { type commandNode struct {
command Command command Command
reserved bool
} }
// New creates an application with the supplied identity and initial commands. // New creates an application with the supplied identity and initial canonical
// The identity name must not be empty. The root help and version names are // commands and routing rules. Use []Declaration to expand a mixed declaration
// reserved for generated commands. // collection; []Command cannot be expanded as ...Declaration without
func New(identity Identity, commands ...Command) (*App, error) { // converting its elements.
//
// The identity name must not be empty. New installs its generated help and
// version commands and caller declarations as one batch; an invalid, nil, or
// zero declaration returns an error without exposing an App. Factory issues
// retained in Command and RoutingRule values are reported during installation.
// Identical duplicate routing mappings are accepted; conflicting mappings
// fail the batch. The root help and version names are reserved for generated
// commands.
func New(identity Identity, declarations ...Declaration) (*App, error) {
if identity.Name == "" { if identity.Name == "" {
return nil, fmt.Errorf("application identity name must not be empty") return nil, fmt.Errorf("application identity name must not be empty")
} }
app := &App{ app := &App{identity: identity, commands: make(map[string]*commandNode)}
identity: identity, builtins := app.builtinCommands()
commands: map[string]*commandNode{ all := make([]Declaration, 0, len(builtins)+len(declarations))
"help": { all = append(all, builtins...)
command: Command{name: "help", summary: "Show help for commands."}, all = append(all, declarations...)
reserved: true, if err := app.installBatch(all...); err != nil {
},
"version": {
command: Command{name: "version", summary: "Show application version."},
reserved: true,
},
},
}
if err := app.installBuiltins(); err != nil {
return nil, err
}
if err := app.Register(commands...); err != nil {
return nil, err return nil, err
} }
return app, nil return app, nil

View File

@@ -1,167 +1,193 @@
package commandkit package commandkit
import ( import (
"errors"
"fmt" "fmt"
"strconv" "strconv"
) )
// ValueFlag declares a singleton flag with a typed parser, optional default, // ValueFlag declares a singleton flag with a typed parser, optional default,
// and setter. Every supplied occurrence is parsed; the last parsed value is // and setter. The default value is captured at construction. Every supplied
// applied with Set=true. // occurrence is parsed before input construction; the last parsed value is
// applied with Set=true. A malformed earlier occurrence remains an error.
func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V, error), set func(*I, Supplied[V])) Flag[I] { func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V, error), set func(*I, Supplied[V])) Flag[I] {
var fallback V var fallback V
if defaultValue != nil { if defaultValue != nil {
fallback = *defaultValue fallback = *defaultValue
} }
binding := flagBinding[I]{meta: meta, defaultText: fmt.Sprint(fallback)} binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(fallback)}
if parse == nil || set == nil { if parse == nil || set == nil {
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
binding.valid = true binding.valid = true
binding.clear = func(input *I) { set(input, Supplied[V]{}) } binding.prepare = func(values []string) (preparedValue[I], error) {
binding.prepare = func(values []string) (func(*I), error) {
value := fallback value := fallback
supplied := len(values) > 0 supplied := len(values) > 0
for _, raw := range values { for index, raw := range values {
parsed, err := parse(raw) parsed, err := parse(raw)
if err != nil { if err != nil {
return nil, err return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
} }
value = parsed value = parsed
} }
result := Supplied[V]{Value: value, Set: supplied} result := Supplied[V]{Value: value, Set: supplied}
return func(input *I) { set(input, result) }, nil return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) { set(input, result) }}, nil
} }
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
// BoolFlag declares a boolean flag. A bare flag is supplied by the parser as // BoolFlag declares a boolean flag. A bare flag is supplied by the parser as
// "true"; explicit values are converted with strconv.ParseBool. // "true"; explicit values are converted with strconv.ParseBool. Set distinguishes
// an omitted flag from an explicit false value.
func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[bool])) Flag[I] { func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[bool])) Flag[I] {
binding := flagBinding[I]{meta: meta, defaultText: fmt.Sprint(defaultValue), boolean: true} binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(defaultValue), boolean: true}
if set == nil { if set == nil {
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
binding.valid = true binding.valid = true
binding.clear = func(input *I) { set(input, Supplied[bool]{}) } binding.prepare = func(values []string) (preparedValue[I], error) {
binding.prepare = func(values []string) (func(*I), error) {
value := defaultValue value := defaultValue
supplied := len(values) > 0 supplied := len(values) > 0
for _, raw := range values { for index, raw := range values {
parsed, err := strconv.ParseBool(raw) parsed, err := strconv.ParseBool(raw)
if err != nil { if err != nil {
return nil, err return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
} }
value = parsed value = parsed
} }
result := Supplied[bool]{Value: value, Set: supplied} result := Supplied[bool]{Value: value, Set: supplied}
return func(input *I) { set(input, result) }, nil return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) { set(input, result) }}, nil
} }
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
// RepeatFlag declares a repeatable flag. Supplied occurrences replace defaults // RepeatFlag declares a repeatable flag. It captures a copy of defaults. Supplied
// and are parsed in argv order, with each occurrence treated as one value. // occurrences replace defaults and are parsed in argv order, with each
// occurrence treated as one value. The core-owned result slice is copied when
// applied to input.
func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, error), set func(*I, Supplied[[]V])) Flag[I] { func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, error), set func(*I, Supplied[[]V])) Flag[I] {
fallback := append([]V(nil), defaults...) fallback := append([]V(nil), defaults...)
binding := flagBinding[I]{meta: meta, defaultText: fmt.Sprint(fallback), repeated: true} binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(fallback), repeated: true}
if parse == nil || set == nil { if parse == nil || set == nil {
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
binding.valid = true binding.valid = true
binding.clear = func(input *I) { set(input, Supplied[[]V]{}) } binding.prepare = func(values []string) (preparedValue[I], error) {
binding.prepare = func(values []string) (func(*I), error) {
parsedValues := append([]V(nil), fallback...) parsedValues := append([]V(nil), fallback...)
supplied := len(values) > 0 supplied := len(values) > 0
if supplied { if supplied {
parsedValues = make([]V, 0, len(values)) parsedValues = make([]V, 0, len(values))
for _, raw := range values { for index, raw := range values {
parsed, err := parse(raw) parsed, err := parse(raw)
if err != nil { if err != nil {
return nil, err return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
} }
parsedValues = append(parsedValues, parsed) parsedValues = append(parsedValues, parsed)
} }
} }
return func(input *I) { return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) {
result := Supplied[[]V]{Value: append([]V(nil), parsedValues...), Set: supplied} result := Supplied[[]V]{Value: append([]V(nil), parsedValues...), Set: supplied}
set(input, result) set(input, result)
}, nil }}, nil
} }
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
// ValueArgument declares an optional or required singleton positional // ValueArgument declares an optional or required singleton positional argument.
// argument. // Required cardinality is enforced for execution, while help can be rendered
// without a supplied operand. Parsing completes before input construction.
func ValueArgument[I, V any](meta ArgumentMeta, required bool, parse func(string) (V, error), set func(*I, Supplied[V])) Argument[I] { func ValueArgument[I, V any](meta ArgumentMeta, required bool, parse func(string) (V, error), set func(*I, Supplied[V])) Argument[I] {
min := 0 min := 0
if required { if required {
min = 1 min = 1
} }
binding := argumentBinding[I]{meta: meta, min: min, max: 1} binding := argumentBinding[I]{identity: newBindingIdentity(), meta: meta, min: min, max: 1}
if parse == nil || set == nil { if parse == nil || set == nil {
return Argument[I]{binding: binding} return Argument[I]{binding: binding}
} }
binding.valid = true binding.valid = true
binding.prepare = func(values []string) (func(*I), error) { binding.prepare = func(values []string) (preparedValue[I], error) {
if len(values) > 1 { if len(values) > 1 {
return nil, fmt.Errorf("received %d values; maximum is 1", len(values)) return preparedValue[I]{}, fmt.Errorf("received %d values; maximum is 1", len(values))
} }
var value V var value V
supplied := len(values) == 1 supplied := len(values) == 1
if supplied { if supplied {
parsed, err := parse(values[0]) parsed, err := parse(values[0])
if err != nil { if err != nil {
return nil, err return preparedValue[I]{}, err
} }
value = parsed value = parsed
} }
result := Supplied[V]{Value: value, Set: supplied} result := Supplied[V]{Value: value, Set: supplied}
return func(input *I) { set(input, result) }, nil return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) { set(input, result) }}, nil
} }
return Argument[I]{binding: binding} return Argument[I]{binding: binding}
} }
// RepeatArgument declares a positional argument that accepts a bounded number // RepeatArgument declares a positional argument that accepts at least min and
// of values. A maximum of -1 means unbounded. // at most max values. A maximum of -1 means unbounded. Parsing completes before
// input construction, and the core-owned result slice is copied when applied.
func RepeatArgument[I, V any](meta ArgumentMeta, min, max int, parse func(string) (V, error), set func(*I, Supplied[[]V])) Argument[I] { func RepeatArgument[I, V any](meta ArgumentMeta, min, max int, parse func(string) (V, error), set func(*I, Supplied[[]V])) Argument[I] {
binding := argumentBinding[I]{meta: meta, min: min, max: max} binding := argumentBinding[I]{identity: newBindingIdentity(), meta: meta, min: min, max: max}
if parse == nil || set == nil { if parse == nil || set == nil {
return Argument[I]{binding: binding} return Argument[I]{binding: binding}
} }
binding.valid = true binding.valid = true
binding.prepare = func(values []string) (func(*I), error) { binding.prepare = func(values []string) (preparedValue[I], error) {
if max >= 0 && len(values) > max { if max >= 0 && len(values) > max {
return nil, fmt.Errorf("received %d values; maximum is %d", len(values), max) return preparedValue[I]{}, fmt.Errorf("received %d values; maximum is %d", len(values), max)
} }
parsedValues := make([]V, 0, len(values)) parsedValues := make([]V, 0, len(values))
for _, raw := range values { for _, raw := range values {
parsed, err := parse(raw) parsed, err := parse(raw)
if err != nil { if err != nil {
return nil, err return preparedValue[I]{}, err
} }
parsedValues = append(parsedValues, parsed) parsedValues = append(parsedValues, parsed)
} }
return func(input *I) { return preparedValue[I]{identity: binding.identity, supplied: len(values) > 0, apply: func(input *I) {
result := Supplied[[]V]{Value: append([]V(nil), parsedValues...), Set: len(values) > 0} result := Supplied[[]V]{Value: append([]V(nil), parsedValues...), Set: len(values) > 0}
set(input, result) set(input, result)
}, nil }}, nil
} }
return Argument[I]{binding: binding} return Argument[I]{binding: binding}
} }
type preparedInput[I any] struct { type preparedInput[I any] struct {
newInput func() I newInput func() I
setters []func(*I) values []preparedValue[I]
} }
type flagOccurrenceError struct {
index int
cause error
}
func (err *flagOccurrenceError) Error() string { return err.cause.Error() }
func (err *flagOccurrenceError) Unwrap() error { return err.cause }
type flagConversionError struct {
binding string
spelling string
occurrence int
cause error
}
func (err *flagConversionError) Error() string {
return fmt.Sprintf("flag %s occurrence %d: %v", err.spelling, err.occurrence, err.cause)
}
func (err *flagConversionError) Unwrap() error { return err.cause }
func (p preparedInput[I]) build() I { func (p preparedInput[I]) build() I {
var input I var input I
if p.newInput != nil { if p.newInput != nil {
input = p.newInput() input = p.newInput()
} }
for _, setter := range p.setters { for _, value := range p.values {
setter(&input) value.apply(&input)
} }
return input return input
} }
@@ -170,36 +196,64 @@ func (p preparedInput[I]) build() I {
// creates an input value. When enforceRequired is false, missing required flags // creates an input value. When enforceRequired is false, missing required flags
// and operands are allowed so metadata help can validate supplied syntax. // and operands are allowed so metadata help can validate supplied syntax.
func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) { func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
return prepareInputWithSpellings(spec, flagValues, nil, operands, enforceRequired)
}
func prepareInputWithSpellings[I any](spec CommandSpec[I], flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
if len(flagValues) != len(spec.Flags) { if len(flagValues) != len(spec.Flags) {
return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags)) return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags))
} }
setters := make([]func(*I), 0, len(spec.Flags)+len(spec.Arguments)) if flagSpellings == nil {
flagSpellings = make([][]string, len(spec.Flags))
}
if len(flagSpellings) != len(spec.Flags) {
return preparedInput[I]{}, fmt.Errorf("received %d flag spelling sets for %d bindings", len(flagSpellings), len(spec.Flags))
}
preparedFlags := make([]preparedValue[I], len(spec.Flags))
missingRequired := ""
for i, flag := range spec.Flags { for i, flag := range spec.Flags {
binding := flag.binding binding := flag.binding
if enforceRequired && binding.meta.Required && len(flagValues[i]) == 0 { value, err := binding.prepare(flagValues[i])
return preparedInput[I]{}, fmt.Errorf("required flag --%s was not supplied", binding.meta.Name)
}
setter, err := binding.prepare(flagValues[i])
if err != nil { if err != nil {
var occurrenceErr *flagOccurrenceError
if errors.As(err, &occurrenceErr) {
spelling := "--" + binding.meta.Name
if occurrenceErr.index >= 0 && occurrenceErr.index < len(flagSpellings[i]) && flagSpellings[i][occurrenceErr.index] != "" {
spelling = flagSpellings[i][occurrenceErr.index]
}
return preparedInput[I]{}, &flagConversionError{
binding: binding.meta.Name, spelling: spelling,
occurrence: occurrenceErr.index + 1, cause: occurrenceErr.cause,
}
}
return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err) return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err)
} }
setters = append(setters, setter) preparedFlags[i] = value
if enforceRequired && binding.meta.Required && !binding.deferredRequired && !value.supplied && missingRequired == "" {
missingRequired = binding.meta.Name
}
}
if missingRequired != "" {
return preparedInput[I]{}, fmt.Errorf("required flag --%s was not supplied", missingRequired)
} }
argumentValues, err := allocateOperands(operands, spec.Arguments, enforceRequired) argumentValues, err := allocateOperands(operands, spec.Arguments, enforceRequired)
if err != nil { if err != nil {
return preparedInput[I]{}, err return preparedInput[I]{}, err
} }
preparedValues := append([]preparedValue[I](nil), preparedFlags...)
for i, argument := range spec.Arguments { for i, argument := range spec.Arguments {
binding := argument.binding binding := argument.binding
setter, err := binding.prepare(argumentValues[i]) value, err := binding.prepare(argumentValues[i])
if err != nil { if err != nil {
return preparedInput[I]{}, fmt.Errorf("argument %q: %w", binding.meta.Name, err) return preparedInput[I]{}, fmt.Errorf("argument %q: %w", binding.meta.Name, err)
} }
setters = append(setters, setter) preparedValues = append(preparedValues, value)
} }
return preparedInput[I]{newInput: spec.NewInput, setters: setters}, nil return preparedInput[I]{newInput: spec.NewInput, values: preparedValues}, nil
} }
func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} }
func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) { func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {
for _, argument := range definitions { for _, argument := range definitions {
binding := argument.binding binding := argument.binding

View File

@@ -1,7 +1,9 @@
package commandkit package commandkit
import ( import (
"context"
"errors" "errors"
"io"
"reflect" "reflect"
"strconv" "strconv"
"testing" "testing"
@@ -30,7 +32,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
BoolFlag(FlagMeta{Name: "enabled"}, true, func(input *bindingInput, value Supplied[bool]) { input.enabled = value }), BoolFlag(FlagMeta{Name: "enabled"}, true, func(input *bindingInput, value Supplied[bool]) { input.enabled = value }),
}, },
} }
if _, err := NewCommand(spec); err != nil { if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
defaultMessage = "changed after declaration" defaultMessage = "changed after declaration"
@@ -104,8 +106,7 @@ func TestBoolFlagRejectsExplicitEmptyValue(t *testing.T) {
func TestRepeatFlagUsesFreshDefaultsAndWholeOccurrenceValues(t *testing.T) { func TestRepeatFlagUsesFreshDefaultsAndWholeOccurrenceValues(t *testing.T) {
defaults := []string{"base"} defaults := []string{"base"}
var captured Supplied[[]string] flag := RepeatFlag(FlagMeta{Name: "tag"}, defaults, func(value string) (string, error) { return value, nil }, func(input *bindingInput, value Supplied[[]string]) { input.tags = value })
flag := RepeatFlag(FlagMeta{Name: "tag"}, defaults, func(value string) (string, error) { return value, nil }, func(_ *bindingInput, value Supplied[[]string]) { captured = value })
defaults[0] = "changed after declaration" defaults[0] = "changed after declaration"
applyDefault, err := flag.binding.prepare(nil) applyDefault, err := flag.binding.prepare(nil)
@@ -113,25 +114,25 @@ func TestRepeatFlagUsesFreshDefaultsAndWholeOccurrenceValues(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
first := &bindingInput{} first := &bindingInput{}
applyDefault(first) applyDefault.apply(first)
second := &bindingInput{} second := &bindingInput{}
applyDefault(second) applyDefault.apply(second)
if !reflect.DeepEqual(captured.Value, []string{"base"}) || captured.Set { if !reflect.DeepEqual(first.tags.Value, []string{"base"}) || first.tags.Set {
t.Fatalf("omitted repeat value = %#v, want copied default with Set=false", captured) t.Fatalf("omitted repeat value = %#v, want copied default with Set=false", first.tags)
} }
captured.Value[0] = "changed one input" first.tags.Value[0] = "changed one input"
applyDefault(second) if !reflect.DeepEqual(second.tags.Value, []string{"base"}) {
if !reflect.DeepEqual(captured.Value, []string{"base"}) { t.Fatalf("repeat default collection was reused: %#v", second.tags.Value)
t.Fatalf("repeat default collection was reused: %#v", captured.Value)
} }
applyValues, err := flag.binding.prepare([]string{"a,b", "c"}) applyValues, err := flag.binding.prepare([]string{"a,b", "c"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
applyValues(&bindingInput{}) third := &bindingInput{}
if !reflect.DeepEqual(captured.Value, []string{"a,b", "c"}) || !captured.Set { applyValues.apply(third)
t.Fatalf("supplied repeat values = %#v, want two ordered occurrences", captured) if !reflect.DeepEqual(third.tags.Value, []string{"a,b", "c"}) || !third.tags.Set {
t.Fatalf("supplied repeat values = %#v, want two ordered occurrences", third.tags)
} }
} }
@@ -149,6 +150,46 @@ func TestSingletonParsesEveryOccurrenceAndPreservesCallerCause(t *testing.T) {
} }
} }
func TestPreparedScalarConvertsEveryOccurrenceOnceBeforeInputCreation(t *testing.T) {
parseCalls := 0
var received Supplied[string]
flag := ValueFlag(FlagMeta{Name: "value"}, (*string)(nil), func(value string) (string, error) {
parseCalls++
return value, nil
}, func(input *bindingInput, value Supplied[string]) { input.message = value })
prepared, err := flag.binding.prepare([]string{"earlier", "last"})
if err != nil {
t.Fatal(err)
}
if parseCalls != 2 || prepared.identity != flag.binding.identity || !prepared.supplied {
t.Fatalf("prepared scalar = %#v, parse calls = %d", prepared, parseCalls)
}
copy := flag
lookalike := ValueFlag(FlagMeta{Name: "value"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*bindingInput, Supplied[string]) {})
if copy.binding.identity != flag.binding.identity || lookalike.binding.identity == flag.binding.identity {
t.Fatal("binding identity was not preserved by copying or distinct across constructors")
}
input := &bindingInput{}
prepared.apply(input)
received = input.message
if parseCalls != 2 || received.Value != "last" || !received.Set {
t.Fatalf("applied value = %#v, parse calls = %d", received, parseCalls)
}
}
func TestInvalidBindingConstructionIsReportedAtInstallation(t *testing.T) {
badFlag := ValueFlag[struct{}, string](FlagMeta{Name: "broken"}, nil, nil, func(*struct{}, Supplied[string]) {})
badArgument := RepeatArgument[struct{}, string](ArgumentMeta{Name: "items"}, 2, 1, func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[[]string]) {})
command := NewCommand(CommandSpec[struct{}]{Name: "run", Flags: []Flag[struct{}]{badFlag}, Arguments: []Argument[struct{}]{badArgument}})
app, err := New(Identity{Name: "example"}, command)
if err == nil {
t.Fatal("installation accepted nil parser wiring and invalid argument cardinality")
}
if app != nil {
t.Fatal("failed installation returned a usable app")
}
}
func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) { func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) {
type operandsInput struct { type operandsInput struct {
prefix Supplied[[]string] prefix Supplied[[]string]
@@ -163,7 +204,7 @@ func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) {
RepeatArgument(ArgumentMeta{Name: "suffix"}, 1, -1, func(value string) (string, error) { return value, nil }, func(input *operandsInput, value Supplied[[]string]) { input.suffix = value }), RepeatArgument(ArgumentMeta{Name: "suffix"}, 1, -1, func(value string) (string, error) { return value, nil }, func(input *operandsInput, value Supplied[[]string]) { input.suffix = value }),
}, },
} }
if _, err := NewCommand(spec); err != nil { if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
prepared, err := prepareInput(spec, nil, []string{"a", "b", "id-1", "x", "y"}, true) prepared, err := prepareInput(spec, nil, []string{"a", "b", "id-1", "x", "y"}, true)
@@ -193,7 +234,7 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "required", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})}, Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "required", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})},
Arguments: []Argument[struct{}]{ValueArgument(ArgumentMeta{Name: "id"}, true, func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})}, Arguments: []Argument[struct{}]{ValueArgument(ArgumentMeta{Name: "id"}, true, func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})},
} }
if _, err := NewCommand(spec); err != nil { if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := prepareInput(spec, [][]string{nil}, nil, true); err == nil { if _, err := prepareInput(spec, [][]string{nil}, nil, true); err == nil {
@@ -206,3 +247,90 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
t.Fatalf("preparation invoked NewInput %d times", factoryCalls) t.Fatalf("preparation invoked NewInput %d times", factoryCalls)
} }
} }
func TestExecuteBindsMixedFlagSpellingsWithFreshPresence(t *testing.T) {
type input struct {
count Supplied[int]
enabled Supplied[bool]
message Supplied[string]
tags Supplied[[]string]
required Supplied[string]
instance int
}
type observed struct {
count Supplied[int]
enabled Supplied[bool]
message Supplied[string]
tags Supplied[[]string]
required Supplied[string]
instance int
}
defaultCount := 9
defaultMessage := "fallback"
defaultTags := []string{"base"}
countParses, inputFactories := 0, 0
var calls []observed
command := NewCommand(CommandSpec[input]{
Name: "run",
NewInput: func() input {
inputFactories++
return input{instance: inputFactories}
},
Flags: []Flag[input]{
ValueFlag(FlagMeta{Name: "count"}, &defaultCount, func(value string) (int, error) {
countParses++
return strconv.Atoi(value)
}, func(target *input, value Supplied[int]) { target.count = value }),
BoolFlag(FlagMeta{Name: "enabled"}, true, func(target *input, value Supplied[bool]) { target.enabled = value }),
ValueFlag(FlagMeta{Name: "message"}, &defaultMessage, func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[string]) { target.message = value }),
RepeatFlag(FlagMeta{Name: "tag"}, defaultTags, func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[[]string]) { target.tags = value }),
ValueFlag(FlagMeta{Name: "required", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[string]) { target.required = value }),
},
Run: func(_ Invocation, value input) Outcome {
value.tags.Value = append([]string(nil), value.tags.Value...)
calls = append(calls, observed(value))
return Success()
},
})
app, err := New(Identity{Name: "Example"}, command,
Alias("shortcut", "run"),
FlagAliasFor("run", "--number", "--count"),
FlagAliasFor("run", "--active", "--enabled"),
FlagAliasFor("run", "--text", "--message"),
FlagAliasFor("run", "--label", "--tag"),
)
if err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"shortcut", "--number=0", "--count=5", "--active=false", "--enabled=true", "--text=", "--message=last", "--label=a", "--tag=b", "--required=first"},
{"run", "--number=0", "--active=false", "--text=", "--label=c", "--required=second"},
} {
if outcome := app.Execute(context.Background(), args, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess {
t.Fatalf("Execute(%q) = %#v, want success", args, outcome)
}
}
if countParses != 3 {
t.Fatalf("count conversions = %d, want every one of three occurrences", countParses)
}
if len(calls) != 2 || calls[0].count != (Supplied[int]{Value: 5, Set: true}) || calls[0].enabled != (Supplied[bool]{Value: true, Set: true}) || calls[0].message != (Supplied[string]{Value: "last", Set: true}) || !reflect.DeepEqual(calls[0].tags, Supplied[[]string]{Value: []string{"a", "b"}, Set: true}) || calls[0].required != (Supplied[string]{Value: "first", Set: true}) {
t.Fatalf("first mixed-spelling invocation = %#v", calls)
}
if calls[1].count != (Supplied[int]{Value: 0, Set: true}) || calls[1].enabled != (Supplied[bool]{Value: false, Set: true}) || calls[1].message != (Supplied[string]{Value: "", Set: true}) || !reflect.DeepEqual(calls[1].tags, Supplied[[]string]{Value: []string{"c"}, Set: true}) || calls[1].required != (Supplied[string]{Value: "second", Set: true}) || calls[0].instance == calls[1].instance {
t.Fatalf("second mixed-spelling invocation = %#v", calls[1])
}
if outcome := app.Execute(context.Background(), []string{"shortcut", "--count=7"}, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeUsage {
t.Fatalf("missing required flag outcome = %#v, want usage", outcome)
}
if inputFactories != 2 || len(calls) != 2 {
t.Fatalf("missing required flag created or ran input: factories=%d calls=%d", inputFactories, len(calls))
}
if outcome := app.Execute(context.Background(), []string{"shortcut", "--required=defaults"}, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess {
t.Fatalf("defaulted invocation = %#v, want success", outcome)
}
if len(calls) != 3 || calls[2].count != (Supplied[int]{Value: 9}) || calls[2].enabled != (Supplied[bool]{Value: true}) || calls[2].message != (Supplied[string]{Value: "fallback"}) || !reflect.DeepEqual(calls[2].tags, Supplied[[]string]{Value: []string{"base"}}) || calls[2].required != (Supplied[string]{Value: "defaults", Set: true}) || calls[2].instance == calls[1].instance {
t.Fatalf("omitted values reused prior invocation state: %#v", calls[2])
}
}

View File

@@ -1,14 +1,12 @@
package commandkit package commandkit
import ( import (
"bytes"
"errors"
"fmt" "fmt"
"io" "io"
"runtime/debug" "runtime/debug"
"strings"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
yaml "go.yaml.in/yaml/v4" yaml "go.yaml.in/yaml/v4"
) )
@@ -29,24 +27,20 @@ type versionDocument struct {
Revision string `yaml:"revision"` Revision string `yaml:"revision"`
} }
func (a *App) installBuiltins() error { func (a *App) builtinCommands() []Declaration {
help, err := NewCommand(CommandSpec[helpInput]{ help := captureCommand(CommandSpec[helpInput]{
Name: "help", Name: "help",
Summary: "Show help for commands.", Summary: "Show help for commands.",
Description: "Describe registered commands and their inputs.", Description: "Describe registered commands and their inputs.",
Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "registered command path"}, 0, -1, Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "registered command path"}, 0, -1,
func(value string) (string, error) { return value, nil }, func(value string) (string, error) { return value, nil },
func(input *helpInput, value Supplied[[]string]) { input.Targets = value })}, func(input *helpInput, value Supplied[[]string]) { input.Targets = value })},
Run: func(invocation Invocation, input helpInput) Outcome { }, func(current commandExecution, input helpInput) Outcome {
return a.renderHelpTargets(input.Targets.Value, invocation.Streams) return current.renderHelpTargets(input.Targets.Value)
},
}) })
if err != nil {
return fmt.Errorf("create built-in help command: %w", err)
}
scopeDefault := "app" scopeDefault := "app"
formatDefault := "text" formatDefault := "text"
version, err := NewCommand(CommandSpec[versionInput]{ version := NewCommand(CommandSpec[versionInput]{
Name: "version", Name: "version",
Summary: "Show application or CommandKit version.", Summary: "Show application or CommandKit version.",
Description: "Report identity and build metadata without loading application configuration.", Description: "Report identity and build metadata without loading application configuration.",
@@ -62,15 +56,10 @@ func (a *App) installBuiltins() error {
if identity.Version == "" { if identity.Version == "" {
identity.Version = "dev" identity.Version = "dev"
} }
return renderVersionOutcome(identity, input.Format, invocation.Streams) return renderVersionOutcome(identity, input.Format, invocation.Reporter())
}, },
}) })
if err != nil { return []Declaration{help, version}
return fmt.Errorf("create built-in version command: %w", err)
}
a.commands["help"] = &commandNode{command: help, reserved: true}
a.commands["version"] = &commandNode{command: version, reserved: true}
return nil
} }
func parseVersionScope(value string) (string, error) { func parseVersionScope(value string) (string, error) {
@@ -87,67 +76,41 @@ func parseVersionFormat(value string) (string, error) {
return value, nil return value, nil
} }
func (a *App) renderHelpOutcome(path []string, streams Streams) Outcome { func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, reporter *Reporter, session *internalcobra.Session) Outcome {
session := internalcobra.Prepare(a.bridgeTree(), internalcobra.ParseOptions{}) content, failure := session.RenderHelpView(view)
return a.renderHelpOutcomeWithSession(path, streams, session)
}
func (a *App) renderHelpTargets(targets []string, streams Streams) Outcome {
session := internalcobra.Prepare(a.bridgeTree(), internalcobra.ParseOptions{})
return a.renderHelpTargetsWithSession(targets, streams, session)
}
func (a *App) renderHelpTargetsWithSession(targets []string, streams Streams, session *internalcobra.Session) Outcome {
target, err := a.helpCommandTarget(targets)
if err != nil {
return Usage(err)
}
return a.renderHelpOutcomeWithSession(target, streams, session)
}
func (a *App) renderHelpOutcomeWithSession(path []string, streams Streams, session *internalcobra.Session) Outcome {
if len(path) > 0 {
if _, ok := a.commandAt(path); !ok {
return Usage(fmt.Errorf("unknown help path %q", strings.Join(path, " ")))
}
}
content, failure := session.RenderHelp(path)
if failure != nil { if failure != nil {
return Failure(errors.New(failure.Message)) if failure.Usage {
} return Usage(failure.Message)
if err := writeReport(streams.Out, []byte(content)); err != nil { }
return incompleteReport(err) return Failure(failure.Message)
} }
_ = reporter.RenderOutput(func(w io.Writer) error {
_, err := io.WriteString(w, content)
return err
})
return Success() return Success()
} }
func renderVersionOutcome(identity Identity, format string, streams Streams) Outcome { func renderVersionOutcome(identity Identity, format string, reporter *Reporter) Outcome {
var content []byte if identity.Version == "" {
identity.Version = "dev"
}
switch format { switch format {
case "text": case "text":
name := identity.Name line := identity.Name + " " + identity.Version
if identity.Version == "" {
identity.Version = "dev"
}
line := name + " " + identity.Version
if identity.Revision != "" { if identity.Revision != "" {
line += " (" + identity.Revision + ")" line += " (" + identity.Revision + ")"
} }
content = []byte(line + "\n") _ = reporter.RenderOutput(func(w io.Writer) error {
_, err := io.WriteString(w, line+"\n")
return err
})
case "yaml": case "yaml":
if identity.Version == "" { _ = reporter.RenderOutput(func(w io.Writer) error {
identity.Version = "dev" return encodeYAML(w, versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision})
} })
var buffer bytes.Buffer
if err := encodeYAML(&buffer, versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision}); err != nil {
return Failure(err)
}
content = buffer.Bytes()
default: default:
return Usage(fmt.Errorf("unsupported version format %q", format)) return Usage(fmt.Sprintf("unsupported version format %q", format))
}
if err := writeReport(streams.Out, content); err != nil {
return incompleteReport(err)
} }
return Success() return Success()
} }
@@ -158,22 +121,6 @@ func encodeYAML(writer io.Writer, document any) error {
return encoder.Encode(document) return encoder.Encode(document)
} }
func writeReport(writer io.Writer, content []byte) error {
checked := &checkedWriter{writer: writer}
_, err := checked.Write(content)
if err == nil {
err = checked.failure
}
return err
}
func incompleteReport(err error) Outcome {
outcome := Failure(err)
outcome.ReportIncomplete = true
outcome.DeliveryError = err
return outcome
}
func readBuildInfo() *debug.BuildInfo { func readBuildInfo() *debug.BuildInfo {
info, ok := debug.ReadBuildInfo() info, ok := debug.ReadBuildInfo()
if !ok { if !ok {
@@ -210,13 +157,3 @@ func commandKitBuildIdentity(info *debug.BuildInfo) Identity {
} }
return identity return identity
} }
func (a *App) helpCommandTarget(targets []string) ([]string, error) {
if len(targets) == 0 {
return nil, nil
}
if _, ok := a.commandAt(targets); !ok {
return nil, fmt.Errorf("unknown help path %q", strings.Join(targets, " "))
}
return append([]string(nil), targets...), nil
}

110
builtins_reporting_test.go Normal file
View File

@@ -0,0 +1,110 @@
package commandkit
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
)
func builtinReportingTestApp(t *testing.T) *App {
t.Helper()
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "Example", Version: "1.2.3"}, command,
Group("promptkit", "PromptKit", Group("list", "List", simpleRunnable("collections"))),
Alias("r", "run"), Alias("list collections", "promptkit list collections"),
)
if err != nil {
t.Fatal(err)
}
return app
}
func TestHelpAndVersionUseManagedOutput(t *testing.T) {
app := builtinReportingTestApp(t)
for _, args := range [][]string{{"help", "--quiet"}, {"version", "--quiet"}, {"list", "--help", "--quiet"}} {
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeSuccess || outcome.Status != 0 {
t.Errorf("Execute(%q) = %#v", args, outcome)
}
if output.Len() != 0 || diagnostics.Len() != 0 {
t.Errorf("Execute(%q) wrote output %q or diagnostics %q", args, output.String(), diagnostics.String())
}
if outcome.Presentation.Output.State != DeliverySuppressed {
t.Errorf("Execute(%q) output presentation = %#v", args, outcome.Presentation.Output)
}
}
for _, test := range []struct {
args []string
want string
}{
{args: []string{"help", "run"}, want: "run"},
{args: []string{"help", "r"}, want: "Alias for: Example run"},
{args: []string{"list", "--help"}, want: "Reporting Flags"},
{args: []string{"version"}, want: "Example 1.2.3"},
{args: []string{"version", "--format=yaml"}, want: "version: 1.2.3"},
} {
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), test.args, Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeSuccess || !strings.Contains(output.String(), test.want) {
t.Errorf("Execute(%q) = %#v, output %q", test.args, outcome, output.String())
}
}
}
func TestHelpAndVersionDestinationFailuresUseReporterObservations(t *testing.T) {
app := builtinReportingTestApp(t)
writeErr := errors.New("help/version destination failed")
for _, args := range [][]string{{"help", "run"}, {"version"}} {
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), args, Streams{
Out: executeReportingFailureWriter{err: writeErr}, Err: &diagnostics,
})
if outcome.Kind != OutcomeOperational || outcome.Status != 1 {
t.Errorf("Execute(%q) = %#v", args, outcome)
}
if !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Presentation.Output.State != DeliveryIncomplete {
t.Errorf("Execute(%q) presentation = %#v", args, outcome.Presentation)
}
if !strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output") {
t.Errorf("Execute(%q) diagnostics = %q", args, diagnostics.String())
}
}
}
func TestInvalidHelpAndVersionRequestsRemainUsageOutcomes(t *testing.T) {
app := builtinReportingTestApp(t)
for _, args := range [][]string{{"help", "unknown"}, {"version", "--scope=unknown"}, {"version", "--format=unknown"}} {
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeUsage || outcome.Status != 2 {
t.Errorf("Execute(%q) = %#v", args, outcome)
}
if !strings.Contains(diagnostics.String(), "Usage: Example") {
t.Errorf("Execute(%q) diagnostics = %q", args, diagnostics.String())
}
}
}
func TestQuietBuiltinRenderingSuppressesDestinationWrites(t *testing.T) {
app := builtinReportingTestApp(t)
// A quiet successful run must not touch its supplied result destination.
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"version", "--quiet"}, Streams{
Out: executeReportingFailureWriter{err: io.ErrClosedPipe}, Err: &diagnostics,
})
if outcome.Kind != OutcomeSuccess || outcome.Presentation.Output.State != DeliverySuppressed {
t.Fatalf("quiet version outcome = %#v", outcome)
}
if diagnostics.Len() != 0 {
t.Fatalf("quiet version diagnostics = %q", diagnostics.String())
}
}

View File

@@ -1,120 +0,0 @@
package commandkit_test
import (
"bytes"
"context"
"errors"
"fmt"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/promptkit"
"strings"
"testing"
)
func TestStandardConfigurationCancellationBoundaries(t *testing.T) {
for _, operation := range []string{"validate", "show", "sources", "explain"} {
for _, before := range []bool{true, false} {
t.Run(fmt.Sprintf("%s/before=%t", operation, before), func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if before {
cancel()
}
loads, contexts := 0, 0
scope, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[struct{}], error) {
loads++
cancel()
return configkit.Load(configkit.NoSource(), configkit.Options[struct{}]{})
})
if err != nil {
t.Fatal(err)
}
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}, NewContext: func() struct{} { contexts++; return struct{}{} }}); err != nil {
t.Fatal(err)
}
args := []string{"config", operation}
if operation == "explain" {
args = append(args, "--path", "")
}
var out bytes.Buffer
result := app.Execute(ctx, args, commandkit.Streams{Out: &out})
want := 1
if before {
want = 0
}
if result.Kind != commandkit.OutcomeOperational || result.Status != 1 || !errors.Is(result.Cause, context.Canceled) || loads != want || contexts != want || out.Len() != 0 {
t.Fatalf("result=%#v loads=%d contexts=%d out=%q", result, loads, contexts, out.String())
}
})
}
}
}
func TestAggregateCancellationRetainsCompletedValidationAndDiagnostics(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
calls := 0
acquire := func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
calls++
if calls == 2 {
cancel()
}
return commandkit.Acquired[commandkit.ConfigView]{Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(configkit.SeverityWarning, "acquired warning")}}, nil
}
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
{Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
{Name: "zeta", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
}}); err != nil {
t.Fatal(err)
}
var out, diagnostics bytes.Buffer
result := app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &out, Err: &diagnostics})
if !errors.Is(result.Cause, context.Canceled) || !result.ReportIncomplete || calls != 2 || out.String() != "app: valid\n" || strings.Count(diagnostics.String(), "acquired warning") != 2 {
t.Fatalf("result=%#v calls=%d out=%q diagnostics=%q", result, calls, out.String(), diagnostics.String())
}
}
func TestDefinitionInspectionCancellationBoundaries(t *testing.T) {
for _, resource := range []string{"prompt", "profile"} {
for _, before := range []bool{true, false} {
t.Run(fmt.Sprintf("%s/before=%t", resource, before), func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if before {
cancel()
}
calls, contexts := 0, 0
diagnostics := []commandkit.Diagnostic{commandkit.MessageDiagnostic(configkit.SeverityWarning, "acquired warning")}
suite := commandkit.PromptKitSuite[struct{}]{NewContext: func() struct{} { contexts++; return struct{}{} },
InspectPrompt: func(context.Context, commandkit.Selection, struct{}, string, string) (commandkit.Acquired[*promptkit.PromptInspection], error) {
calls++
cancel()
return commandkit.Acquired[*promptkit.PromptInspection]{Value: &promptkit.PromptInspection{}, Diagnostics: diagnostics}, nil
},
InspectProfile: func(context.Context, commandkit.Selection, struct{}, string) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
calls++
cancel()
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: &promptkit.ProfileInspection{}, Diagnostics: diagnostics}, nil
},
}
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterPromptKit(app, suite); err != nil {
t.Fatal(err)
}
var out, errs bytes.Buffer
result := app.Execute(ctx, []string{"promptkit", "show", resource, "example"}, commandkit.Streams{Out: &out, Err: &errs})
want := 1
if before {
want = 0
}
if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, context.Canceled) || calls != want || contexts != want || out.Len() != 0 || (!before && !strings.Contains(errs.String(), "acquired warning")) {
t.Fatalf("result=%#v calls=%d contexts=%d out=%q diagnostics=%q", result, calls, contexts, out.String(), errs.String())
}
})
}
}
}

View File

@@ -1,24 +1,85 @@
package commandkit package commandkit
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"unicode" "unicode"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
) )
// Command is an opaque command declaration. Use NewCommand or Group to create // Command is an opaque, reusable declaration of canonical commands and groups.
// declarations and WithChildren to compose them. // NewCommand, Group, RootGroup, and integration command factories return
type Command struct { // Command values. Capture copies declaration slices but retains callback and
// caller-owned object references. Installation validates the declaration
// before changing an App registry.
type Command struct{ record *commandRecord }
// Declaration is a captured canonical command or application routing rule
// accepted by New and App.Register. Use []Declaration when collecting both
// kinds. Group and RootGroup children remain Commands; install routing rules
// alongside the completed command tree through New or Register. Only
// CommandKit declaration types implement Declaration.
//
// A zero Command, zero RoutingRule, nil declaration, or typed nil declaration
// pointer is invalid. Use NoCommand to represent an intentionally absent
// optional command.
type Declaration interface {
commandkitDeclaration()
}
func (Command) commandkitDeclaration() {}
// RoutingRule is an opaque, reusable command or flag alias declaration. Alias,
// FlagAlias, and FlagAliasFor capture routing rules eagerly and retain any
// construction issues for New or App.Register to report. Identical duplicate
// mappings are accepted; competing targets for the same source are errors.
type RoutingRule struct{ record *routingRuleRecord }
func (RoutingRule) commandkitDeclaration() {}
type routingRuleKind uint8
const (
commandAliasRule routingRuleKind = iota + 1
globalFlagAliasRule
scopedFlagAliasRule
)
type capturedFlagSpelling struct {
name string
short bool
}
type routingRuleRecord struct {
kind routingRuleKind
sourcePath commandpath.Path
targetPath commandpath.Path
scopePath commandpath.Path
sourceFlag capturedFlagSpelling
targetFlag capturedFlagSpelling
sourceText string
targetText string
scopeText string
issues []error
}
type commandRecord struct {
name string name string
summary string summary string
description string description string
children []Command children []Command
payload commandPayload payload commandPayload
issues []error
rootOnly bool
absent bool
invalid bool
} }
// Supplied carries a value and whether it was explicitly provided. // Supplied carries a value and whether it was explicitly provided. Defaults can
// have Set=false; an explicitly supplied empty, false, or zero value has Set=true.
type Supplied[T any] struct { type Supplied[T any] struct {
// Value is the associated value. // Value is the associated value.
Value T Value T
@@ -26,7 +87,8 @@ type Supplied[T any] struct {
Set bool Set bool
} }
// FlagMeta describes a command flag. // FlagMeta describes a command flag. Required applies when execution prepares
// the selected command; help reports the metadata without requiring the value.
type FlagMeta struct { type FlagMeta struct {
// Name is the flag name without leading dashes. // Name is the flag name without leading dashes.
Name string Name string
@@ -38,7 +100,8 @@ type FlagMeta struct {
Required bool Required bool
} }
// ArgumentMeta describes a positional command argument. // ArgumentMeta describes a positional command argument. Its name and usage are
// used in generated help.
type ArgumentMeta struct { type ArgumentMeta struct {
// Name is the argument name shown in command help. // Name is the argument name shown in command help.
Name string Name string
@@ -46,19 +109,30 @@ type ArgumentMeta struct {
Usage string Usage string
} }
// Flag represents a typed binding for a command flag. // Flag represents a typed binding for a command flag. Copies retain binding
// identity; separately constructed flags are distinct even when their metadata
// and callbacks are equivalent.
type Flag[I any] struct { type Flag[I any] struct {
binding flagBinding[I] binding flagBinding[I]
} }
type bindingIdentity struct{ marker byte }
type preparedValue[I any] struct {
identity *bindingIdentity
supplied bool
apply func(*I)
}
type flagBinding[I any] struct { type flagBinding[I any] struct {
meta FlagMeta identity *bindingIdentity
defaultText string meta FlagMeta
repeated bool deferredRequired bool
boolean bool defaultText string
prepare func([]string) (func(*I), error) repeated bool
clear func(*I) boolean bool
valid bool prepare func([]string) (preparedValue[I], error)
valid bool
} }
// Argument represents a typed binding for a positional command argument. // Argument represents a typed binding for a positional command argument.
@@ -67,14 +141,19 @@ type Argument[I any] struct {
} }
type argumentBinding[I any] struct { type argumentBinding[I any] struct {
meta ArgumentMeta identity *bindingIdentity
min int meta ArgumentMeta
max int min int
prepare func([]string) (func(*I), error) max int
valid bool prepare func([]string) (preparedValue[I], error)
valid bool
} }
// CommandSpec declares one command using typed input and handler callbacks. // CommandSpec declares one command using typed input and handler callbacks.
// Capture copies its Flags, Arguments, and Children slices. NewInput runs only
// when execution reaches input construction; a nil factory yields a fresh zero
// value. CheckInput runs after supplied values are converted and applied, before
// Run. Run may be nil for a parent that only groups child commands.
type CommandSpec[I any] struct { type CommandSpec[I any] struct {
// Name is one command path component. // Name is one command path component.
Name string Name string
@@ -82,6 +161,8 @@ type CommandSpec[I any] struct {
Summary string Summary string
// Description provides additional command detail. // Description provides additional command detail.
Description string Description string
// Children are relative child declarations captured with this command.
Children []Command
// NewInput creates an input value for execution. A nil factory uses a fresh // NewInput creates an input value for execution. A nil factory uses a fresh
// zero value. // zero value.
NewInput func() I NewInput func() I
@@ -98,15 +179,14 @@ type CommandSpec[I any] struct {
type commandPayload interface { type commandPayload interface {
validate() error validate() error
hasRun() bool hasRun() bool
bridgeFlags() []internalcobra.Flag bridgeFlags() []commandmeta.Flag
bridgeArguments() []internalcobra.Argument bridgeArguments() []commandmeta.Argument
prepare([][]string, []string, bool) (func() any, error) prepare([][]string, [][]string, []string, bool) (func(*execution) Outcome, error)
checkInput(any) error
run(Invocation, any) Outcome
} }
type typedCommand[I any] struct { type typedCommand[I any] struct {
spec CommandSpec[I] spec CommandSpec[I]
privateRun func(commandExecution, I) Outcome
} }
func (c typedCommand[I]) validate() error { func (c typedCommand[I]) validate() error {
@@ -114,14 +194,14 @@ func (c typedCommand[I]) validate() error {
} }
func (c typedCommand[I]) hasRun() bool { func (c typedCommand[I]) hasRun() bool {
return c.spec.Run != nil return c.spec.Run != nil || c.privateRun != nil
} }
func (c typedCommand[I]) bridgeFlags() []internalcobra.Flag { func (c typedCommand[I]) bridgeFlags() []commandmeta.Flag {
flags := make([]internalcobra.Flag, len(c.spec.Flags)) flags := make([]commandmeta.Flag, len(c.spec.Flags))
for index, flag := range c.spec.Flags { for index, flag := range c.spec.Flags {
binding := flag.binding binding := flag.binding
flags[index] = internalcobra.Flag{ flags[index] = commandmeta.Flag{
Name: binding.meta.Name, Name: binding.meta.Name,
Short: binding.meta.Short, Short: binding.meta.Short,
Usage: binding.meta.Usage, Usage: binding.meta.Usage,
@@ -134,11 +214,11 @@ func (c typedCommand[I]) bridgeFlags() []internalcobra.Flag {
return flags return flags
} }
func (c typedCommand[I]) bridgeArguments() []internalcobra.Argument { func (c typedCommand[I]) bridgeArguments() []commandmeta.Argument {
arguments := make([]internalcobra.Argument, len(c.spec.Arguments)) arguments := make([]commandmeta.Argument, len(c.spec.Arguments))
for index, argument := range c.spec.Arguments { for index, argument := range c.spec.Arguments {
binding := argument.binding binding := argument.binding
arguments[index] = internalcobra.Argument{ arguments[index] = commandmeta.Argument{
Name: binding.meta.Name, Name: binding.meta.Name,
Usage: binding.meta.Usage, Usage: binding.meta.Usage,
Min: binding.min, Min: binding.min,
@@ -148,124 +228,236 @@ func (c typedCommand[I]) bridgeArguments() []internalcobra.Argument {
return arguments return arguments
} }
func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enforceRequired bool) (func() any, error) { func (c typedCommand[I]) prepare(flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (func(*execution) Outcome, error) {
prepared, err := prepareInput(c.spec, flagValues, operands, enforceRequired) prepared, err := prepareInputWithSpellings(c.spec, flagValues, flagSpellings, operands, enforceRequired)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return func() any { return func(current *execution) Outcome {
input := prepared.build() input := prepared.build()
return &input if c.spec.CheckInput != nil {
if err := c.spec.CheckInput(input); err != nil {
return classifyInputError(err)
}
}
if c.privateRun != nil {
return c.privateRun(current.commandContext(), input)
}
return c.spec.Run(current.invocation(), input)
}, nil }, nil
} }
func (c typedCommand[I]) run(invocation Invocation, input any) Outcome { // NewCommand captures a typed canonical command declaration without
return c.spec.Run(invocation, *input.(*I)) // constructing its input or running callbacks. It copies the spec's flags,
// arguments, and children slices. Construction issues are retained and
// reported when the declaration is installed; NewCommand itself does not
// return an error. The returned declaration can be reused across Apps.
func NewCommand[I any](spec CommandSpec[I]) Command {
return captureCommand(spec, nil)
} }
func (c typedCommand[I]) checkInput(input any) error { func captureCommand[I any](spec CommandSpec[I], privateRun func(commandExecution, I) Outcome) Command {
if c.spec.CheckInput == nil {
return nil
}
return c.spec.CheckInput(*input.(*I))
}
// NewCommand validates and creates a typed command declaration. It retains
// callbacks without invoking them and copies the declarative flag and argument
// slices.
func NewCommand[I any](spec CommandSpec[I]) (Command, error) {
if err := validateCommandName(spec.Name); err != nil {
return Command{}, err
}
copySpec := spec copySpec := spec
copySpec.Flags = append([]Flag[I](nil), spec.Flags...) copySpec.Flags = append([]Flag[I](nil), spec.Flags...)
copySpec.Arguments = append([]Argument[I](nil), spec.Arguments...) copySpec.Arguments = append([]Argument[I](nil), spec.Arguments...)
payload := typedCommand[I]{spec: copySpec} copySpec.Children = append([]Command(nil), spec.Children...)
payload := typedCommand[I]{spec: copySpec, privateRun: privateRun}
record := &commandRecord{
name: spec.Name, summary: spec.Summary, description: spec.Description,
children: copySpec.Children, payload: payload,
}
if err := payload.validate(); err != nil { if err := payload.validate(); err != nil {
return Command{}, fmt.Errorf("command %q: %w", spec.Name, err) record.issues = append(record.issues, fmt.Errorf("command %q: %w", spec.Name, err))
} }
return Command{ captureComposition(record)
name: spec.Name, return Command{record: record}
summary: spec.Summary,
description: spec.Description,
payload: payload,
}, nil
} }
// Group creates a non-runnable command group with the supplied children. // Group captures a non-runnable canonical command group and a copy of its
func Group(name, summary string, children ...Command) (Command, error) { // relative Command child slice. Child declarations remain reusable and are not
if err := validateCommandName(name); err != nil { // recursively copied. Routing rules cannot be children; install them alongside
return Command{}, err // the root command tree through New or App.Register.
} func Group(name, summary string, children ...Command) Command {
group := Command{name: name, summary: summary, children: cloneCommands(children)} record := &commandRecord{name: name, summary: summary, children: append([]Command(nil), children...)}
if err := validateCommandDeclaration(group); err != nil { captureComposition(record)
return Command{}, err return Command{record: record}
}
return group, nil
} }
// WithChildren returns a copy of parent with the supplied relative child // RootGroup captures a group that may only be installed at the application
// declarations. The parent's optional handler and input bindings are retained. // root. Installation rejects it below any other command, including through
func WithChildren(parent Command, children ...Command) (Command, error) { // wrappers. Its children are canonical Commands; install routing rules at the
if err := validateCommandDeclaration(parent); err != nil { // application root separately.
return Command{}, fmt.Errorf("parent command: %w", err) func RootGroup(name, summary string, children ...Command) Command {
} command := Group(name, summary, children...)
combined := make([]Command, 0, len(parent.children)+len(children)) command.record.rootOnly = true
combined = append(combined, parent.children...)
combined = append(combined, children...)
result := parent
result.children = cloneCommands(combined)
if err := validateCommandDeclaration(result); err != nil {
return Command{}, err
}
return result, nil
}
func cloneCommands(commands []Command) []Command {
if len(commands) == 0 {
return nil
}
cloned := make([]Command, len(commands))
for i, command := range commands {
cloned[i] = cloneCommand(command)
}
return cloned
}
func cloneCommand(command Command) Command {
command.children = cloneCommands(command.children)
return command return command
} }
func validateCommandDeclaration(command Command) error { // WithError returns a new Command declaration that also retains a non-nil err
if err := validateCommandName(command.name); err != nil { // for installation. Earlier declaration and child errors remain available,
return err // and wrapped error identity is preserved. A nil err returns command unchanged.
func WithError(command Command, err error) Command {
if err == nil {
return command
} }
if command.payload != nil { var record commandRecord
if err := command.payload.validate(); err != nil { if command.record == nil {
return fmt.Errorf("command %q: %w", command.name, err) record.invalid = true
} } else {
record = *command.record
record.issues = append([]error(nil), record.issues...)
} }
record.issues = append(record.issues, err)
return Command{record: &record}
}
children := make(map[string]struct{}, len(command.children)) // NoCommand explicitly represents an absent optional command declaration.
for _, child := range command.children { // Installation skips it, but reports errors attached to it; a zero Command is
if err := validateCommandDeclaration(child); err != nil { // invalid and is not an absence marker. Routing rules have no absence marker.
return err func NoCommand() Command { return Command{record: &commandRecord{absent: true}} }
}
if _, exists := children[child.name]; exists { // Alias captures a command-path alias from path to target. Paths are
return fmt.Errorf("duplicate child command %q under %q", child.name, command.name) // whitespace-separated sequences of command names relative to the application
} // root. The target must resolve to a canonical command or group; aliases do not
children[child.name] = struct{}{} // add operands or transform flag values. Construction issues are retained for
// New or App.Register to report. Identical mappings are accepted, while
// different targets for the same source path are installation errors.
func Alias(path, target string) RoutingRule {
record := &routingRuleRecord{
kind: commandAliasRule,
sourceText: path,
targetText: target,
}
record.sourcePath = captureRoutingPath("source", path, &record.issues)
record.targetPath = captureRoutingPath("target", target, &record.issues)
return RoutingRule{record: record}
}
// FlagAlias captures an application-wide flag spelling alias. Spellings must
// be one long flag name or one individual short spelling. The mapping applies
// wherever its canonical target flag is available and contributes nothing on
// commands without that flag. Identical mappings are accepted; different
// targets for the same source spelling in this scope are installation errors.
func FlagAlias(spelling, target string) RoutingRule {
record := &routingRuleRecord{
kind: globalFlagAliasRule,
sourceText: spelling,
targetText: target,
}
record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues)
record.targetFlag = captureFlagSpelling("target", target, &record.issues)
return RoutingRule{record: record}
}
// FlagAliasFor captures a flag spelling alias scoped to a canonical command
// path, not an alias path. The command and canonical target flag must be
// available in the complete installation. Scoped mappings take precedence over
// application-wide mappings with the same source spelling. Identical mappings
// are accepted; competing targets in the same scope are installation errors.
func FlagAliasFor(commandPath, spelling, target string) RoutingRule {
record := &routingRuleRecord{
kind: scopedFlagAliasRule,
sourceText: spelling,
targetText: target,
scopeText: commandPath,
}
record.scopePath = captureRoutingPath("command scope", commandPath, &record.issues)
record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues)
record.targetFlag = captureFlagSpelling("target", target, &record.issues)
return RoutingRule{record: record}
}
func captureRoutingPath(label, value string, issues *[]error) commandpath.Path {
path, err := commandpath.ParseDeclaration(value)
if err != nil {
*issues = append(*issues, fmt.Errorf("invalid %s path: %w", label, err))
}
return path
}
func captureFlagSpelling(label, value string, issues *[]error) capturedFlagSpelling {
spelling, err := parseFlagSpelling(value)
if err != nil {
*issues = append(*issues, fmt.Errorf("invalid %s flag spelling %q: %w", label, value, err))
}
return spelling
}
func parseFlagSpelling(value string) (capturedFlagSpelling, error) {
if strings.HasPrefix(value, "--") {
name := value[2:]
if validFlagName(name) {
return capturedFlagSpelling{name: name}, nil
}
}
if len(value) == 2 && value[0] == '-' && value[1] != '-' {
return capturedFlagSpelling{name: value[1:], short: true}, nil
}
return capturedFlagSpelling{}, fmt.Errorf("must be a long flag or one-byte short flag spelling")
}
func captureComposition(record *commandRecord) {
if err := validateCommandName(record.name); err != nil {
record.issues = append(record.issues, err)
}
seen := make(map[string]struct{}, len(record.children))
for _, child := range record.children {
if child.record == nil {
record.issues = append(record.issues, fmt.Errorf("command %q has an invalid child declaration", record.name))
continue
}
if child.record.absent {
continue
}
name := child.record.name
if _, exists := seen[name]; exists {
record.issues = append(record.issues, fmt.Errorf("duplicate child command %q under %q", name, record.name))
}
seen[name] = struct{}{}
}
}
func validateCommandDeclaration(command Command) error {
return validateCommandPlacement(command)
}
func validateCommandPlacement(command Command) error {
var issues []error
collectDeclarationIssues(command, nil, true, &issues)
return errors.Join(issues...)
}
func collectDeclarationIssues(command Command, parents []string, root bool, issues *[]error) {
if command.record == nil {
*issues = append(*issues, errors.New("invalid zero command declaration"))
return
}
record := command.record
path := append(append([]string(nil), parents...), record.name)
label := commandpath.FromTokens(path).String()
for _, issue := range record.issues {
if label != "" {
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
} else {
*issues = append(*issues, issue)
}
}
if record.rootOnly && !root {
*issues = append(*issues, fmt.Errorf("root-only command %q is nested beneath another command", label))
}
if record.invalid || record.absent {
return
}
for _, child := range record.children {
if child.record == nil {
continue
}
collectDeclarationIssues(child, path, false, issues)
} }
return nil
} }
func validateCommandName(name string) error { func validateCommandName(name string) error {
fields := strings.Fields(name) return commandpath.ValidateComponent(name)
if len(fields) != 1 || fields[0] != name || strings.HasPrefix(name, "-") || name == "--" {
return fmt.Errorf("command name %q must be one non-option token", name)
}
return nil
} }
func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error { func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
@@ -279,6 +471,9 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
if binding.meta.Name == "help" { if binding.meta.Name == "help" {
return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name) return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name)
} }
if commandmeta.IsReportingFlag(binding.meta.Name) {
return fmt.Errorf("flag name %q is reserved for reporting", binding.meta.Name)
}
if _, exists := flagNames[binding.meta.Name]; exists { if _, exists := flagNames[binding.meta.Name]; exists {
return fmt.Errorf("duplicate flag name %q", binding.meta.Name) return fmt.Errorf("duplicate flag name %q", binding.meta.Name)
} }
@@ -295,7 +490,7 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
} }
shortNames[binding.meta.Short] = struct{}{} shortNames[binding.meta.Short] = struct{}{}
} }
if !binding.valid || binding.prepare == nil { if binding.identity == nil || !binding.valid || binding.prepare == nil {
return fmt.Errorf("flag %q is missing parser or setter wiring", binding.meta.Name) return fmt.Errorf("flag %q is missing parser or setter wiring", binding.meta.Name)
} }
} }
@@ -308,7 +503,7 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
if binding.min < 0 || binding.max < -1 || (binding.max >= 0 && binding.min > binding.max) { if binding.min < 0 || binding.max < -1 || (binding.max >= 0 && binding.min > binding.max) {
return fmt.Errorf("argument %q has invalid bounds %d..%d", binding.meta.Name, binding.min, binding.max) return fmt.Errorf("argument %q has invalid bounds %d..%d", binding.meta.Name, binding.min, binding.max)
} }
if !binding.valid || binding.prepare == nil { if binding.identity == nil || !binding.valid || binding.prepare == nil {
return fmt.Errorf("argument %q is missing parser or setter wiring", binding.meta.Name) return fmt.Errorf("argument %q is missing parser or setter wiring", binding.meta.Name)
} }
} }

461
config.go
View File

@@ -1,461 +0,0 @@
package commandkit
import (
"context"
"errors"
"fmt"
"io"
"sort"
"strconv"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"go.yaml.in/yaml/v4"
)
// Selection identifies the application configuration inputs supplied to a
// configuration scope.
type Selection struct {
Config Supplied[string]
Overlay Supplied[string]
}
// ConfigOperation identifies a supported configuration inspection operation.
type ConfigOperation string
const (
ConfigValidate ConfigOperation = "validate"
ConfigShow ConfigOperation = "show"
ConfigSources ConfigOperation = "sources"
ConfigExplain ConfigOperation = "explain"
)
// ConfigRequest describes one configuration operation and its selected inputs.
type ConfigRequest struct {
Operation ConfigOperation
Selection Selection
}
// EffectiveWriter writes an owning library's native effective configuration.
type EffectiveWriter interface {
WriteEffective(io.Writer) error
}
// SourceReader reports the sources used to construct a configuration.
type SourceReader interface {
Sources() []configkit.SourceInfo
}
// ExplanationReader writes an owning library's path explanation.
type ExplanationReader interface {
Explain(configkit.Path) (configkit.Explanation, bool)
WriteExplanation(io.Writer, configkit.Path) error
}
// ConfigView contains optional views relevant to supported operations.
type ConfigView struct {
Effective EffectiveWriter
Sources SourceReader
Explanation ExplanationReader
}
// Acquired contains a configuration view and diagnostics from its acquisition.
type Acquired[T any] struct {
Value T
Diagnostics []Diagnostic
}
// ConfigScope declares one named configuration and its supported operations.
type ConfigScope[C any] struct {
Name string
Summary string
Operations []ConfigOperation
Acquire func(context.Context, ConfigRequest, C) (Acquired[ConfigView], error)
}
// Selector binds one typed command flag to selected configuration scopes.
// A required binding is required only when a selected scope uses it.
type Selector[C any] struct {
Binding Flag[C]
Scopes []string
}
// ConfigSuite declares selectors and configuration scopes for an App.
type ConfigSuite[C any] struct {
NewContext func() C
Selectors []Selector[C]
CheckInput func(Selection, C) error
Scopes []ConfigScope[C]
}
type configScopeInfo struct {
Name string `yaml:"name"`
Operations []ConfigOperation `yaml:"operations"`
}
type scopesInventory struct {
Scopes []configScopeInfo `yaml:"scopes"`
}
type preparedConfigSuite[C any] struct {
suite ConfigSuite[C]
ordered []ConfigScope[C]
byName map[string]ConfigScope[C]
installed []ConfigOperation
inventory scopesInventory
}
type configScopesInput struct {
Format string
}
type selectorState[C any] struct {
name string
required bool
scopes []string
supplied bool
apply func(*C)
clear func(*C)
}
type configInput[C any] struct {
Selection Selection
Scope string
Format string
Path Supplied[string]
SelectorContext C
selectors []selectorState[C]
}
// RegisterConfig installs the available config handlers and the scope discovery
// command. Its registration and discovery paths do not invoke scope loaders.
func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
if app == nil {
return fmt.Errorf("cannot register configuration on a nil app")
}
if app.configRegistered {
return fmt.Errorf("configuration suite is already registered")
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
return err
}
if _, exists := app.commands["config"]; exists {
return fmt.Errorf("command path %q is already registered", "config")
}
prepared := prepareConfigSuite(normalized)
commands := make([]Command, 0, 5)
for _, operation := range prepared.installed {
var command Command
switch operation {
case ConfigValidate:
command, err = newConfigValidateCommand(prepared)
case ConfigShow:
command, err = newConfigShowCommand(prepared)
case ConfigSources:
command, err = newConfigSourcesCommand(prepared)
case ConfigExplain:
command, err = newConfigExplainCommand(prepared)
}
if err != nil {
return err
}
commands = append(commands, command)
}
command, err := newConfigScopesCommandWithPrepared(prepared)
if err != nil {
return err
}
commands = append(commands, command)
root, err := Group("config", "Inspect configuration.", commands...)
if err != nil {
return err
}
if err := app.Register(root); err != nil {
return err
}
app.configRegistered = true
return nil
}
// NewConfigKitScope adapts a ConfigKit loader into a scope with validation,
// effective display, source listing, and path explanation capabilities. The
// loader runs once per selected operation. Successful loads expose the result's
// public facets directly; ConfigKit LoadError records become CommandKit
// diagnostics while the original error is returned.
func NewConfigKitScope[C, T any](name, summary string, load func(context.Context, Selection, C) (*configkit.Result[T], error)) (ConfigScope[C], error) {
if load == nil {
return ConfigScope[C]{}, fmt.Errorf("ConfigKit scope loader must not be nil")
}
return ConfigScope[C]{
Name: name,
Summary: summary,
Operations: []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain},
Acquire: func(ctx context.Context, request ConfigRequest, contextValue C) (Acquired[ConfigView], error) {
result, err := load(ctx, request.Selection, contextValue)
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
return Acquired[ConfigView]{Diagnostics: ConfigKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())}, err
}
return Acquired[ConfigView]{}, err
}
if result == nil {
return Acquired[ConfigView]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
}
return Acquired[ConfigView]{
Value: ConfigView{Effective: result, Sources: result, Explanation: result},
Diagnostics: ConfigKitDiagnostics(name, result.Sources(), result.Diagnostics()),
}, nil
},
}, nil
}
// ConfigKitDiagnostics groups ConfigKit records with their scope and source legend.
func ConfigKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []Diagnostic {
if len(records) == 0 {
return nil
}
severity := configkit.SeverityWarning
for _, record := range records {
if record.Severity == configkit.SeverityError {
severity = configkit.SeverityError
break
}
}
return []Diagnostic{{
Severity: severity,
Write: func(writer io.Writer) error {
if err := writeDiagnosticText(writer, "Configuration scope "+strconv.Quote(scope)+":\n"); err != nil {
return err
}
if len(sources) > 0 {
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
return err
}
for _, source := range sources {
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
if err := writeDiagnosticText(writer, line); err != nil {
return err
}
}
}
return configkit.WriteDiagnostics(writer, records)
},
}}
}
func writeDiagnosticText(writer io.Writer, value string) error {
written, err := io.WriteString(writer, value)
if err != nil {
return err
}
if written != len(value) {
return io.ErrShortWrite
}
return nil
}
func normalizeConfigSuite[C any](suite ConfigSuite[C]) (ConfigSuite[C], error) {
normalized := suite
normalized.Selectors = append([]Selector[C](nil), suite.Selectors...)
normalized.Scopes = make([]ConfigScope[C], 0, len(suite.Scopes))
scopeNames := make(map[string]struct{}, len(suite.Scopes))
for _, scope := range suite.Scopes {
if scope.Name == "" {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope name must not be empty")
}
if scope.Name == "all" {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope name %q is reserved", scope.Name)
}
if _, exists := scopeNames[scope.Name]; exists {
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration scope %q", scope.Name)
}
scopeNames[scope.Name] = struct{}{}
operations, err := normalizeConfigOperations(scope.Operations)
if err != nil {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope %q: %w", scope.Name, err)
}
if len(operations) > 0 && scope.Acquire == nil {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope %q declares operations without an acquisition callback", scope.Name)
}
scope.Operations = operations
normalized.Scopes = append(normalized.Scopes, scope)
}
seenNames := make(map[string]struct{}, len(normalized.Selectors))
seenShorts := make(map[string]struct{}, len(normalized.Selectors))
for index := range normalized.Selectors {
selector := &normalized.Selectors[index]
binding := selector.Binding.binding
if !binding.valid || binding.clear == nil || binding.meta.Name == "" {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector %d has an invalid flag binding", index)
}
if !validFlagName(binding.meta.Name) {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector flag name %q is invalid", binding.meta.Name)
}
if binding.meta.Name == "config" || binding.meta.Name == "overlay" || binding.meta.Name == "scope" || binding.meta.Name == "format" || binding.meta.Name == "help" {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector flag --%s conflicts with a standard flag", binding.meta.Name)
}
if binding.meta.Short == "h" {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector short flag -%s conflicts with help", binding.meta.Short)
}
if binding.meta.Short != "" && (len(binding.meta.Short) != 1 || binding.meta.Short == "-") {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector short spelling %q must be one byte", binding.meta.Short)
}
seen := make(map[string]struct{}, len(selector.Scopes))
unique := make([]string, 0, len(selector.Scopes))
for _, scopeName := range selector.Scopes {
if _, exists := scopeNames[scopeName]; !exists {
return ConfigSuite[C]{}, fmt.Errorf("selector --%s references unknown configuration scope %q", binding.meta.Name, scopeName)
}
if _, exists := seen[scopeName]; exists {
continue
}
seen[scopeName] = struct{}{}
unique = append(unique, scopeName)
}
selector.Scopes = append([]string(nil), unique...)
if _, exists := seenNames[binding.meta.Name]; exists {
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration selector flag --%s", binding.meta.Name)
}
if binding.meta.Short != "" {
if _, exists := seenShorts[binding.meta.Short]; exists {
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration selector flag --%s", binding.meta.Name)
}
seenShorts[binding.meta.Short] = struct{}{}
}
seenNames[binding.meta.Name] = struct{}{}
}
return normalized, nil
}
func prepareConfigSuite[C any](suite ConfigSuite[C]) *preparedConfigSuite[C] {
prepared := &preparedConfigSuite[C]{
suite: suite,
ordered: orderedConfigScopes(suite.Scopes),
byName: make(map[string]ConfigScope[C], len(suite.Scopes)),
installed: make([]ConfigOperation, 0, 4),
inventory: scopesInventory{Scopes: make([]configScopeInfo, 0, len(suite.Scopes))},
}
for _, scope := range suite.Scopes {
prepared.byName[scope.Name] = scope
}
for _, operation := range []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain} {
if configOperationDeclared(suite.Scopes, operation) {
prepared.installed = append(prepared.installed, operation)
}
}
for _, scope := range prepared.ordered {
operations := make([]ConfigOperation, 0, len(scope.Operations))
for _, operation := range scope.Operations {
if containsOperation(prepared.installed, operation) {
operations = append(operations, operation)
}
}
prepared.inventory.Scopes = append(prepared.inventory.Scopes, configScopeInfo{Name: scope.Name, Operations: operations})
}
return prepared
}
func normalizeConfigOperations(operations []ConfigOperation) ([]ConfigOperation, error) {
seen := make(map[ConfigOperation]struct{}, len(operations))
for _, operation := range operations {
switch operation {
case ConfigValidate, ConfigShow, ConfigSources, ConfigExplain:
default:
return nil, fmt.Errorf("unknown operation %q", operation)
}
seen[operation] = struct{}{}
}
ordered := make([]ConfigOperation, 0, len(seen))
for _, operation := range []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain} {
if _, exists := seen[operation]; exists {
ordered = append(ordered, operation)
}
}
return ordered, nil
}
func newConfigScopesCommandWithPrepared[C any](prepared *preparedConfigSuite[C]) (Command, error) {
formatDefault := "text"
spec := CommandSpec[configScopesInput]{
Name: "scopes",
Summary: "List configuration scopes and supported operations.",
Flags: []Flag[configScopesInput]{ValueFlag[configScopesInput](FlagMeta{
Name: "format", Usage: "Output format: text or yaml.",
}, &formatDefault, parseConfigFormat, func(input *configScopesInput, value Supplied[string]) {
input.Format = value.Value
})},
CheckInput: func(input configScopesInput) error { return checkConfigFormat(input.Format) },
Run: func(invocation Invocation, input configScopesInput) Outcome {
var output []byte
var err error
if input.Format == "yaml" {
output, err = yaml.Marshal(prepared.inventory)
} else {
output = []byte(formatScopeInventory(prepared.inventory))
}
if err != nil {
return Failure(err)
}
if err := writeReport(invocation.Streams.Out, output); err != nil {
return incompleteReport(err)
}
return Success()
},
}
return NewCommand(spec)
}
func orderedConfigScopes[C any](scopes []ConfigScope[C]) []ConfigScope[C] {
ordered := append([]ConfigScope[C](nil), scopes...)
sort.SliceStable(ordered, func(i, j int) bool {
return configScopeOrder(ordered[i].Name) < configScopeOrder(ordered[j].Name)
})
return ordered
}
func configScopeOrder(name string) string {
switch name {
case "app":
return "\x00"
case "promptkit":
return "\x01"
default:
return "\x02" + name
}
}
func formatScopeInventory(inventory scopesInventory) string {
var output strings.Builder
for _, scope := range inventory.Scopes {
output.WriteString(scope.Name)
output.WriteString(":")
if len(scope.Operations) > 0 {
output.WriteString(" ")
for index, operation := range scope.Operations {
if index > 0 {
output.WriteString(", ")
}
output.WriteString(string(operation))
}
}
output.WriteByte('\n')
}
return output.String()
}
func parseConfigFormat(value string) (string, error) {
if err := checkConfigFormat(value); err != nil {
return "", err
}
return value, nil
}
func checkConfigFormat(value string) error {
if value != "text" && value != "yaml" {
return fmt.Errorf("format must be text or yaml")
}
return nil
}

View File

@@ -1,359 +0,0 @@
package commandkit
import (
"bytes"
"context"
"reflect"
"strconv"
"strings"
"testing"
)
func TestConfigScopeDiscoveryOrdersAndDeduplicatesCapabilities(t *testing.T) {
app, err := New(Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
newContextCalls := 0
acquireCalls := 0
suite := ConfigSuite[struct{}]{
NewContext: func() struct{} { newContextCalls++; return struct{}{} },
Scopes: []ConfigScope[struct{}]{
{Name: "zeta", Operations: []ConfigOperation{ConfigExplain, ConfigShow, ConfigExplain}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
acquireCalls++
return Acquired[ConfigView]{}, nil
}},
{Name: "promptkit", Operations: []ConfigOperation{ConfigSources}, Acquire: noopConfigAcquire[struct{}]},
{Name: "app", Operations: []ConfigOperation{ConfigShow, ConfigValidate, ConfigShow}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
acquireCalls++
return Acquired[ConfigView]{}, nil
}},
{Name: "metadata", Summary: "No provider is required."},
},
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(normalized.Scopes[0].Operations, []ConfigOperation{ConfigShow, ConfigExplain}) || !reflect.DeepEqual(normalized.Scopes[2].Operations, []ConfigOperation{ConfigValidate, ConfigShow}) {
t.Fatalf("normalized capabilities = %#v", normalized.Scopes)
}
if err := RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var textOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, Streams{Out: &textOutput})
if outcome.Kind != OutcomeSuccess {
t.Fatalf("text inventory outcome = %#v", outcome)
}
wantText := "app: validate, show\npromptkit: sources\nmetadata:\nzeta: show, explain\n"
if textOutput.String() != wantText {
t.Fatalf("text inventory = %q, want %q", textOutput.String(), wantText)
}
var yamlOutput bytes.Buffer
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--format", "yaml"}, Streams{Out: &yamlOutput})
if outcome.Kind != OutcomeSuccess {
t.Fatalf("YAML inventory outcome = %#v", outcome)
}
if got := yamlOutput.String(); !strings.Contains(got, "scopes:") || strings.Index(got, "name: app") > strings.Index(got, "name: promptkit") || !strings.Contains(got, "operations: []") {
t.Fatalf("YAML inventory = %q", got)
}
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--help"}, Streams{})
if outcome.Kind != OutcomeSuccess {
t.Fatalf("scope help outcome = %#v", outcome)
}
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--format", "json"}, Streams{})
if outcome.Kind != OutcomeUsage {
t.Fatalf("invalid format outcome = %#v", outcome)
}
if newContextCalls != 0 || acquireCalls != 0 {
t.Fatalf("discovery called runtime callbacks: NewContext=%d Acquire=%d", newContextCalls, acquireCalls)
}
}
func TestConfigInputDefaultsToAppAndRetainsAbsentSelections(t *testing.T) {
suite := ConfigSuite[struct{}]{Scopes: []ConfigScope[struct{}]{{Name: "app"}}}
prepared, err := prepareInput(CommandSpec[configInput[struct{}]]{Flags: configInputFlags(suite, "yaml")}, make([][]string, 4), nil, true)
if err != nil {
t.Fatal(err)
}
input := prepared.build()
if input.Scope != "app" || input.Format != "yaml" || input.Selection.Config.Set || input.Selection.Overlay.Set {
t.Fatalf("configuration defaults = %#v", input)
}
}
func TestRegisterConfigOwnsPreparedScopeAndSelectorMetadata(t *testing.T) {
type selectorInput struct{ Region Supplied[string] }
var acquired selectorInput
suite := ConfigSuite[selectorInput]{
Selectors: []Selector[selectorInput]{{
Binding: ValueFlag[selectorInput](FlagMeta{Name: "region"}, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.Region = value }),
Scopes: []string{"app"},
}},
Scopes: []ConfigScope[selectorInput]{{
Name: "app",
Operations: []ConfigOperation{ConfigValidate},
Acquire: func(_ context.Context, _ ConfigRequest, input selectorInput) (Acquired[ConfigView], error) {
acquired = input
return Acquired[ConfigView]{}, nil
},
}},
}
app, err := New(Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
if err := RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
suite.Scopes[0].Name = "renamed"
suite.Scopes[0].Operations[0] = ConfigShow
suite.Selectors[0].Scopes[0] = "renamed"
var inventory bytes.Buffer
if result := app.Execute(context.Background(), []string{"config", "scopes"}, Streams{Out: &inventory}); result.Kind != OutcomeSuccess {
t.Fatalf("scope inventory outcome = %#v", result)
}
if got := inventory.String(); got != "app: validate\n" {
t.Fatalf("scope inventory after caller mutation = %q", got)
}
if result := app.Execute(context.Background(), []string{"config", "validate", "--scope=app", "--region=west"}, Streams{}); result.Kind != OutcomeSuccess {
t.Fatalf("registered scope execution after caller mutation = %#v", result)
}
if acquired.Region != (Supplied[string]{Value: "west", Set: true}) {
t.Fatalf("acquired selector after caller mutation = %#v", acquired)
}
}
func TestRegisterConfigRejectsDuplicateSelectorNamesAndShorts(t *testing.T) {
type selectorInput struct{ First, Second string }
tests := []struct {
name string
firstMeta FlagMeta
secondMeta FlagMeta
}{
{name: "name", firstMeta: FlagMeta{Name: "region", Short: "r"}, secondMeta: FlagMeta{Name: "region", Short: "g"}},
{name: "short", firstMeta: FlagMeta{Name: "region", Short: "r"}, secondMeta: FlagMeta{Name: "group", Short: "r"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
app, err := New(Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
suite := ConfigSuite[selectorInput]{Selectors: []Selector[selectorInput]{
{Binding: ValueFlag[selectorInput](test.firstMeta, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.First = value.Value })},
{Binding: ValueFlag[selectorInput](test.secondMeta, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.Second = value.Value })},
}}
if err := RegisterConfig(app, suite); err == nil || !strings.Contains(err.Error(), "duplicate configuration selector flag") {
t.Fatalf("RegisterConfig error = %v", err)
}
})
}
}
func TestRegisterConfigRejectsInvalidSuitesAndCollisions(t *testing.T) {
tests := []struct {
name string
scope ConfigScope[struct{}]
want string
}{
{name: "empty name", scope: ConfigScope[struct{}]{}, want: "name must not be empty"},
{name: "all name", scope: ConfigScope[struct{}]{Name: "all"}, want: "reserved"},
{name: "unknown operation", scope: ConfigScope[struct{}]{Name: "app", Operations: []ConfigOperation{"diff"}}, want: "unknown operation"},
{name: "missing acquisition", scope: ConfigScope[struct{}]{Name: "app", Operations: []ConfigOperation{ConfigValidate}}, want: "without an acquisition"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
app, _ := New(Identity{Name: "example"})
err := RegisterConfig(app, ConfigSuite[struct{}]{Scopes: []ConfigScope[struct{}]{test.scope}})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("RegisterConfig error = %v, want %q", err, test.want)
}
})
}
app, _ := New(Identity{Name: "example"})
custom, err := NewCommand(CommandSpec[struct{}]{Name: "config", Run: func(Invocation, struct{}) Outcome { return Success() }})
if err != nil {
t.Fatal(err)
}
if err := app.Register(custom); err != nil {
t.Fatal(err)
}
if err := RegisterConfig(app, ConfigSuite[struct{}]{}); err == nil {
t.Fatal("replaced a pre-existing config command")
}
}
func TestConfigSelectorPreflightAndScopedContextProjection(t *testing.T) {
type selectorInput struct {
Count Supplied[int]
Enabled Supplied[bool]
Other Supplied[int]
Runtime string
}
baseContextCalls := 0
checkCalls := 0
var checked selectorInput
countDefault := 9
suite := ConfigSuite[selectorInput]{
NewContext: func() selectorInput { baseContextCalls++; return selectorInput{Runtime: "invocation cache"} },
Selectors: []Selector[selectorInput]{
{Binding: ValueFlag[selectorInput](FlagMeta{Name: "count"}, &countDefault, strconv.Atoi, func(input *selectorInput, value Supplied[int]) { input.Count = value }), Scopes: []string{"app"}},
{Binding: BoolFlag[selectorInput](FlagMeta{Name: "enabled"}, true, func(input *selectorInput, value Supplied[bool]) { input.Enabled = value }), Scopes: []string{"promptkit"}},
{Binding: ValueFlag[selectorInput](FlagMeta{Name: "other"}, nil, strconv.Atoi, func(input *selectorInput, value Supplied[int]) { input.Other = value })},
},
CheckInput: func(_ Selection, input selectorInput) error { checkCalls++; checked = input; return nil },
Scopes: []ConfigScope[selectorInput]{
{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
{Name: "promptkit", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
},
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
t.Fatal(err)
}
preparedSuite := prepareConfigSuite(normalized)
spec := CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}
prepared, err := prepareInput(spec, [][]string{{"all"}, {"text"}, {""}, nil, {"0"}, {"false"}, nil}, nil, true)
if err != nil {
t.Fatal(err)
}
input := prepared.build()
scopeNames, err := preflightConfig(preparedSuite, input, ConfigValidate)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(scopeNames, []string{"app", "promptkit"}) || checkCalls != 1 || checked.Count != (Supplied[int]{Value: 0, Set: true}) || checked.Enabled != (Supplied[bool]{Value: false, Set: true}) {
t.Fatalf("preflight result=%v checked=%#v calls=%d", scopeNames, checked, checkCalls)
}
if !input.Selection.Config.Set || input.Selection.Config.Value != "" || input.Selection.Overlay.Set {
t.Fatalf("configuration selection presence = %#v", input.Selection)
}
if baseContextCalls != 0 {
t.Fatalf("NewContext called during preflight %d times", baseContextCalls)
}
contexts := contextsForScopes(preparedSuite, input, scopeNames)
if baseContextCalls != 1 {
t.Fatalf("NewContext calls = %d, want one", baseContextCalls)
}
if contexts["app"].Count != (Supplied[int]{Value: 0, Set: true}) || contexts["app"].Enabled != (Supplied[bool]{}) || contexts["app"].Other != (Supplied[int]{}) {
t.Fatalf("app selectors = %#v", contexts["app"])
}
if contexts["promptkit"].Count != (Supplied[int]{}) || contexts["promptkit"].Enabled != (Supplied[bool]{Value: false, Set: true}) || contexts["promptkit"].Runtime != "invocation cache" {
t.Fatalf("promptkit selectors = %#v", contexts["promptkit"])
}
}
func TestConfigPreflightRejectsUnsupportedAggregateAndInapplicableSelector(t *testing.T) {
type selectorInput struct{ Region Supplied[string] }
newContextCalls := 0
suite := ConfigSuite[selectorInput]{
NewContext: func() selectorInput { newContextCalls++; return selectorInput{} },
Selectors: []Selector[selectorInput]{{Binding: ValueFlag[selectorInput](FlagMeta{Name: "region"}, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.Region = value }), Scopes: []string{"promptkit"}}},
Scopes: []ConfigScope[selectorInput]{
{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
{Name: "promptkit", Operations: []ConfigOperation{ConfigShow}, Acquire: noopConfigAcquire[selectorInput]},
},
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
t.Fatal(err)
}
preparedSuite := prepareConfigSuite(normalized)
prepared, err := prepareInput(CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}, [][]string{{"all"}, {"text"}, nil, nil, nil}, nil, true)
if err != nil {
t.Fatal(err)
}
if _, err := preflightConfig(preparedSuite, prepared.build(), ConfigValidate); err == nil || !strings.Contains(err.Error(), "does not support validate") {
t.Fatalf("unsupported aggregate error = %v", err)
}
if newContextCalls != 0 {
t.Fatalf("unsupported aggregate created context %d times", newContextCalls)
}
prepared, err = prepareInput(CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}, [][]string{{"app"}, {"text"}, nil, nil, {"west"}}, nil, true)
if err != nil {
t.Fatal(err)
}
if _, err := preflightConfig(preparedSuite, prepared.build(), ConfigValidate); err == nil || !strings.Contains(err.Error(), "not applicable") {
t.Fatalf("inapplicable selector error = %v", err)
}
}
func noopConfigAcquire[C any](context.Context, ConfigRequest, C) (Acquired[ConfigView], error) {
return Acquired[ConfigView]{}, nil
}
func TestRequiredConfigSelectorOnlyAppliesToSelectedScopes(t *testing.T) {
app, _ := New(Identity{Name: "example"})
calls := 0
acquire := func(context.Context, ConfigRequest, string) (Acquired[ConfigView], error) {
calls++
return Acquired[ConfigView]{}, nil
}
err := RegisterConfig(app, ConfigSuite[string]{
Selectors: []Selector[string]{{Scopes: []string{"app"}, Binding: ValueFlag[string](FlagMeta{Name: "campaign", Required: true}, nil, parseString, func(c *string, v Supplied[string]) { *c = v.Value })}},
Scopes: []ConfigScope[string]{{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: acquire}, {Name: "promptkit", Operations: []ConfigOperation{ConfigValidate}, Acquire: acquire}},
})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
args []string
kind OutcomeKind
calls int
}{
{[]string{"--scope", "promptkit"}, OutcomeSuccess, 1},
{nil, OutcomeUsage, 0},
{[]string{"--scope", "all"}, OutcomeUsage, 0},
{[]string{"--scope", "all", "--campaign", "spring"}, OutcomeSuccess, 2},
{[]string{"--scope", "promptkit", "--campaign", "spring"}, OutcomeUsage, 0},
{[]string{"--help"}, OutcomeSuccess, 0},
} {
calls = 0
var out bytes.Buffer
result := app.Execute(context.Background(), append([]string{"config", "validate"}, tc.args...), Streams{Out: &out})
if result.Kind != tc.kind || calls != tc.calls {
t.Fatalf("%v: result=%#v calls=%d", tc.args, result, calls)
}
if len(tc.args) > 0 && tc.args[0] == "--help" && !strings.Contains(out.String(), "required where applicable") {
t.Fatalf("help=%q", out.String())
}
}
}
func TestUnknownConfigScopeReportsChoicesWithoutRuntimeWork(t *testing.T) {
for _, tc := range []struct {
names []string
scope []string
choices string
}{
{[]string{"promptkit"}, nil, "available: promptkit"},
{[]string{"zeta", "alpha", "promptkit", "app"}, []string{"--scope", "unknown"}, "available: app, promptkit, alpha, zeta"},
} {
runtimeCalls := 0
suite := ConfigSuite[struct{}]{NewContext: func() struct{} { runtimeCalls++; return struct{}{} }}
for _, name := range tc.names {
suite.Scopes = append(suite.Scopes, ConfigScope[struct{}]{Name: name, Operations: []ConfigOperation{ConfigValidate}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
runtimeCalls++
return Acquired[ConfigView]{}, nil
}})
}
app, _ := New(Identity{Name: "example"})
if err := RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var out, diagnostics bytes.Buffer
result := app.Execute(context.Background(), append([]string{"config", "validate"}, tc.scope...), Streams{Out: &out, Err: &diagnostics})
if result.Kind != OutcomeUsage || runtimeCalls != 0 || out.Len() != 0 || !strings.Contains(diagnostics.String(), tc.choices) {
t.Fatalf("result=%#v calls=%d out=%q diagnostics=%q", result, runtimeCalls, out.String(), diagnostics.String())
}
}
}

View File

@@ -1,720 +0,0 @@
package commandkit_test
import (
"bytes"
"context"
"errors"
"io"
"reflect"
"strings"
"testing"
"testing/fstest"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"go.yaml.in/yaml/v4"
)
type effectiveYAML string
func (document effectiveYAML) WriteEffective(writer io.Writer) error {
_, err := io.WriteString(writer, string(document))
return err
}
type capturedSourceReader struct {
entries []configkit.SourceInfo
calls int
}
func TestConfigExplainPreservesConfigKitProvenanceAndRedaction(t *testing.T) {
type entry struct {
Enabled bool `yaml:"enabled"`
Credential configkit.Secret `yaml:"credential"`
}
type configuration struct {
Entries map[string]entry `yaml:"entries"`
Odd map[string]string `yaml:"odd"`
Value string `yaml:"value"`
Dependency string `yaml:"dependency"`
Secret configkit.Secret `yaml:"secret"`
}
const secretValue = "EXPLAIN_TEST_SECRET_VALUE"
t.Setenv("EXPLAIN_TEST_SECRET", secretValue)
input := []byte("entries:\n parent:\n enabled: false\n credential: EXPLAIN_TEST_SECRET\n child:\n extends: parent\nodd:\n \"key/name~\": escaped\nvalue: original\ndependency: source\nsecret: EXPLAIN_TEST_SECRET\n")
result, err := configkit.Load[configuration](configkit.Bytes("explanation.yml", input), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
Secret: func(path configkit.Path) bool { return path.String() == "/entries/parent/credential" },
Resolve: func(value *configuration, resolution *configkit.Resolution) error {
resolution.Record(configkit.Keys("value"), configkit.ChangeNormalization, "upper case", configkit.Keys("dependency"))
value.Value = strings.ToUpper(value.Value)
return nil
},
}})
if err != nil {
t.Fatal(err)
}
loads, contexts := 0, 0
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
loads++
return result, nil
})
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{NewContext: func() struct{} { contexts++; return struct{}{} }, Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
t.Fatal(err)
}
runYAML := func(pointer string) (map[string]any, commandkit.Outcome, string) {
var output bytes.Buffer
argv := []string{"config", "explain", "--path", pointer, "--format", "yaml"}
if pointer == "" {
argv = []string{"config", "explain", "--path=", "--format", "yaml"}
}
outcome := app.Execute(context.Background(), argv, commandkit.Streams{Out: &output, Err: io.Discard})
var report map[string]any
if outcome.Kind == commandkit.OutcomeSuccess {
if err := yaml.Unmarshal(output.Bytes(), &report); err != nil {
t.Fatalf("decode explanation YAML: %v; output=%q", err, output.String())
}
}
return report, outcome, output.String()
}
path := "/entries/child/enabled"
var native bytes.Buffer
if err := result.WriteExplanation(&native, configkit.Keys("entries", "child", "enabled")); err != nil {
t.Fatal(err)
}
var textOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", path}, commandkit.Streams{Out: &textOutput, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || textOutput.String() != native.String() {
t.Fatalf("native text outcome=%#v output=%q want=%q", outcome, textOutput.String(), native.String())
}
var inherited map[string]any
inherited, outcome, _ = runYAML(path)
if outcome.Kind != commandkit.OutcomeSuccess || inherited["path"] != path {
t.Fatalf("inherited explanation outcome=%#v report=%#v", outcome, inherited)
}
effective := inherited["effective"].(map[string]any)
if effective["present"] != true || effective["yaml"] != "false\n" {
t.Fatalf("inherited effective snapshot = %#v", effective)
}
origin := inherited["origin"].(map[string]any)
steps := origin["inheritance"].([]any)
step := steps[0].(map[string]any)
if step["from"] != "/entries/parent/enabled" || step["to"] != path || step["reference"] != "/entries/child/extends" {
t.Fatalf("inheritance paths = %#v", step)
}
escaped, outcome, _ := runYAML("/odd/key~1name~0")
if outcome.Kind != commandkit.OutcomeSuccess || escaped["path"] != "/odd/key~1name~0" || escaped["effective"].(map[string]any)["yaml"] != "escaped\n" {
t.Fatalf("escaped path outcome=%#v report=%#v", outcome, escaped)
}
historical, outcome, _ := runYAML("/entries/child/extends")
if outcome.Kind != commandkit.OutcomeSuccess || historical["effective"].(map[string]any)["present"] != false || len(historical["history"].([]any)) == 0 {
t.Fatalf("historical path outcome=%#v report=%#v", outcome, historical)
}
resolved, outcome, _ := runYAML("/value")
if outcome.Kind != commandkit.OutcomeSuccess || resolved["effective"].(map[string]any)["yaml"] != "ORIGINAL\n" {
t.Fatalf("resolved snapshot outcome=%#v report=%#v", outcome, resolved)
}
changes := resolved["history"].([]any)
last := changes[len(changes)-1].(map[string]any)
if last["reasons"].([]any)[0] != "upper case" || last["dependencies"].([]any)[0] != "/dependency" {
t.Fatalf("resolution change = %#v", last)
}
secretReport, outcome, secretText := runYAML("/entries/child/credential")
secretEffective := secretReport["effective"].(map[string]any)
if outcome.Kind != commandkit.OutcomeSuccess || secretEffective["redacted"] != true || strings.Contains(secretText, secretValue) {
t.Fatalf("secret report outcome=%#v report=%#v", outcome, secretReport)
}
for _, change := range secretReport["history"].([]any) {
entry := change.(map[string]any)
for _, key := range []string{"before", "after"} {
snapshot := entry[key].(map[string]any)
if snapshot["present"] == true && snapshot["redacted"] != true {
t.Fatalf("secret history was not redacted: %#v", entry)
}
}
}
directSecret, outcome, directSecretText := runYAML("/secret")
secretRef, refPresent := directSecret["secret_reference"].(map[string]any)
if !refPresent {
t.Fatalf("secret reference was absent: %#v", directSecret)
}
if outcome.Kind != commandkit.OutcomeSuccess || secretRef["name"] != "EXPLAIN_TEST_SECRET" || strings.Contains(directSecretText, secretValue) {
t.Fatalf("direct secret reference outcome=%#v report=%#v", outcome, directSecret)
}
root, outcome, _ := runYAML("")
if outcome.Kind != commandkit.OutcomeSuccess || root["path"] != "" {
t.Fatalf("root path outcome=%#v report=%#v", outcome, root)
}
loadsBeforeInvalid, contextsBeforeInvalid := loads, contexts
outcome = app.Execute(context.Background(), []string{"config", "explain"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || loads != loadsBeforeInvalid || contexts != contextsBeforeInvalid {
t.Fatalf("missing path outcome=%#v loads=%d contexts=%d", outcome, loads, contexts)
}
outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", "/bad~2path"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || outcome.Status != 2 || loads != loadsBeforeInvalid || contexts != contextsBeforeInvalid {
t.Fatalf("malformed path outcome=%#v loads=%d contexts=%d", outcome, loads, contexts)
}
outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", "/does-not-exist"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 {
t.Fatalf("unknown path outcome = %#v", outcome)
}
partial := &configKitShowPartialWriter{failure: io.ErrClosedPipe}
outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", path}, commandkit.Streams{Out: partial, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.DeliveryError, io.ErrClosedPipe) {
t.Fatalf("explanation writer failure = %#v", outcome)
}
}
func (reader *capturedSourceReader) Sources() []configkit.SourceInfo {
reader.calls++
return reader.entries
}
func TestConfigSourcesReportsCapturedConfigKitSourceInventory(t *testing.T) {
type configuration struct {
Value string `yaml:"value"`
Shared string `yaml:"shared"`
Synthetic string `yaml:"synthetic"`
}
filesystem := configkit.NewFilesystem("fixture", fstest.MapFS{
"root/config/main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../shared.yml}]}\nvalue: base\n")},
"root/shared.yml": &fstest.MapFile{Data: []byte("shared: imported\n")},
"root/overlays/dev.yml": &fstest.MapFile{Data: []byte("value: overlay\n")},
})
filesystemContext := &configkit.FilesystemContext{Root: "root", FS: &filesystem}
overlayFilesystemContext := &configkit.FilesystemContext{Root: "root", BaseDir: "root/config", FS: &filesystem}
loaderCalls := 0
reader := &capturedSourceReader{}
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
loaderCalls++
result, err := configkit.LoadLayered(configkit.Input{
Source: filesystem.File("root/config/main.yml"),
Imports: true,
Filesystem: filesystemContext,
}, configkit.LayeredOptions[configuration]{
Definition: configkit.Definition[configuration]{},
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("synthetic source", map[string]any{"synthetic": "underlay"})}}}},
Overlay: "dev",
OverlayFilesystem: overlayFilesystemContext,
})
if err != nil {
return nil, err
}
reader.entries = result.Sources()
return result, nil
})
if err != nil {
t.Fatal(err)
}
// Replace the result's reader after the owning loader has captured its source inventory.
originalAcquire := scope.Acquire
scope.Acquire = func(ctx context.Context, request commandkit.ConfigRequest, input struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
acquired, err := originalAcquire(ctx, request, input)
if err == nil {
acquired.Value.Sources = reader
}
return acquired, err
}
// Register the wrapped scope so the command reads the captured public facet once.
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "sources", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || loaderCalls != 1 || reader.calls != 1 {
var details bytes.Buffer
for _, diagnostic := range outcome.Diagnostics {
_ = diagnostic.Write(&details)
}
t.Fatalf("sources outcome=%#v cause=%v details=%q loader calls=%d reader calls=%d", outcome, outcome.Cause, details.String(), loaderCalls, reader.calls)
}
var report struct {
Scope string `yaml:"scope"`
Sources []struct {
ID string `yaml:"id"`
Label string `yaml:"label"`
Path string `yaml:"path"`
Layer string `yaml:"layer"`
Kind string `yaml:"kind"`
UnderlayIndex int `yaml:"underlay_index"`
ImportedFrom *struct {
SourceID string `yaml:"source_id"`
Line int `yaml:"line"`
Column int `yaml:"column"`
} `yaml:"imported_from"`
} `yaml:"sources"`
}
if err := yaml.Unmarshal(output.Bytes(), &report); err != nil {
t.Fatal(err)
}
if report.Scope != "app" || len(report.Sources) != 4 {
t.Fatalf("source report = %#v", report)
}
if report.Sources[0].Path != "root/config/main.yml" || report.Sources[0].ID != "source-1" {
t.Fatalf("main source = %#v", report.Sources[0])
}
if report.Sources[0].ImportedFrom != nil {
t.Fatalf("root source imported_from = %#v, want null", report.Sources[0].ImportedFrom)
}
imported := report.Sources[1]
if imported.Path != "root/shared.yml" || imported.ImportedFrom == nil || imported.ImportedFrom.SourceID != report.Sources[0].ID || imported.ImportedFrom.Line == 0 {
t.Fatalf("import source correlation = %#v", imported)
}
synthetic := report.Sources[2]
if synthetic.Kind != string(configkit.SourceKindMapping) || synthetic.Path != "" || synthetic.Layer != string(configkit.LayerUnderlay) || synthetic.UnderlayIndex != 1 {
t.Fatalf("synthetic underlay source = %#v", synthetic)
}
if report.Sources[3].Layer != string(configkit.LayerOverlay) || report.Sources[3].Path != "root/overlays/dev.yml" {
t.Fatalf("overlay source = %#v", report.Sources[3])
}
}
func TestConfigSourcesHandlesEmptyInventoryAllPreflightAndWriterFailure(t *testing.T) {
reader := &capturedSourceReader{}
newContextCalls, acquireCalls := 0, 0
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
suite := commandkit.ConfigSuite[string]{
NewContext: func() string { newContextCalls++; return "context" },
Scopes: []commandkit.ConfigScope[string]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigSources}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
acquireCalls++
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Sources: reader}}, nil
}}},
}
if err := commandkit.RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "sources", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || newContextCalls != 0 || acquireCalls != 0 || output.Len() != 0 {
t.Fatalf("all preflight outcome=%#v contexts=%d acquires=%d output=%q", outcome, newContextCalls, acquireCalls, output.String())
}
outcome = app.Execute(context.Background(), []string{"config", "sources", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "scope: app\nsources: []\n" || reader.calls != 1 {
t.Fatalf("empty source report outcome=%#v output=%q reader calls=%d", outcome, output.String(), reader.calls)
}
reader.entries = []configkit.SourceInfo{{ID: "source-1", Label: "label with \"quotes\"", Path: "path with spaces.yml", Layer: configkit.LayerBase, Kind: configkit.SourceKindFile}}
var textOutput bytes.Buffer
outcome = app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: &textOutput, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(textOutput.String(), `label: "label with \"quotes\""`) || !strings.Contains(textOutput.String(), `path: "path with spaces.yml"`) {
t.Fatalf("quoted text source output outcome=%#v output=%q", outcome, textOutput.String())
}
partial := &configKitShowPartialWriter{failure: io.ErrClosedPipe}
outcome = app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: partial, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.DeliveryError, io.ErrClosedPipe) {
t.Fatalf("source writer failure = %#v", outcome)
}
}
func TestConfigShowWritesNativeSingleScopeAndFramesAggregateYAML(t *testing.T) {
const native = "%YAML 1.1\n# authored comment\n---\nvalue: null\nitems: []\nmissing: {}\n"
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
var calls []string
suite := commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{
{Name: "zeta", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
calls = append(calls, "zeta")
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML("%YAML 1.1\n# zeta comment\nvalue: zeta\n")}}, nil
}},
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
calls = append(calls, "app")
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML(native)}}, nil
}},
}}
if err := commandkit.RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var single bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "show"}, commandkit.Streams{Out: &single, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || single.String() != native {
t.Fatalf("single show outcome=%#v output=%q", outcome, single.String())
}
calls = nil
outcome = app.Execute(context.Background(), []string{"config", "show", "--format", "text"}, commandkit.Streams{Out: &single, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || len(calls) != 0 {
t.Fatalf("text format outcome=%#v acquisitions=%v", outcome, calls)
}
calls = nil
var aggregate bytes.Buffer
outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &aggregate, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !reflect.DeepEqual(calls, []string{"app", "zeta"}) {
t.Fatalf("aggregate outcome=%#v acquisitions=%v", outcome, calls)
}
if !strings.Contains(aggregate.String(), "%YAML 1.1\n# authored comment\n---\n# scope: app\n") || !strings.Contains(aggregate.String(), "...\n%YAML 1.1\n# zeta comment\n---\n# scope: zeta\nvalue: zeta\n") {
t.Fatalf("aggregate framing = %q", aggregate.String())
}
decoder := yaml.NewDecoder(bytes.NewReader(aggregate.Bytes()))
var first, second map[string]any
if err := decoder.Decode(&first); err != nil {
t.Fatal(err)
}
if err := decoder.Decode(&second); err != nil {
t.Fatal(err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
t.Fatalf("third YAML document decode error = %v, value=%#v", err, extra)
}
if first["value"] != nil || first["items"] == nil || first["missing"] == nil || second["value"] != "zeta" {
t.Fatalf("decoded documents = %#v and %#v", first, second)
}
if _, exists := first["absent"]; exists {
t.Fatalf("absent field was added to native document: %#v", first)
}
}
func TestConfigShowAcquiresAllBeforeOutputAndTracksWriterFailures(t *testing.T) {
failure := errors.New("second scope failed")
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
calls := 0
suite := commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
calls++
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML("value: app\n")}}, nil
}},
{Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
calls++
return commandkit.Acquired[commandkit.ConfigView]{Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(configkit.SeverityWarning, "provider warning")}}, failure
}},
}}
if err := commandkit.RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
if !errors.Is(outcome.Cause, failure) || outcome.ReportIncomplete || output.Len() != 0 || calls != 2 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != configkit.SeverityWarning {
t.Fatalf("acquisition failure outcome=%#v output=%q calls=%d", outcome, output.String(), calls)
}
app, _ = commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML("value: app\n")}}, nil
}}}}); err != nil {
t.Fatal(err)
}
partial := &configKitShowPartialWriter{failure: io.ErrClosedPipe}
outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: partial, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, io.ErrClosedPipe) || !errors.Is(outcome.DeliveryError, io.ErrClosedPipe) || len(partial.written) == 0 {
t.Fatalf("writer failure outcome = %#v", outcome)
}
}
type configKitShowPartialWriter struct {
written []byte
failure error
}
func (writer *configKitShowPartialWriter) Write(data []byte) (int, error) {
n := len(data)
if n > 4 {
n = 4
}
writer.written = append(writer.written, data[:n]...)
return n, writer.failure
}
func TestConfigKitScopeUsesDefaultsOverlayAndRecordedResolution(t *testing.T) {
type configuration struct {
Mode string `yaml:"mode"`
Defaulted string `yaml:"defaulted"`
}
filesystem := configkit.NewFilesystem("fixture", fstest.MapFS{
"config/main.yml": &fstest.MapFile{Data: []byte("mode: base\n")},
"config/overlays/dev.yml": &fstest.MapFile{Data: []byte("mode: overlay\n")},
})
definition := configkit.Definition[configuration]{
Defaults: func() configuration { return configuration{Defaulted: "from default"} },
Resolve: func(input *configuration, resolution *configkit.Resolution) error {
resolution.Record(configkit.Keys("mode"), configkit.ChangeNormalization, "normalized for use")
input.Mode = strings.ToUpper(input.Mode)
return nil
},
}
var received commandkit.Selection
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "Application settings.", func(_ context.Context, selection commandkit.Selection, _ struct{}) (*configkit.Result[configuration], error) {
received = selection
return configkit.LoadLayered(configkit.Input{Source: filesystem.File(selection.Config.Value)}, configkit.LayeredOptions[configuration]{
Definition: definition,
Overlay: selection.Overlay.Value,
OverlayFilesystem: &configkit.FilesystemContext{Root: "config", BaseDir: "config", FS: &filesystem},
})
})
if err != nil {
t.Fatal(err)
}
selection := commandkit.Selection{
Config: commandkit.Supplied[string]{Value: "config/main.yml", Set: true},
Overlay: commandkit.Supplied[string]{Value: "dev", Set: true},
}
acquired, err := scope.Acquire(context.Background(), commandkit.ConfigRequest{Operation: commandkit.ConfigValidate, Selection: selection}, struct{}{})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(received, selection) {
t.Fatalf("loader selection = %#v, want %#v", received, selection)
}
if acquired.Value.Effective == nil || acquired.Value.Sources == nil || acquired.Value.Explanation == nil {
t.Fatalf("ConfigKit result facets = %#v", acquired.Value)
}
if got := acquired.Value.Sources.Sources(); len(got) < 2 {
t.Fatalf("sources = %#v, want base and overlay", got)
}
var effective bytes.Buffer
if err := acquired.Value.Effective.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if !strings.Contains(effective.String(), "mode: OVERLAY") || !strings.Contains(effective.String(), "defaulted: from default") {
t.Fatalf("effective configuration = %q", effective.String())
}
if explanation, ok := acquired.Value.Explanation.Explain(configkit.Keys("mode")); !ok || len(explanation.History) == 0 {
t.Fatalf("resolution explanation = %#v, present=%t", explanation, ok)
}
}
func TestConfigKitScopePreservesAbsentAndEmptySelections(t *testing.T) {
type configuration struct {
Value string `yaml:"value"`
}
var seen []commandkit.Selection
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(_ context.Context, selection commandkit.Selection, _ struct{}) (*configkit.Result[configuration], error) {
seen = append(seen, selection)
return configkit.Load(configkit.NoSource(), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{Defaults: func() configuration { return configuration{Value: "default"} }}})
})
if err != nil {
t.Fatal(err)
}
_, err = scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{})
if err != nil {
t.Fatal(err)
}
empty := commandkit.Selection{Config: commandkit.Supplied[string]{Set: true}, Overlay: commandkit.Supplied[string]{Set: true}}
_, err = scope.Acquire(context.Background(), commandkit.ConfigRequest{Selection: empty}, struct{}{})
if err != nil {
t.Fatal(err)
}
want := []commandkit.Selection{{}, empty}
if !reflect.DeepEqual(seen, want) {
t.Fatalf("loader selections = %#v, want %#v", seen, want)
}
}
func TestConfigKitScopeRequiresLoaderAndNonNilResult(t *testing.T) {
if _, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", nil); err == nil {
t.Fatal("accepted a nil ConfigKit loader")
}
scope, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[struct{}], error) {
return nil, nil
})
if err != nil {
t.Fatal(err)
}
if _, err := scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{}); err == nil || !strings.Contains(err.Error(), "nil result") {
t.Fatalf("nil result error = %v", err)
}
}
func TestConfigKitLoadErrorRetainsIdentityAndStructuredDiagnostics(t *testing.T) {
type configuration struct {
Name string `yaml:"name"`
}
var loadError error
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
_, loadError = configkit.Load(configkit.Mapping("bad source", map[string]any{"unknown": true}), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{}})
return nil, loadError
})
if err != nil {
t.Fatal(err)
}
acquired, acquireError := scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{})
var publicLoadError *configkit.LoadError
if acquireError != loadError || !errors.As(acquireError, &publicLoadError) {
t.Fatalf("load error identity = %v (original %v), public type=%T", acquireError, loadError, acquireError)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != configkit.SeverityError {
t.Fatalf("structured diagnostics = %#v", acquired.Diagnostics)
}
var rendered bytes.Buffer
if err := acquired.Diagnostics[0].Write(&rendered); err != nil {
t.Fatal(err)
}
if !strings.Contains(rendered.String(), `Configuration scope "app":`) || !strings.Contains(rendered.String(), `"bad source"`) {
t.Fatalf("grouped diagnostics = %q", rendered.String())
}
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
t.Fatal(err)
}
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Cause != loadError || len(outcome.Diagnostics) != 1 || strings.Count(diagnostic.String(), `Configuration scope "app":`) != 1 || output.Len() != 0 {
t.Fatalf("presented load failure outcome=%#v output=%q diagnostics=%q", outcome, output.String(), diagnostic.String())
}
}
func TestConfigValidationEmitsSuccessAsScopesCompleteAndStopsAtFailure(t *testing.T) {
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
var calls []string
newContextCalls := 0
failure := errors.New("promptkit could not load")
suite := commandkit.ConfigSuite[string]{
NewContext: func() string { newContextCalls++; return "runtime" },
Scopes: []commandkit.ConfigScope[string]{
{Name: "zeta", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
calls = append(calls, "zeta")
return commandkit.Acquired[commandkit.ConfigView]{}, nil
}},
{Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
calls = append(calls, "promptkit")
return commandkit.Acquired[commandkit.ConfigView]{}, failure
}},
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
calls = append(calls, "app")
return commandkit.Acquired[commandkit.ConfigView]{}, nil
}},
},
}
if err := commandkit.RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, failure) || !outcome.ReportIncomplete {
t.Fatalf("validation outcome = %#v", outcome)
}
if !reflect.DeepEqual(calls, []string{"app", "promptkit"}) || newContextCalls != 1 {
t.Fatalf("acquisitions = %v, NewContext calls=%d", calls, newContextCalls)
}
if output.String() != "app: valid\n" || !strings.Contains(diagnostic.String(), failure.Error()) {
t.Fatalf("output=%q diagnostics=%q", output.String(), diagnostic.String())
}
}
func TestConfigValidationYAMLWarningsAndShortWrites(t *testing.T) {
type configuration struct {
Name string `yaml:"name"`
}
warningScope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
return configkit.Load(configkit.NoSource(), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
Validate: func(_ *configuration, validation *configkit.Validation) {
validation.Warning(configkit.Keys("name"), "uses a demonstration name")
},
}})
})
if err != nil {
t.Fatal(err)
}
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{warningScope}}); err != nil {
t.Fatal(err)
}
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "validate", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
t.Fatalf("warning validation outcome = %#v", outcome)
}
if output.String() != "scopes:\n - name: app\n valid: true\n" || !strings.Contains(diagnostic.String(), "uses a demonstration name") {
t.Fatalf("output=%q diagnostics=%q", output.String(), diagnostic.String())
}
outcome = app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: configKitPartialWriter{}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, io.ErrShortWrite) || !errors.Is(outcome.DeliveryError, io.ErrShortWrite) {
t.Fatalf("report short write outcome = %#v", outcome)
}
var delivered bytes.Buffer
outcome = app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: &delivered, Err: configKitPartialWriter{}})
if outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || !errors.Is(outcome.DeliveryError, io.ErrShortWrite) {
t.Fatalf("diagnostic short write outcome = %#v", outcome)
}
}
type configKitPartialWriter struct{}
func (configKitPartialWriter) Write(data []byte) (int, error) {
if len(data) == 0 {
return 0, nil
}
return len(data) - 1, nil
}
func TestValidationReportDeliveryErrorsAtEachWriteBoundary(t *testing.T) {
reportErr := errors.New("report failed")
diagnosticErr := errors.New("diagnostic failed")
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
acquire := func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
return commandkit.Acquired[commandkit.ConfigView]{}, nil
}
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
{Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
}}); err != nil {
t.Fatal(err)
}
for _, format := range []string{"text", "yaml"} {
writes := 2
if format == "yaml" {
writes = 3
}
for at := 1; at <= writes; at++ {
for _, brokenErr := range []bool{false, true} {
out := &validationBoundaryWriter{failAt: at, err: reportErr}
errStream := io.Writer(io.Discard)
if brokenErr {
errStream = failingWriter{diagnosticErr}
}
result := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--format", format}, commandkit.Streams{Out: out, Err: errStream})
if result.Kind != commandkit.OutcomeOperational || !result.ReportIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || (brokenErr && !errors.Is(result.DeliveryError, diagnosticErr)) {
t.Fatalf("format=%s at=%d brokenErr=%t result=%#v", format, at, brokenErr, result)
}
if at > 1 && out.written.Len() == 0 {
t.Fatal("lost completed prefix")
}
}
}
}
failedApp, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterConfig(failedApp, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
return commandkit.Acquired[commandkit.ConfigView]{}, reportErr
}}}}); err != nil {
t.Fatal(err)
}
result := failedApp.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{})
if !errors.Is(result.Cause, reportErr) || result.DeliveryError != nil {
t.Fatalf("acquisition failure=%#v", result)
}
}
type validationBoundaryWriter struct {
failAt, calls int
err error
written bytes.Buffer
}
func (w *validationBoundaryWriter) Write(p []byte) (int, error) {
w.calls++
if w.calls == w.failAt {
return 0, w.err
}
return w.written.Write(p)
}

118
debug_events_test.go Normal file
View File

@@ -0,0 +1,118 @@
package commandkit
import (
"bytes"
"context"
"errors"
"strings"
"testing"
)
func TestDebugEventsFollowModeAndFinalizationOrder(t *testing.T) {
cause := errors.New("private operation cause")
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(inv Invocation, _ struct{}) Outcome {
if err := inv.Reporter().AtEnd().Info("deferred summary"); err != nil {
t.Errorf("queue summary: %v", err)
}
return Failure("operation failed").WithCause(cause)
},
})
app, err := New(Identity{Name: "Example"}, command, Alias("shortcut", "run"), FlagAlias("--trace", "--debug"))
if err != nil {
t.Fatal(err)
}
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"shortcut", "--verbose", "--trace"}, Streams{Err: &diagnostics})
if outcome.Kind != OutcomeOperational || !errors.Is(outcome.Cause, cause) {
t.Fatalf("outcome = %#v", outcome)
}
got := diagnostics.String()
for _, fragment := range []string{
"resolved command alias \"shortcut\" to \"run\"",
"selected debug reporting mode using --trace (alias for --debug)",
"deferred summary",
"handler completed for \"run\" with operational outcome and status 1",
"ERROR: operation failed",
} {
if !strings.Contains(got, fragment) {
t.Errorf("diagnostics %q omit %q", got, fragment)
}
}
if strings.Contains(got, cause.Error()) {
t.Fatalf("diagnostics exposed the operation cause: %q", got)
}
ordered := []string{
"resolved command alias",
"selected debug reporting mode",
"deferred summary",
"handler completed",
"ERROR: operation failed",
}
previous := -1
for _, fragment := range ordered {
index := strings.Index(got, fragment)
if index <= previous {
t.Fatalf("diagnostic event %q out of order in %q", fragment, got)
}
previous = index
}
}
func TestDebugEventsRespectFinalQuietSelection(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"run", "--debug", "--quiet"}, Streams{Err: &diagnostics})
if outcome.Kind != OutcomeSuccess || diagnostics.Len() != 0 {
t.Fatalf("outcome = %#v, diagnostics = %q", outcome, diagnostics.String())
}
}
func TestDebugCompletionRequiresAHandler(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
var diagnostics bytes.Buffer
app.Execute(context.Background(), []string{"unknown", "--debug"}, Streams{Err: &diagnostics})
if strings.Contains(diagnostics.String(), "handler completed") {
t.Fatalf("no-handler diagnostics include completion: %q", diagnostics.String())
}
}
func TestDebugDeliveryFailureAffectsInvocationOutcome(t *testing.T) {
ran := false
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome {
ran = true
return Success()
},
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
writeErr := errors.New("debug destination failed")
outcome := app.Execute(context.Background(), []string{"run", "--debug"}, Streams{
Err: executeReportingFailureWriter{err: writeErr},
})
if !ran {
t.Fatal("debug delivery failure prevented handler execution")
}
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeErr) {
t.Fatalf("outcome = %#v", outcome)
}
}

View File

@@ -0,0 +1,241 @@
package commandkit_test
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"reflect"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
)
func TestPublicInstallationAcceptsMixedDeclarations(t *testing.T) {
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "show",
Summary: "Show a value.",
Flags: []commandkit.Flag[struct{}]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, commandkit.Supplied[string]) {}),
},
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() },
})
declarations := []commandkit.Declaration{
command,
commandkit.Alias("inspect", "show"),
commandkit.FlagAlias("--fmt", "--format"),
commandkit.FlagAliasFor("show", "-f", "--format"),
}
app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...)
if err != nil {
t.Fatal(err)
}
if app == nil {
t.Fatal("New() returned a nil app")
}
}
func TestPublicAliasesPreserveCommandsAcrossDeclarationOrders(t *testing.T) {
for _, test := range []struct {
name string
rulesFirst bool
}{
{name: "commands first"},
{name: "routing rules first", rulesFirst: true},
} {
t.Run(test.name, func(t *testing.T) {
type runInput struct{ words commandkit.Supplied[[]string] }
type inspectInput struct{ id commandkit.Supplied[string] }
type showInput struct {
profile commandkit.Supplied[string]
format commandkit.Supplied[string]
}
var runParses, inspectParses, optionalParses int
var runValues [][]string
var inspectValues []string
var showValues []string
run := commandkit.NewCommand(commandkit.CommandSpec[runInput]{
Name: "run",
Arguments: []commandkit.Argument[runInput]{commandkit.RepeatArgument(commandkit.ArgumentMeta{Name: "words"}, 0, -1,
func(value string) (string, error) { runParses++; return value, nil },
func(target *runInput, value commandkit.Supplied[[]string]) { target.words = value },
)},
Run: func(_ commandkit.Invocation, value runInput) commandkit.Outcome {
runValues = append(runValues, append([]string(nil), value.words.Value...))
return commandkit.Success()
},
})
inspect := commandkit.NewCommand(commandkit.CommandSpec[inspectInput]{
Name: "inspect",
Arguments: []commandkit.Argument[inspectInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id"}, true,
func(value string) (string, error) { inspectParses++; return value, nil },
func(target *inspectInput, value commandkit.Supplied[string]) { target.id = value },
)},
Run: func(_ commandkit.Invocation, value inspectInput) commandkit.Outcome {
inspectValues = append(inspectValues, value.id.Value)
return commandkit.Success()
},
})
show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{
Name: "show",
Arguments: []commandkit.Argument[showInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "profile"}, false,
func(value string) (string, error) { optionalParses++; return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.profile = value },
)},
Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil),
func(value string) (string, error) { return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.format = value },
)},
Run: func(_ commandkit.Invocation, value showInput) commandkit.Outcome {
showValues = append(showValues, value.profile.Value+":"+value.format.Value)
return commandkit.Success()
},
})
commands := []commandkit.Declaration{run, inspect, show}
rules := []commandkit.Declaration{
commandkit.Alias("r", "run"),
commandkit.Alias("r full", "inspect"),
commandkit.Alias("inspect-shortcut", "inspect"),
commandkit.Alias("i", "inspect-shortcut"),
commandkit.FlagAlias("--fmt", "--format"),
}
declarations := append([]commandkit.Declaration(nil), commands...)
if test.rulesFirst {
declarations = append(append([]commandkit.Declaration(nil), rules...), commands...)
} else {
declarations = append(declarations, rules...)
}
app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...)
if err != nil {
t.Fatal(err)
}
if runParses != 0 || inspectParses != 0 || optionalParses != 0 {
t.Fatalf("installation invoked operand converters: %d/%d/%d", runParses, inspectParses, optionalParses)
}
for _, args := range [][]string{{"run", "full"}, {"r", "full", "item-1"}, {"i", "item-2"}, {"show", "profile", "--fmt=json"}} {
if outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("Execute(%q) = %#v, want success", args, outcome)
}
}
if !reflect.DeepEqual(runValues, [][]string{{"full"}}) {
t.Fatalf("canonical run operands = %#v, want [[full]]", runValues)
}
if !reflect.DeepEqual(inspectValues, []string{"item-1", "item-2"}) {
t.Fatalf("routed required operands = %#v", inspectValues)
}
if !reflect.DeepEqual(showValues, []string{"profile:json"}) || optionalParses != 1 {
t.Fatalf("optional operand/flag values = %#v, converter calls %d", showValues, optionalParses)
}
parsesBeforeReuse := [3]int{runParses, inspectParses, optionalParses}
secondApp, err := commandkit.New(commandkit.Identity{Name: "Second"}, declarations...)
if err != nil {
t.Fatalf("reusing declarations: %v", err)
}
if got := [3]int{runParses, inspectParses, optionalParses}; got != parsesBeforeReuse {
t.Fatalf("reinstalling declarations invoked operand converters: %v -> %v", parsesBeforeReuse, got)
}
if outcome := secondApp.Execute(context.Background(), []string{"run", "again"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("reused declaration execution = %#v", outcome)
}
if !reflect.DeepEqual(runValues, [][]string{{"full"}, {"again"}}) {
t.Fatalf("reused command declaration values = %#v", runValues)
}
})
}
}
func TestPublicRootGroupRegistrationAndFailedBatchRecovery(t *testing.T) {
type showInput struct{ format commandkit.Supplied[string] }
var formatParses, lateRuns int
var formats []string
show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{
Name: "show",
Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil),
func(value string) (string, error) { formatParses++; return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.format = value },
)},
Run: func(_ commandkit.Invocation, value showInput) commandkit.Outcome {
formats = append(formats, value.format.Value)
return commandkit.Success()
},
})
family := commandkit.RootGroup("config", "Configuration", show)
constructionFailure := errors.New("family construction failed")
if _, err := commandkit.New(commandkit.Identity{Name: "Broken"}, commandkit.WithError(family, fmt.Errorf("adapter: %w", constructionFailure))); !errors.Is(err, constructionFailure) {
t.Fatalf("New() error = %v, want retained family construction failure", err)
}
app, err := commandkit.New(commandkit.Identity{Name: "Example"})
if err != nil {
t.Fatal(err)
}
var before bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &before, Err: io.Discard})
if strings.Contains(before.String(), "config") {
t.Fatalf("root help showed an unregistered family: %q", before.String())
}
if err := app.Register(family,
commandkit.Alias("cfg-show", "config show"),
commandkit.Alias("cs", "cfg-show"),
commandkit.FlagAlias("--fmt", "--format"),
); err != nil {
t.Fatal(err)
}
if formatParses != 0 {
t.Fatalf("registration invoked flag converter %d times", formatParses)
}
var after bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &after, Err: io.Discard})
for _, fragment := range []string{"config", "Aliases:", "cfg-show", "cs"} {
if !strings.Contains(after.String(), fragment) {
t.Errorf("updated root help %q does not contain %q", after.String(), fragment)
}
}
if outcome := app.Execute(context.Background(), []string{"cs", "--fmt=yaml"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("registered alias execution = %#v", outcome)
}
if !reflect.DeepEqual(formats, []string{"yaml"}) || formatParses != 1 {
t.Fatalf("registered family values = %#v, converter calls %d", formats, formatParses)
}
if err := app.Register(commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "cfg-show", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})); err == nil {
t.Fatal("registration accepted a canonical command that conflicts with an existing alias")
}
var afterConflict bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &afterConflict, Err: io.Discard})
if !strings.Contains(afterConflict.String(), "cfg-show") {
t.Fatalf("failed relationship validation changed registered aliases: %q", afterConflict.String())
}
late := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "late", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
lateRuns++
return commandkit.Success()
}})
registrationFailure := errors.New("retained registration failure")
err = app.Register(commandkit.WithError(late, fmt.Errorf("family: %w", registrationFailure)))
if !errors.Is(err, registrationFailure) {
t.Fatalf("Register() error = %v, want retained failure", err)
}
if err := app.Register(late, commandkit.Alias("late-shortcut", "missing")); err == nil {
t.Fatal("registration accepted a batch whose alias target is absent")
}
var afterFailure bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &afterFailure, Err: io.Discard})
if strings.Contains(afterFailure.String(), "late-shortcut") || strings.Contains(afterFailure.String(), " late ") {
t.Fatalf("failed batch changed root help: %q", afterFailure.String())
}
if err := app.Register(late, commandkit.Alias("late-shortcut", "late")); err != nil {
t.Fatalf("registration after failed batch: %v", err)
}
var recovered bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &recovered, Err: io.Discard})
if !strings.Contains(recovered.String(), "late-shortcut") {
t.Fatalf("recovered root help omitted alias: %q", recovered.String())
}
if outcome := app.Execute(context.Background(), []string{"late-shortcut"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess || lateRuns != 1 {
t.Fatalf("recovered alias execution = %#v, runs %d", outcome, lateRuns)
}
}

9
doc.go
View File

@@ -1,3 +1,8 @@
// Package commandkit provides an instance-owned CLI dispatcher with typed // Package commandkit provides the core instance-owned CLI dispatcher, typed
// command declarations, configuration scopes, and PromptKit inspection. // command declarations, application-owned command and flag aliases, input
// bindings, invocation-scoped reporting, and outcomes with separate operation
// causes and presentation observations. New and App.Register accept canonical
// Command declarations and RoutingRule declarations together. Optional
// integration modules provide ConfigKit and PromptKit-specific commands;
// importing those modules does not install their commands.
package commandkit package commandkit

View File

@@ -4,6 +4,10 @@
Accepted. Accepted.
[ADR 0011](0011-captured-declarations-and-installation.md) refines
declaration construction, installation, optional-family namespace reservation,
and integration input/state lifetimes.
## Date ## Date
2026-10-06 2026-10-06

View File

@@ -2,7 +2,11 @@
## Status ## Status
Accepted. Partially superseded by [ADR 0010](0010-optional-integration-modules.md).
ADR 0010 narrows the root facade and public-type ownership decision to the core
module and places optional library dependencies in integration modules. The
remaining principles continue to apply; this record's decision text is retained.
## Date ## Date

View File

@@ -71,8 +71,8 @@ schemas for implemented operations.
CommandKit supplies shared handlers with predictable meanings and discovers their CommandKit supplies shared handlers with predictable meanings and discovers their
availability from registration. Each enabled operation needs its own data availability from registration. Each enabled operation needs its own data
contract: comparison needs safe snapshots, catalogs need enumeration, and profile contract: comparison needs supported native comparison results, catalogs need
display needs safe execution-target inspection. enumeration, and profile display needs safe execution-target inspection.
The [scope model](0009-configuration-scopes.md) and The [scope model](0009-configuration-scopes.md) and
[comparison policy](0008-semantic-configuration-diff.md) describe related [comparison policy](0008-semantic-configuration-diff.md) describe related

View File

@@ -55,7 +55,7 @@ A writer failure can leave a partial report and returns operational failure. The
outcome identifies incomplete delivery. Custom handler output remains outcome identifies incomplete delivery. Custom handler output remains
application-owned. [ADR 0007](0007-native-yaml-reports.md) covers `config show` report application-owned. [ADR 0007](0007-native-yaml-reports.md) covers `config show` report
preparation; [ADR 0008](0008-semantic-configuration-diff.md) defines successful preparation; [ADR 0008](0008-semantic-configuration-diff.md) defines successful
comparison, including differences and coverage gaps. comparison outcomes.
## Alternatives considered ## Alternatives considered

View File

@@ -68,6 +68,6 @@ allows a failure in one scope to prevent combined display. Explicit single-scope
display remains available for a narrower view. display remains available for a narrower view.
Additional structured schemas are compatibility contracts owned by CommandKit. Additional structured schemas are compatibility contracts owned by CommandKit.
Native concatenation can use existing writers; semantic comparison separately Native concatenation can use existing writers; semantic comparison uses the owning
requires the safe structured data described in library's supported comparison API under
[ADR 0008](0008-semantic-configuration-diff.md). [ADR 0008](0008-semantic-configuration-diff.md).

View File

@@ -1,4 +1,4 @@
# ADR 0008: Compare semantic snapshots with explicit sensitivity gaps # ADR 0008: Delegate effective configuration comparison to owning libraries
## Status ## Status
@@ -6,72 +6,79 @@ Accepted.
## Date ## Date
2026-10-06 2026-10-06; revised 2026-10-11.
## Context ## Context
Configuration comparison helps operators understand changes in final effective ConfigKit provides a native configuration comparison capability. Exposing it
values across roots and overlays. Formatting, authored file layout, and provenance through CommandKit gives applications a consistent operator interface and reduces
identifiers are different properties from effective-value equality. Sensitivity repeated command, selection, and reporting infrastructure. ConfigKit remains the
classification also limits the values available for inspection. authority for the meaning and disclosure of its comparison results.
Meaningful comparison requires structured effective snapshots with per-path
sensitivity data from the owning libraries. The comparison policy must preserve
value semantics while making limited coverage visible to operators.
## Decision ## Decision
For a scope declaring safe comparison support, `config diff` compares two ### Comparison ownership
independently resolved effective configurations. CommandKit checks that capability
before acquisition. Providers supply captured results through the owning
libraries' supported public inspection APIs.
Compare values using the owning library's safe structured inspection semantics, For ConfigKit-backed scopes declaring comparison support, `config diff` exposes
including defaults and recorded domain resolution. Preserve the distinctions ConfigKit's native comparison API. CommandKit checks capability availability before
among absent values, null, empty mappings, and empty sequences. Mappings are acquisition and coordinates selection, acquisition, adaptation, presentation,
compared recursively; ordered sequences are compared as whole values. and invocation outcomes.
For each path, use the union of the two sides' sensitivity classifications to Integrations delegate comparison to their owning libraries' supported public
protect both before and after presentation. A value classified on either side APIs. ConfigKit owns its comparison semantics and disclosure rules. CommandKit
receives the corresponding safe treatment in both snapshots. Hidden values follows the public contract of the ConfigKit version it adopts. When an adopted
produce explicit comparison gaps with their paths and supported reasons. upstream revision changes those semantics, the changes flow through to
CommandKit's integration. Upstream remains authoritative as the capability evolves.
Equality conclusions cover the comparable data. A comparison with gaps reports Application-supplied providers retain ownership of their implementation and
its limited coverage alongside observed differences. A complete comparison with loading effects. [ADR 0012](0012-typed-configuration-comparison-pairs.md) governs
an empty difference set supports an equality conclusion for the inspected paired selections, provider contracts, and typed input/shared-state lifetimes.
selection. Redaction masks are presentation data; classification metadata
determines comparison coverage.
Text and YAML reports include differences and coverage gaps. The exact report ### Safe disclosure and presentation
schema belongs in the [CLI reference](../cli.md) when implemented. Selection
syntax, inheritance rules, and detailed change records are outside the scope
of this decision.
A completed comparison returns success whether it finds differences or gaps; Adapt and present native comparison results in accordance with the adopted
load, comparison, and writer failures use the operational failure classification in upstream API's disclosure contract. The integration carries the upstream results
[ADR 0005](0005-invocation-outcomes.md). and relevant metadata through its public provider boundary.
CommandKit owns report presentation and may summarize or transform the returned
safe comparison data for different operator needs. Reports may vary in format,
organization, and detail while respecting safe disclosure and the evidence
provided by the comparison. Each report's documented meaning defines the
conclusions an operator can draw from it.
Report formats, schemas, and rendering choices belong in the
[CLI reference](../cli.md) when implemented. They can evolve independently of
this ownership decision under the [release policy](../policy/release.md).
### Invocation outcomes
A completed comparison returns success regardless of whether differences are
reported. Load, comparison, and writer
failures use the operational failure classification in
[ADR 0005](0005-invocation-outcomes.md). Applications may use comparison results
to make their own domain decisions.
## Alternatives considered ## Alternatives considered
| Alternative | Analysis | | Alternative | Analysis |
| --- | --- | | --- | --- |
| Rendered YAML line comparison | Reuses native display output. Formatting and masking become part of change interpretation. | | Compare safe snapshots in CommandKit | Gives CommandKit direct control over comparison, but requires it to maintain equality and sensitivity logic and account for values already withheld from snapshots. Native comparison keeps those responsibilities with the owning library. |
| Compare masks as literal strings | Uses visible snapshot bytes directly. Equal masks express equal presentation while hidden values remain uncertain. | | Compare rendered configuration text | Reuses existing output, but formatting and masking affect the apparent differences. Native comparison supplies evidence grounded in the library's effective-value model. |
| Use one side's sensitivity policy | Simplifies classification selection. A value's disclosure treatment depends on which snapshot supplies that policy. | | Leave comparison commands entirely to applications | Preserves application flexibility, but repeats selection, acquisition coordination, and presentation infrastructure across consumers. Integration providers retain application control within the common command system. |
| Fetch secret values for comparison | Gives access to additional equality evidence. Comparison acquires sensitive material and additional confidentiality responsibilities. | | Return a distinct status when differences are found | Supports assertion-oriented scripts, but couples successful inspection with a policy judgment about the result. The selected outcome model leaves that judgment to consumers. |
| Element-by-element sequence diff | Gives detailed collection changes. Stable element identity and alignment need another comparison contract. |
| A distinct status for observed differences | Supports assertion-oriented scripts. Inspection completion and change detection share an outcome signal. |
## Consequences ## Consequences
Operators receive meaningful effective-value changes with explicit uncertainty CommandKit supplies consistent comparison commands while relying on owning
where sensitivity limits comparison. Whole-sequence comparison keeps libraries for comparison semantics and safe disclosure. Dependency adoption
implementation scope focused at the cost of less detailed collection changes. includes adapting the integration and its documentation to upstream changes.
New library integrations
are assessed through their supported comparison contracts; each scope can expose
its available capabilities independently under
[ADR 0009](0009-configuration-scopes.md).
Diff support depends on safe snapshots preserving value semantics and sensitivity. Reports can serve different operator needs using the same native comparison
CommandKit applies this comparison policy to scopes declaring that capability. data. Their level of detail may differ, and sensitivity rules may limit what
Other operations and families can be integrated independently under can be shown. Maintaining the boundary between comparison evidence and report
[ADR 0004](0004-standard-inspection-surface.md). presentation allows those reports to evolve while preserving library ownership
and consumer control.
Applications can use the successful report as input to domain decisions or future
assertion workflows.

View File

@@ -0,0 +1,100 @@
# ADR 0010: Separate the core facade from optional integration modules
## Status
Accepted. Partially supersedes
[ADR 0003](0003-public-facade.md) as described below.
Partially superseded by [ADR 0011](0011-captured-declarations-and-installation.md)
for the consumer-migration consequence during the unreleased prototype
transition. It also supplies the registration and invocation-lifetime
decisions left open here. The module
boundary and remaining principles continue to apply; this record's decision
text is retained.
## Date
2026-10-09
## Context
CommandKit serves consumers that need its command infrastructure with different
combinations of library integrations, including none. A consumer using only the
core should not acquire dependencies belonging to optional integrations.
ADR 0003 establishes deliberate public contracts and an internal CLI backend
boundary. Those principles remain appropriate. Its initial single root facade,
however, combines core and integration responsibilities. Separate packages within
one module would separate imports while retaining shared module requirements.
## Decision
Retain the root `commandkit` package as the core module's public facade. It owns
core command declarations, registration, typed inputs, dispatch, help/version,
shared syntax, diagnostics, and invocation outcomes.
Provide optional library integrations as separate Go modules with their own
public package contracts. Each integration owns the standard command handlers,
provider contracts, reports, and adapters appropriate to its responsibility.
Applications select integrations and explicitly register their commands; importing
an integration does not install a command family.
Integrations provide convenient registration of their standard command families,
with standard declarations and handlers supplied by the integration. Applications
provide acquisition and domain-specific context without assembling individual
standard commands or repeating adapter plumbing. More specialized composition
remains available where concrete consumer needs justify it.
Dependencies point from integrations toward the core. The core's public API,
implementation, and module requirements remain independent of optional
integration libraries. An integration may depend on another integration when
its responsibility requires that relationship. Document such dependencies so
consumers can understand the modules they select.
Integrations compose through supported public core contracts and preserve
CommandKit's standard grammar, command ownership, help, and outcome conventions.
They do not access private core state or backend structures. Add public contracts
where concrete integration needs justify them, without requiring a general plugin
or backend-selection framework.
### Relationship to ADR 0003
The root-facade and public-type ownership provisions of ADR 0003 now apply to
the core module. Integration-owned public types and operations belong in their
respective public packages. The permission to expose deliberately selected
upstream library types applies within the integrations that depend on them;
it does not justify optional integration dependencies in the core.
ADR 0003's remaining principles continue to apply across the core and
integrations: deliberate exports, private implementation state, proportionate
internal layering, no exposure of internal or backend types, and preservation of
documented ownership, presence, callback lifetime, and error semantics. Application
input types and caller-supplied values and errors retain their existing handling.
Cobra/pflag remain behind the core's internal adapter.
This decision changes the scope of the single-facade decision rather than
replacing its contract discipline. ADR 0003 is partially superseded and its
original decision text remains intact.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Keep integrations in the root package | Preserves existing imports and simplifies releases, but core consumers acquire optional library dependencies. |
| Use separate packages within one module | Separates compiled imports and retains one release unit, but keeps integration requirements in the shared module graph. |
| Have applications implement integration handlers | Keeps the core independent, but recreates duplicated inspection commands and weakens consistent operator behavior. |
| Introduce universal provider contracts in the core | Could support several libraries through a common model, but adds abstractions and conversion obligations before concrete needs justify them. |
## Consequences
Consumers can adopt the core independently and add supported integrations as
needed. Integrations remain maintained parts of CommandKit, with shared CLI
conventions and owning-library semantics. Dependency independence does not mean
the core has no external implementation dependencies.
Moving existing integration APIs requires a documented consumer migration.
Integration modules add module-specific dependency, validation, and release work.
Core tests, examples, and tools must not reintroduce the optional dependencies
that this boundary removes.
Exact registration mechanics and release cadence are not selected here.
Current GoDoc and CLI contracts continue to own implemented behavior.

View File

@@ -0,0 +1,172 @@
# ADR 0011: Capture command declarations and install optional families explicitly
## Status
Accepted. Refines [ADR 0001](0001-command-dispatch-and-inputs.md) for declaration
construction, installation, optional-family namespace reservation, and integration
input/state lifetimes. Partially supersedes
[ADR 0010](0010-optional-integration-modules.md) only for its consumer-migration
consequence during the unreleased prototype transition, and refines its
convenient composition principle.
Refined by [ADR 0012](0012-typed-configuration-comparison-pairs.md) for
two-selection configuration comparison and its input/state lifetime. The
single-selection lifecycle and other decisions remain in force.
## Date
2026-10-10
## Context
Applications declare domain commands, while optional integrations assemble
standard command families. A common construction and installation model reduces
repeated error checks and registration plumbing. Integrations need public typed
composition contracts to assemble those families while preserving core and
backend boundaries.
Declaration and installation have different responsibilities. An optional family
claims a namespace when installed; selecting a dependency alone should leave the
application's command surface unchanged. During execution, scope-specific CLI
inputs and application-owned shared state also have distinct lifetimes. Making
those lifetimes explicit avoids accidental aliasing between selected scopes.
At the time of this decision, the implementation was an unreleased prototype
with no downstream consumers. Its API shapes carried no compatibility obligations
that would constrain the selected design.
## Decision
### Declaration capture and reuse
Applications and integrations construct the same opaque core command declarations
and install them through core application construction or registration. Initial
integrations provide complete-family factories and focused conveniences through
that common installation API. Suite and provider types remain integration-owned.
Factories eagerly capture metadata, bindings, capability selections, selector
applicability, and callback references. They copy the declaration collections
needed to isolate returned commands from later edits to source metadata.
Construction errors remain attached for installation to report. This model uses
eager declarations with public typed composition; a deferred preparation lifecycle
and untyped integration payload are outside the selected API.
Declarations may be reused across applications with independent registry and
invocation state. Callback references and captured application objects retain
caller ownership. Reuse provides no deep-copy or concurrent-dispatch guarantee.
Application helpers performing other fallible work may still return errors.
### Installation and retained errors
Core validates each installation batch before registry mutation, checking
retained errors, bindings, placement, and collisions. Failed application
construction returns no usable application; failed later registration preserves
the existing registry. Atomicity covers one installation call. Separate calls
and arbitrary callback effects retain their own lifetimes.
Report retained errors with useful command context and preserve supported wrapped
error identity. The aggregate-error representation remains an implementation
choice.
An explicit absence declaration contributes no command, help entry, or namespace.
Installation checks attached errors before skipping it. A zero command declaration
remains invalid. Integrations define their own empty-suite semantics: the initial
configuration suite needs at least one named scope, and metadata-only scopes
remain discoverable. A definition suite with no capabilities contributes nothing.
Binding validation applies to generated command declarations. Unused selector
bindings in an absent family are ignored; explicitly retained adapter-construction
errors still reach installation. A nil loader supplied to an adapter remains
invalid wiring even if its capabilities are subsequently cleared.
### Namespace ownership and placement
Core construction reserves help and version. An optional standard family owns
its complete root namespace upon installation. Imports and absent declarations
claim nothing. An application may occupy an uninstalled family name; installing
a conflicting declaration then fails in either registration order. Installation
preserves existing handlers and group ownership. Private application operations
remain outside installed standard families.
Standard families carry root-only placement metadata, which core enforces when
they are nested beneath another group. Placement is a public declaration property;
its enforcement is independent of package identity or a list of integration names.
A real empty root-only group claims its namespace and obeys placement checks.
Explicit absence contributes no node to place.
### Input and shared-state lifetimes
Integration suites distinguish typed CLI input from typed shared acquisition
state. Ordinary configuration inspection initializes one input per selected scope,
applies its applicable selector bindings, and initializes shared state once for
the inspection invocation. Input validation receives the scope identity and its
typed input. Named definition inspection uses one input and one shared state.
Providers receive both explicitly. ADR 0012 refines this lifecycle for comparison
pairs with two selections of one scope.
Availability preflight and applicable input checks precede shared-state
initialization and acquisition. Failures may stop later input factories.
Construction, installation, help, version, and scope discovery leave suite
input/state factories and acquisition providers uninvoked.
An omitted factory supplies its type's zero value, including nil pointers.
Applications provide initialization needed by their accessors and providers;
providers may intentionally accept nil. Allocation of application objects,
cloning, and aliases returned by factories remain caller responsibilities.
Core-owned parsed collections stay independent when applied to separate inputs.
Caller-owned objects and callback effects retain their own lifetimes.
### Typed binding composition
Core provides a narrow typed contract to capture converted values and apply a
selected binding subset to initialized input. It preserves binding identity,
defaults, and presence while keeping private setters, parser state, and backend
objects behind their existing boundaries. A copied binding retains its identity;
a newly constructed lookalike has its own identity.
Execution-required checks apply to the selected subset. Missing required input
is a usage failure; invalid binding selection or destination is a wiring failure
with operational classification at invocation. These categories remain identifiable
through supported wrapping and stop execution before shared-state initialization
and acquisition. Metadata defects detectable during assembly of generated
commands remain installation errors under the validation boundary above.
### Relationship to earlier decisions
For the unreleased prototype transition covered by this decision, compatibility
wrappers and a downstream migration guide were unnecessary. This exception to
ADR 0010's migration consequence is limited to that transition. Changes to
published contracts follow the [release policy](../policy/release.md).
ADR 0001 retains dispatcher, registry, typed-input, and application-ownership
principles. ADR 0010 governs module boundaries and dependency direction, with
[ADR 0003](0003-public-facade.md) governing deliberate exports and backend isolation
within that scope. ADRs 0004 through 0009 retain their operation, grammar, report,
and outcome decisions. Go declarations and GoDoc own exact implemented signatures.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Fallible constructors plus integration-specific registration helpers | Makes each construction failure immediate, but creates several assembly conventions and repeated consumer checks. Captured errors keep one installation boundary. |
| Deferred preparation callbacks | Allows installation to construct a family, but adds lifecycle and error timing beyond eager capture. No demonstrated assembly need requires it. |
| Reserve all known optional family names at core construction | Prevents later name conflicts, but consumes namespaces for integrations the application has not selected. Installed-family ownership keeps conflicts explicit without that restriction. |
| Separate family/plugin registration framework | Could encode ownership and composition, but a captured command with root-only placement and batch validation expresses the required behavior without another installation model. |
| One shared input/state object | Uses fewer type parameters and can share a cache through references in separate input values, but leaves the distinct lifetimes implicit and scope-input isolation to application conventions. Explicit input and state types make those lifetimes visible. |
| Clone hooks or automatic pointer allocation | Can hide input construction, but adds object-graph and initialization responsibilities that belong to the application. Explicit factories keep those choices visible. |
| Public binding getters/setters or an untyped value bag | Enables integration assembly, but exposes private mechanics or returns typed-input assembly to string lookup. Narrow typed composition addresses the actual need. |
## Consequences
Consumers assemble domain commands and optional families through one installation
boundary. Eager assembly errors reach that boundary before provider acquisition.
Namespace collisions remain explicit choices for the application to resolve.
Separate input and state types make initialization and sharing visible. Focused
conveniences support simple applications, while typed composition gives
integrations the public contracts needed to assemble their families.
The [architecture policy](../policy/architecture.md) owns normative boundaries;
roadmaps own implementation status and sequencing. Application loading, cache
design, startup, domain validation, callback effects, and process exit remain
consumer responsibilities.

View File

@@ -0,0 +1,108 @@
# ADR 0012: Prepare typed selection pairs for configuration comparison
## Status
Accepted. Refines [ADR 0011](0011-captured-declarations-and-installation.md) for
two-selection configuration comparison; its other decisions remain in force.
[ADR 0008](0008-semantic-configuration-diff.md) governs native comparison and
report presentation.
## Date
2026-10-11
## Context
Native effective-configuration comparison needs two completed loads. ConfigKit
requires results of the same configuration type, which an ordinary inspection
view does not expose for pairwise comparison. A generic adapter can retain that
type without introducing result assertions or a universal core provider model.
ADR 0011 separates a selected scope's typed input from shared acquisition state.
A comparison selects the same scope twice, potentially with different roots or
overlays. Reusing one input obscures which selection a provider receives; creating
separate shared state loses the invocation-wide lifetime chosen for inspection.
## Decision
### Selections and provider ownership
Comparison selects one scope and prepares an earlier/left selection and a
later/right selection. Omitted right selection fields inherit the corresponding
left field and supplied state. An explicit right override, including an empty
value, replaces that field. Providers retain interpretation of omitted defaults
and explicit empty values. Applicable domain selector values are shared between
the two sides initially; independent per-side domain selectors may be considered
for a concrete consumer need.
Equal selections are allowed. CommandKit does not skip acquisition merely because
their selection values are equal: caller-owned loading may produce different
effective results. Exact flag spellings belong in the CLI reference when
implemented.
Use a narrow optional comparison provider owned by the integration. Its request
groups each side's selection with its typed input and supplies one shared state
value separately. The provider returns acquired native comparison results
with diagnostics and an error through the existing acquisition convention.
Reuse useful upstream public record types rather than duplicating them solely
to rename fields. Exact exported signatures belong in Go declarations and GoDoc.
The typed ConfigKit adapter invokes the supplied loader for each reached side
and passes the successful typed results to `configkit.Diff`. It retains the
configuration type inside that adapter, without reflection, result type
assertions, private core access, or a comparison-facet framework. Applications
may supply their own pair provider through the same integration contract.
Declared capabilities determine required wiring. A comparison-only scope needs
its comparison provider, without an unrelated ordinary acquisition callback.
Ordinary operations continue to use their ordinary acquisition provider.
Retained adapter construction errors still apply when capabilities are narrowed.
Other scopes and families need not support comparison under
[ADR 0009](0009-configuration-scopes.md).
### Typed input and shared state lifetime
Capture comparison declarations and applicable selector bindings under ADR 0011.
Convert supplied flag values through core once, including on help paths; applying
the values to both inputs does not parse them again.
After availability preflight, initialize each reached side's typed input
separately, apply the same applicable captured selector values, and check that
input with the selected scope identity and that side's selection. Finish both
input checks before initializing shared state once and invoking the pair
provider. Failures may stop later factories or loads. Construction, installation,
help, version, and scope discovery invoke neither input/state factories nor
acquisition/comparison providers.
Existing factory ownership rules remain unchanged: an omitted factory supplies
its type's zero value, including nil pointers. Separate factory calls do not
guarantee distinct caller-owned objects. CommandKit does not allocate arbitrary
pointer types, clone application graphs, repair caller aliases, or supply or
repair application caches. Core-owned parsed collections remain independent
when applied to the two inputs; callback effects remain caller-owned.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Symmetric independently resolved left/right selectors | Makes each side explicit and avoids inheritance. It adds vocabulary and repetition for common base-versus-overlay comparisons. |
| One typed input for both sides | Requires fewer factory calls, but obscures side-specific selection checks and leaves mutation isolation implicit. |
| Acquire a comparison facet with each ordinary view | Shares single-side acquisition across operations, but requires a public compatibility contract between heterogeneous facets and additional handle or assertion machinery. |
| Separate shared state for each side | Isolates acquisition state, but loses the invocation-wide sharing already selected for inspection. Applications can choose isolation within their own pair provider if needed. |
## Consequences
Pair preparation has explicit input and state lifetimes while ordinary inspection
keeps its existing lifecycle. Generic result handling stays inside the integration
adapter; core gains no owning-library dependency or universal comparison model.
Shared state permits application-owned caches. A cache used by a pair provider
should distinguish selections and any input values affecting loading to avoid
reusing the left result for a different right selection. This is integration
guidance, not a CommandKit cache policy or guarantee about caller callbacks.
The [architecture policy](../policy/architecture.md) owns normative boundaries.
ADR 0008 governs delegation to upstream comparison semantics, safe disclosure,
and CommandKit's presentation responsibility. This decision does not add a new
equality engine or strengthen application
loading, object ownership, or concurrency contracts.

View File

@@ -0,0 +1,68 @@
# ADR 0013: Separate application alias routing from canonical command ownership
## Status
Accepted. Refines [ADR 0011](0011-captured-declarations-and-installation.md)
by extending its shared installation boundary to application routing rules.
Its canonical command ownership and declaration principles remain in force.
## Date
2026-10-19
## Context
Applications need alternate command paths and flag spellings that suit their
operator vocabulary. These conveniences should reuse the behavior supplied by
application commands and optional integrations. Canonical command families
already establish ownership of handlers, namespaces, inputs, and invocation
behavior.
A routing layer allows applications to choose alternate spellings while
preserving those ownership boundaries. Commands and routing rules have distinct
roles, and their relationships require validation during application assembly.
## Decision
CommandKit core owns alias declaration, resolution, and coordination with parsing
and help. Applications choose their alias mappings. Alias infrastructure is
available independently of optional integrations under the module boundaries
established by [ADR 0010](0010-optional-integration-modules.md).
Aliases belong to an application routing layer that directs invocations to
canonical commands and flags. Canonical definitions retain ownership of their
handlers, inputs, and namespaces. An alternate path within an integration's
namespace remains an application routing rule; the integration continues to own
the canonical command family and its operations.
Canonical commands and routing rules are distinct declaration kinds installed
through a common core assembly boundary. Installation validates their
relationships together, preserves canonical definitions and namespace ownership,
and applies the batch-atomicity and retained-error model of ADR 0011 to both
layers. Declaration capture and reuse follow that existing model.
An invocation through an alias uses the canonical target's parsing, typed
bindings, validation, and ordinary invocation lifecycle. Execution behavior
remains with the canonical command and its owning application or integration.
Core coordinates alternate routing and presentation through the same command
infrastructure.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Install aliases as wrapper commands | Reuses the command tree for discovery, but introduces additional command nodes and handler delegation into namespace ownership and invocation lifetimes. |
| Have applications rewrite arguments before dispatch | Gives applications direct control over shortcuts, but repeats routing infrastructure and separates alias handling from target validation, parsing, and help. |
| Register aliases through a separate assembly API | Makes routing declarations explicit, but splits validation of commands and their alternate routes across installation boundaries. Distinct declaration kinds express the separation within one atomic batch. |
## Consequences
Applications can tailor operator vocabulary while reusing canonical behavior.
Core maintains the relationship between routing rules and canonical definitions
as declarations are installed. Optional integrations continue to supply their
command families through the existing ownership model.
Routing, parsing, and help must coordinate across the two layers. Exact API
shapes, resolution rules, and presentation choices belong in GoDoc and the
[CLI reference](../cli.md) when implemented. Those details can evolve within
this architectural separation.

View File

@@ -0,0 +1,79 @@
# ADR 0014: Share command-line presentation within each invocation
## Status
Accepted. Extends [ADR 0005](0005-invocation-outcomes.md) with shared reporting
for each invocation. Its outcome classifications, standard status meanings,
output channels, error-cause preservation, and application ownership of process
exit remain in force.
## Date
2026-10-25
## Context
Applications need consistent control over command results, messages shown during
a run, and messages shown at its end. Sharing that control reduces repeated
output code across applications and integrations.
An application decides what an error means for its work. It may recover silently,
show a warning, or end the invocation. CommandKit needs the chosen outcome and
messages to present that decision to the operator.
## Decision
CommandKit core provides one reporting facility for each invocation. Application
commands, integration commands, and core commands use it for results, immediate
messages, and deferred messages. Core applies shared rules for visibility,
formatting, and delivery, and completes reporting when the invocation ends.
Callers choose the output destinations.
Applications decide how to respond to domain-specific errors and choose their
outcomes and messages. CommandKit may provide shared helpers for carrying,
inspecting, and presenting errors while leaving those decisions with the
application.
Applications supply explicit outcomes and explanations for failures. They may
also supply a Go error cause. CommandKit keeps that error unchanged for the caller
to inspect. Supplying a cause alone does not select an outcome or generate an
operator-facing message. CommandKit remains responsible for handling its own
errors, including routing, parsing, and presentation failures.
Integrations adapt upstream results and diagnostics into command outcomes and
messages. The upstream libraries retain ownership of their meanings and rules
for safe disclosure.
An Error message explains a failed invocation with a non-zero status. Its
explanation is presented at the end of the invocation, after normal reporting
finishes. The returned outcome carries that explanation. The application entry
point decides how to exit the process.
CommandKit reports rendering and delivery failures separately from the command's
explanation. A presentation failure produces an operational failure for standard
outcomes under ADR 0005. Application-defined domain classifications are preserved,
along with an already non-zero domain status. A successful domain outcome receives
a non-zero status if presentation fails. The original explanation and any supplied
cause remain available to the caller. Intentionally suppressed output is not a
delivery failure.
Reporting keeps the state needed to deliver messages and return the invocation
outcome. Logging and message history remain application concerns.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Let each application manage all presentation | Gives applications full control, but repeats visibility and delivery code across commands and integrations. |
| Have core decide whether domain-specific errors require recovery, a warning, or failure | Reduces some caller code, but gives core decisions that depend on application behavior. |
| Use separate facilities for results, immediate messages, and deferred messages | Lets each facility serve one purpose, but spreads visibility and final delivery across several paths. |
## Consequences
Applications and integrations share presentation behavior while choosing their
own results and messages. Core handles reporting failures consistently and keeps
them visible in the returned outcome.
GoDoc and the [CLI reference](../cli.md) define the implemented API, flags,
message formats, and delivery details. Those details can evolve within this
ownership model.

View File

@@ -66,6 +66,93 @@ version: dev
revision: "" revision: ""
``` ```
## Reporting
CommandKit applies one shared reporting policy to application handlers,
integrations, help, version, and CommandKit's own invocation diagnostics. The
caller passes result and diagnostic writers to `App.Execute`; substantive
results go to `Streams.Out`, and categorized messages, terminal explanations,
and usage guidance go to `Streams.Err`.
### Modes and visibility
Each invocation starts in ordinary mode. `--quiet`, `--verbose`, and `--debug`
are mutually exclusive; the last enabled occurrence selects the mode. They
accept presence or an explicit boolean value. A `false` occurrence clears the
selected mode only when it names the current mode, returning to ordinary mode;
it does not restore an earlier selection. Invalid boolean values are usage
errors, even if a later flag would select another mode.
| Category | `--quiet` | No mode flag | `--verbose` | `--debug` |
| --- | --- | --- | --- | --- |
| Substantive result | Suppressed | Shown | Shown | Shown |
| Terminal failure | Shown | Shown | Shown | Shown |
| Warning | Suppressed | Suppressed | Shown | Shown |
| Info | Suppressed | Suppressed | Shown | Shown |
| Debug | Suppressed | Suppressed | Suppressed | Shown |
| Progress, unless enabled explicitly | Suppressed | Suppressed | Suppressed | Shown |
`--progress` independently enables Progress; `--progress=false` disables it.
Both accept the existing boolean syntax, and the last occurrence wins. An
explicit setting overrides the mode default, except quiet always suppresses
Progress. Without an explicit setting, debug enables Progress; ordinary and
verbose modes leave it disabled. Progress does not enable Info, Warning, or
Debug.
### Placement and aliases
Reporting flags are available on application commands, the root, canonical
groups, help, version, and alias-only discovery help. Place them after the
complete command path; they may be interspersed with operands after that path.
For example:
```text
example export --quiet
example config show --verbose
example promptkit test all --debug --progress=false
example help config show --quiet
example version --quiet
```
Reporting flags do not go before or between command-path components. Tokens
consumed as values and tokens after `--` retain their normal meaning.
The reserved long spellings are `--quiet`, `--verbose`, `--debug`, and
`--progress`; application declarations cannot reuse them. There are no built-in
short spellings. Applications may define aliases such as `-q → --quiet`,
`-v → --verbose`, and `-d → --debug`; canonical and aliased occurrences share
the same ordering. Thus `example export --verbose -q` selects quiet mode.
### Channels and messages
Substantive reports use the result destination. Warnings, Info, Progress,
Debug, terminal explanations, and usage guidance use the diagnostic destination.
Simple Warning, Debug, and terminal failure messages begin with `WARN:`,
`DEBUG:`, and `ERROR:` respectively. Info, Progress, and substantive results
have no prefix. For multiline messages, the prefix appears on the opening line;
native structured details keep their own formatting without duplicate labels.
Progress is streamed as unprefixed lines; terminal redraw and progress bars are
not provided. A failed outcome with no message or detail uses the fallback
`ERROR: Command failed.`.
Quiet mode suppresses successful help and version output, as it does other
substantive results. Usage guidance and routing, parsing, and operational
failures remain visible in every mode. A recovered problem is reported only if
the application chooses to submit a Warning; an error cause by itself does not
create a message.
### Presentation failures
CommandKit continues rendering substantive output under quiet mode through a
discard destination. This suppresses writes to the caller's result writer while
still detecting report preparation, encoding, or rendering failures. An
intentional suppression is not a delivery failure. A failure while rendering or
writing requested content is retained in the returned `Outcome` and makes an
otherwise successful or usage invocation operational with status `1`. A domain
classification and its nonzero status are preserved; a zero-status domain
outcome becomes status `1` when presentation fails. Earlier bytes already
written remain visible.
## Registered command syntax ## Registered command syntax
Options are parsed after the complete command path and may be interspersed with Options are parsed after the complete command path and may be interspersed with
@@ -83,11 +170,82 @@ Typed inputs keep supplied state separate from defaults. An explicit empty,
false, or zero value is supplied. Help and version are available without false, or zero value is supplied. Help and version are available without
constructing application command inputs. constructing application command inputs.
## Application-owned aliases
Applications can install command and flag aliases alongside their canonical
commands. Aliases route an invocation to existing behavior; they do not add
handlers, commands, or flags to a canonical command family.
Command alias paths are sequences of command tokens relative to the application
root. The executable name, flags, flag values, and operands are not part of an
alias path. Matching uses complete tokens and chooses the longest matching
path. A group alias preserves its remaining command tokens, and aliases can
compose through chains. For example, `list collections` can route to
`promptkit list collections`, while `pk` can route to the `promptkit` group.
The target must resolve to a canonical command or group.
Aliases do not rewrite operands or supply arguments. In particular,
`promptkit test KIND` remains one canonical command whose `KIND` is an operand;
`all` is an operand value, not a child command. `smoke → promptkit test` is a
valid shortcut because it preserves the caller's `KIND` value.
Flag aliases can be application-wide or scoped to a canonical command path.
An application-wide alias is effective wherever its canonical target flag is
available and is ignored elsewhere. A scoped alias requires its canonical
command and target flag to be installed. The selected canonical command
determines applicable scoped aliases, so the alias also works when that
command is reached through another command path. A scoped mapping takes
precedence over an application-wide mapping with the same spelling.
Only tokens parsed as flag names participate in flag aliasing. Values, operands,
and tokens following `--` remain unchanged. This includes values that look like
flags, such as `--message=--hlep`. Aliased flags use the target's canonical
binding, conversion, default, required-value check, presence, and repetition
behavior. Mixed spellings preserve occurrence order: singleton flags use the
last successfully parsed occurrence, every supplied occurrence is converted,
and repeatable flags accumulate values in order.
Flag aliases accept one long spelling or one individual short spelling. Short
aliases use the canonical short-option cluster and attached-value grammar:
| Input | Interpretation |
| --- | --- |
| `-vq` | Two boolean flags, `--verbose` and `--quiet`. |
| `-cblue` or `-c blue` | Value `blue` for a value-taking `-c` flag. |
| `-vqcblue` | `-v`, `-q`, then `--color=blue`. |
| `-cvq` | Value `vq` for a value-taking `-c` flag. |
| `-v=false` | Explicit false for the boolean flag `-v`. |
| `-c=` | An explicitly supplied empty string. |
Boolean flags use presence as true; an explicit boolean value requires `=`.
The first value-taking short flag consumes the remaining cluster suffix or the
next argument. A consumed value is never interpreted as an alias. The canonical
short-option empty-value form `-c=` supplies an empty value, consistently with
`--color=` and `-c ""`.
Help reached through an alias describes the canonical command's flags and
operands while using the entered route in usage. It identifies the canonical
target, lists applicable aliases separately, and supports global help aliases
such as `--hlep → --help`. Help for an alias-only prefix lists its routes
without creating a command group; the bare incomplete prefix points to that
help. Help and alias registration do not construct command inputs or invoke
acquisition callbacks.
Usage diagnostics preserve the command path and flag spelling the caller used.
When a command alias resolved successfully, usage identifies its canonical
target while retaining the entered path in the diagnostic. An unresolved route
receives no invented canonical label. A flag conversion error identifies the
entered flag spelling and the failing occurrence while preserving the original
cause.
## Configuration scopes ## Configuration scopes
An application installs one configuration suite with `RegisterConfig`. Its An application installs the declaration returned by
`ckconfig.Command(ckconfig.Suite[Input, State]{...})` through
`commandkit.New` or `App.Register`. For the common single application scope,
`ckconfig.AppCommand` returns a convenience declaration. The installed
`config scopes` command lists scope names and the configuration operations `config scopes` command lists scope names and the configuration operations
currently installed for those scopes: currently available for those scopes:
```text ```text
example config scopes example config scopes
@@ -102,10 +260,10 @@ runtime context or acquire configuration. It reports the intersection between
declared scope capabilities and installed handlers, so an operation is listed declared scope capabilities and installed handlers, so an operation is listed
only when the corresponding command is available. only when the corresponding command is available.
Applications can register the `promptkit` scope with `NewPromptKitScope` to Applications can add `ckprompt.ConfigScope(loadEngine)` to the ConfigKit suite
expose PromptKit engine configuration through these handlers. This configuration to expose PromptKit engine configuration through these handlers. The scope
scope can be installed without registering PromptKit prompt or profile declares validate, show, sources, explain, and native diff. It does not install
definition commands. PromptKit prompt or profile definition commands.
### Validate configuration ### Validate configuration
@@ -152,8 +310,8 @@ interrupt the native value. Existing native markers and leading comments are
retained; documents without a marker receive one. Scope labels containing line retained; documents without a marker receive one. Scope labels containing line
control characters are quoted to keep them on one comment line. A provider control characters are quoted to keep them on one comment line. A provider
failure leaves the output stream empty. A rendering or output failure may leave failure leaves the output stream empty. A rendering or output failure may leave
an aggregate prefix, and the outcome reports an aggregate prefix; `Outcome.Presentation.Output` records incomplete delivery
incomplete report delivery. and the destination or rendering error.
### List configuration sources ### List configuration sources
@@ -172,14 +330,36 @@ sources:
kind: file kind: file
underlay_index: 0 underlay_index: 0
imported_from: null imported_from: null
destination: /server
placement: mapping
destination_unresolved: false
version_check:
path: /version
version:
present: true
null: false
text: "1"
location:
source_id: source-1
line: 2
column: 1
redacted: false
``` ```
Each source includes `id`, `label`, `path`, `layer`, `kind`, `underlay_index`, Each source includes `id`, `label`, `path`, `layer`, `kind`, `underlay_index`,
and `imported_from`. The import location is null when absent; otherwise it has and `imported_from`, plus the native destination pointer, placement, unresolved
`source_id`, `line`, and `column`. Owning values and source order are preserved, destination flag, and nullable `version_check`. Placement is `mapping` or
including empty fields, zero coordinates, and synthetic sources without paths. `sequence_item`. The import location is null when absent; otherwise it has
An empty inventory is `sources: []`. Text output begins with the scope heading `source_id`, `line`, and `column`. Version evidence preserves `path`, `present`,
and then prints the same source fields, quoting labels and paths. `null`, `text`, optional source `location`, and `redacted` as returned by
ConfigKit. Owning values and source order are preserved, including empty fields,
zero coordinates, false and null observations, and synthetic sources without
paths. An absent version check is null. An empty inventory is `sources: []`.
Text output begins with the scope heading and prints the same source fields,
quoting labels and paths. These fields project the selected ConfigKit source
records; CommandKit does not reload files or rerun version checks. See the
[ConfigKit v0.8.0 inspection API](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/inspection.go)
for native evidence definitions.
### Explain a configuration path ### Explain a configuration path
@@ -228,41 +408,111 @@ environment-variable name; effective and historical secret fragments remain
redacted. Empty location, inheritance, history, reason, and dependency lists are redacted. Empty location, inheritance, history, reason, and dependency lists are
serialized as `[]`. serialized as `[]`.
### Compare configuration selections
`config diff` prepares two selections for one scope that declares comparison
and invokes its comparison provider after input checks pass. The default scope
is `app`; `--scope all` is unsupported.
When an application installs `ckprompt.ConfigScope(loadEngine)`, the PromptKit
scope declares comparison and delegates it to PromptKit's captured
configuration inspection API. PromptKit owns the meaning of that native
comparison; CommandKit uses the existing `config diff` preparation and report
path.
`--config` and `--overlay` select the left side. `--against-config` and
`--against-overlay` independently override the right side. When a right-side
field is omitted, it inherits the corresponding left value and supplied state;
an explicit empty string overrides it and remains supplied. Equal selections are
allowed; their equality alone does not cause CommandKit to skip the provider.
YAML is the only format and the default. Explicit `--format yaml` is accepted;
`text` and other formats are usage errors. For example:
```text
example config diff --config before.yml --against-config after.yml
example config diff --config base.yml --against-overlay candidate
example config diff --config base.yml --overlay current --against-overlay ""
example config diff --scope promptkit --config before.yml --against-config after.yml
```
For ConfigKit-backed scopes, ConfigKit compares frozen effective typed values
after defaults and resolution; source or provenance differences alone do not
create a change. PromptKit delegates its native comparison to ConfigKit. The
report is one YAML document with `additions`, `changes`, and `deletions` in that
order. Additions and changes preserve semantic paths and contain right-side
values provided by the native comparison. Deletions contain canonical JSON
Pointer paths. Explicit null is a value; it is not a deletion. Changed sequences
and native structural replacement regions stay atomic.
ConfigKit pairs sensitivity across both loads. A concealed change can therefore
appear even when its two safe snapshots are equal. For those ConfigKit-backed
comparisons, the report omits separate redaction and hidden-change metadata: a
reported masked region changed, but that does not mean every member inside it
changed. Marker-looking public strings stay ordinary YAML values. An empty
comparison succeeds with this shape:
```yaml
additions: {}
changes: {}
deletions: []
```
The reduced report is a partial safe view, not a reloadable configuration or an
automatically applicable patch. Exceptional whole-root empty/null transitions
can be ambiguous because the report has no root envelope; ordinary field-level
null values and deletions retain their distinction.
Successful comparisons return status `0`, including empty results.
Unsupported scopes and format errors are usage errors; input checks retain the
existing usage/operational classification. Load and native comparison failures
are operational and preserve supported error identity.
The bundled `FromConfigKit` and `AppCommand` adapters invoke the supplied loader
for each reached side, left before right, then call native comparison. They label
native diagnostics with their left/right side and source legend, retain left
diagnostics if right loading fails, and gather both loads' diagnostics before
native comparison. Custom pair providers own their loading and diagnostics;
application loaders and caches determine whether a loader call reads sources
again.
The complete report is prepared before writing. Report preparation failures and
cancellation observed before report delivery starts leave CommandKit's report
output empty. Once writing starts, CommandKit does not interrupt the supplied
writer or roll back delivered bytes. A write error or short write is recorded
as incomplete delivery in `Outcome.Presentation.Output` and can leave partial
output.
## PromptKit definition inspection ## PromptKit definition inspection
Applications can install prompt and profile inspection independently with Applications can install prompt and profile inspection independently with
`RegisterPromptKit`. When acquisition is reached, the handler invokes its `ckprompt.Command(suite)`. The `ckprompt.FromEngine(loadEngine)` adapter
registered inspection callback once with the caller's selection and context. supplies these callbacks alongside native requirements discovery and the five
The bundled `NewPromptKitSuite` helper acquires an engine through the application catalogs. Its loader receives invocation context, configuration selection,
loader and asks PromptKit to inspect its captured named definition. The loader typed input, and shared state. Direct suite callbacks retain their own
can construct or reuse an engine; direct suite callbacks retain their own
implementation and lifecycle. implementation and lifecycle.
CommandKit's inspection path does not execute prompts, contact model providers, Prompt inspection uses an ID only. PromptKit returns any descriptive version
or resolve provider credential values. Application acquisition and direct metadata as an optional report field; there is no `--version` flag for selecting
callback effects remain application-owned. Prompt definitions and profile a prompt version. The prompt format and native selection behavior remain owned by
resolution remain owned by [PromptKit v0.16.0](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md).
[PromptKit's inspection API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/engine.go).
```text ```text
example promptkit show prompt customer/summary --version 2 --format yaml example promptkit show prompt customer/summary --format yaml
example promptkit show prompt customer/summary example promptkit show prompt customer/summary
example promptkit show profile customer-support --format yaml example promptkit show profile customer-support --format yaml
``` ```
Both commands require one ID operand and accept `--config`, `--overlay`, and Both commands require one ID operand and accept `--config`, `--overlay`, and
`--format text|yaml` (default `text`). Prompt inspection also accepts singleton `--format text|yaml` (default `text`). These commands have no built-in
`--version`, defaulting to the empty string. PromptKit owns version selection: `--scope` selection; their namespace is `promptkit`.
an omitted version works for a single-version ID, while an ambiguous ID is an
operational error. These commands have no built-in `--scope` selection; their
namespace is `promptkit`.
Prompt YAML has these fields. Input order is preserved and an empty input list is Prompt YAML preserves native input order and uses an optional `version` field.
`[]`. The prompt hash is an opaque owning-library equality value. The key is omitted when PromptKit returns no version and is retained when the
version is present, including an empty string. The prompt hash is an opaque
owning-library equality value.
```yaml ```yaml
prompt_id: customer/summary prompt_id: customer/summary
prompt_version: "2" version: "2"
prompt_hash: opaque-value prompt_hash: opaque-value
default_profile_id: customer-support default_profile_id: customer-support
inputs: inputs:
@@ -301,15 +551,363 @@ api_key_required: true
``` ```
Text uses labeled fields and includes nested `extra_params` values. A missing ID Text uses labeled fields and includes nested `extra_params` values. A missing ID
is a usage error. Blank, unknown, and ambiguous definition selections reach the is a usage error. Definition selection reaches the owning inspection API and
owning inspection API and produce operational results with supported public preserves supported error identity and cancellation.
error identity. Cancellation is also operational: standard handlers check the
caller's context at inspection boundaries, and PromptKit can return cancellation ## PromptKit requirements
from its own inspection methods. Supported cancellation identity is preserved.
When `Suite.InspectRequirements` is present, `ckprompt.Command(suite)` installs
`promptkit requirements show`. It accepts one prompt ID and uses PromptKit's
native selection and discovery rules:
```text
example promptkit requirements show briefing --campaign spring
example promptkit requirements show briefing --profile editorial --var mode=full
example promptkit requirements show briefing --session-id session-42 --var note= --format yaml
```
The command accepts:
| Flag | Values | Default | Meaning |
| --- | --- | --- | --- |
| `--config` | path string | omitted | Select the configuration input; explicit presence is retained. |
| `--overlay` | path string | omitted | Select one overlay; explicit presence is retained. Repeated occurrences use the last value. |
| Suite selectors | application-defined | per selector | The flags captured from `Suite.Selectors`. |
| `--profile` | string | empty | Pass the profile ID to PromptKit; empty uses its native selection rule. |
| `--session-id` | string | empty | Pass session context to PromptKit. |
| `--var` | repeatable `KEY=VALUE` | none | Supply caller-variable context for native discovery. |
| `--format` | `yaml` | `yaml` | Select the requirements report format. Other formats are usage errors for this operation. |
The `promptkit` namespace identifies the operation, so there is no built-in
`--scope` flag. An explicitly empty profile or session value retains PromptKit's
native empty-string meaning. Requirements inspection does not require a profile
to be execution-ready. See the
[PromptKit v0.16.0 consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md)
for native configuration, selection, and requirements behavior. The built-in
flags other than `--var` are singleton bindings; when repeated, the last value
is used. Suite selectors retain their declared binding behavior.
Each `--var` occurrence must contain `=`. CommandKit splits at the first
separator and preserves the exact key and remaining value. Empty keys and
values, whitespace, commas, and additional `=` characters are retained. All
occurrences are parsed; duplicate keys use the last value, while any malformed
occurrence is a usage error even if a later assignment is valid.
The YAML report contains selected identities, native completeness, and input,
variable, and issue inventories. False values and empty inventories are
retained. Input metadata preserves null versus an empty string. Variable entries
contain `name`, `kind`, `supplied`, and `explanation`. `supplied` reports native
exact-key presence, including an explicitly empty value, independently of
`kind` and `complete`. Explanations and issue descriptions are upstream-owned
human-readable summaries, not machine-readable details. The optional `version`
key is omitted when absent and retained when supplied by PromptKit.
For a prompt with the template `{{if .mode}}{{input "notes"}}{{end}}` and no
input declarations, omitting `mode` can produce a partial report:
```yaml
prompt_id: briefing
version: "2"
profile_id: editorial
complete: false
inputs:
- name: notes
kind: conditional
declared: false
content_type: null
description: null
explanation: This reference is not known to be reached on every execution path.
variables:
- name: mode
kind: required
supplied: false
explanation: ""
issues:
- code: missing_variable
input_name: ""
variable_name: mode
description: A missing variable prevents selecting a dependency-bearing branch.
```
PromptKit owns the inventory, classifications, explanations, completeness, and
structured issues. Successfully acquired and delivered partial reports return
status `0`; structured issues do not independently fail the command. Provider
errors, native selection errors, cancellation, and report delivery failures use
the operational path (status `1`) with supported cause identity. Syntax,
operand, variable-assignment, and format errors use usage status `2`.
### Concrete request validation
When `Suite.ValidateRequest` is present, `ckprompt.Command(suite)` also installs
`promptkit requirements validate`. It can be installed without discovery, and
discovery can remain installed without validation. The command accepts one
prompt ID and passes a concrete request to PromptKit's native
`Engine.ValidateRequest` preparation path:
```text
example promptkit requirements validate briefing --campaign spring --input topic=topic.txt
example promptkit requirements validate briefing --profile editorial --session-id session-42 --var mode=full --input notes=notes.txt
```
The command accepts:
| Flag | Values | Default | Meaning |
| --- | --- | --- | --- |
| `--config` | path string | omitted | Select the configuration input; explicit presence is retained. |
| `--overlay` | path string | omitted | Select one overlay; explicit presence is retained. Repeated occurrences use the last value. |
| Suite selectors | application-defined | per selector | The flags captured from `Suite.Selectors`. |
| `--profile` | string | empty | Pass the profile ID to PromptKit. |
| `--session-id` | string | empty | Pass session context to PromptKit. |
| `--var` | repeatable `KEY=VALUE` | none | Supply caller variables for the concrete request. |
| `--input` | repeatable `NAME=PATH` | none | Bind a file-backed PromptKit artifact. |
| `--format` | `yaml` | `yaml` | Select the success confirmation format. Other formats are usage errors. |
The built-in flags other than repeatable `--var` and `--input` are singleton
bindings; when repeated, the last value is used. Explicit empty `--config` or
`--overlay` values keep their supplied-presence information. The profile and
session fields use native empty-string semantics. PromptKit owns their
interpretation and selection errors.
Each `--var` and `--input` occurrence must contain `=`. CommandKit splits at the
first separator and preserves the exact key/name and remaining value/path.
Empty variable keys and values and empty input names and paths are passed to
native validation. File paths become `promptkit.File(PATH)` references without
being read, normalized, expanded, or rebased during argument binding. Duplicate
keys or names use the last value, but every occurrence is parsed.
On success, the YAML confirmation contains only the requested prompt ID and
`valid: true`:
```yaml
prompt_id: briefing
valid: true
```
Validation delegates concrete request preparation to PromptKit. Native
preparation may resolve prompt/profile resources, load artifact and schema
resources, render messages and session context, and perform applicable
configuration and credential checks. It does not make a model call or retain a
prepared execution snapshot. A successful report describes this preparation
invocation; it does not certify provider acceptance or guarantee later
execution. See PromptKit's
[`Engine.ValidateRequest` API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/engine.go).
Builder, engine-loading, and native validation errors use the operational path
(status `1`) with supported error identity and acquired diagnostics retained.
Malformed syntax, missing operands, and unsupported formats use usage status
`2`. Successful diagnostics use the common reporting policy. Report delivery
failures are recorded in `Outcome.Presentation.Output`, separately from the
operation cause.
### PromptKit catalogs
An installed `ckprompt.Command(suite)` exposes each catalog whose callback is
present:
```text
example promptkit list prompts
example promptkit list profiles --config settings.yml --campaign spring
example promptkit list backends --namespace team
example promptkit list namespaces --kind prompt --kind profile
example promptkit list collections
```
All catalogs accept `--config`, `--overlay`, applicable flags from
`Suite.Selectors`, and `--format yaml` (the default). Other formats are usage
errors. Prompt, profile, and backend listings accept namespace selection:
| Binding | Effect |
| --- | --- |
| omitted `--namespace` and `--all-namespaces` | Forward PromptKit's native default namespace filter. |
| `--namespace team` | Forward the named namespace filter. |
| `--namespace ""` | Select the unqualified namespace. |
| `--all-namespaces` | Forward the all-namespaces filter. |
| `--all-namespaces=false` | Do not select an override; a supplied namespace still applies, otherwise native default is used. |
The namespace binding is a singleton and its last occurrence is used.
Supplying a namespace while `--all-namespaces=true` is active is a usage error
before provider work. Namespace inventory accepts repeatable `--kind KIND`;
omitting `--kind` forwards PromptKit's native default kind selection. Repeated
values are forwarded in order. Collections have no namespace or kind filter.
Each operation writes one bare YAML sequence, retaining provider order and
native identities:
- Prompt entries are `{id, version?}`. The optional `version` key is omitted
when absent and retained for a present empty or nonempty value.
- Profiles, backends, and namespaces are strings.
- Collection entries are `{namespace, version?}`, where `version` projects
PromptKit's optional release version.
- Nil and allocated-empty inventories both render as `[]`.
```yaml
- id: team/briefing
version: "2"
```
```yaml
- autumn
- spring
```
A catalog result is an inventory, not a readiness check or execution result.
The owning provider controls membership and order. Namespace filters are passed
to PromptKit without local membership interpretation. See the
[PromptKit v0.16.0 consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md)
for native resource and selection definitions.
### PromptKit smoke testing
When `Suite.SmokeTest` is present, `ckprompt.Command(suite)` installs
`promptkit test KIND`. The special kind `all` runs the four concrete operations
sequentially in this order: `reachability`, `authentication`, `text_inference`,
and `structured_output`. Both forms use the same single-operation callback;
each aggregate call changes the seed kind before invoking it. The command
accepts:
```text
example promptkit test text_inference --campaign spring --prompt briefing --profile spring
example promptkit test all --campaign spring --prompt briefing --profile spring
example promptkit test authentication --backend local
```
| Flag | Values | Default | Meaning |
| --- | --- | --- | --- |
| `--config`, `--overlay` | path strings | omitted | Select PromptKit configuration; supplied presence is retained. |
| Suite selectors | application-defined | per selector | Captured flags from `Suite.Selectors`. |
| `--backend` | string | empty | Native backend ID. |
| `--prompt` | string | empty | Native prompt ID. |
| `--profile` | string | empty | Native profile ID. |
| `--endpoint` | string | omitted | Override the execution endpoint. |
| `--model` | string | omitted | Override the execution model. |
| `--temperature` | float | omitted | Override execution temperature. |
| `--max-tokens` | integer | omitted | Override execution maximum tokens. |
| `--top-p` | float | omitted | Override execution top-p. |
| `--timeout-seconds` | integer | omitted | Override the execution timeout. |
| `--service-tier` | string | omitted | Override the execution service tier. |
| `--reasoning-effort` | string | omitted | Override execution reasoning effort. |
| `--api-key-env` | string | omitted | Supply an API-key environment-variable name to native configuration. |
| `--transient-retries` | integer | omitted | Set the native inference transient retry count. |
| `--format` | `yaml` | `yaml` | Select the report format. Other formats are usage errors. |
The execution flags populate PromptKit's native execution override. The
`--api-key-env` value is a variable name, not a credential value. Application
selectors, loader policy, credentials, and client wiring remain application
owned. The `WithSmokeTesting` helper accepts an optional typed builder. A nil
builder forwards the seed; a supplied builder's complete native request is
authoritative. The helper retains a native report when PromptKit returns both a
report and an error.
A single report projects the native smoke report into YAML. It preserves native
false, unknown/null, and incomplete values and does not add a readiness score.
The report includes `kind`, `supported`, `applicable`, `completed`, `reason`,
`target`, `facts`, `timing`, `attempts`, `usage`, and `failure`. Its target
contains selected IDs, optional `version`, selection source, endpoint, effective
settings, and settings-presence flags. Attempt entries retain their own facts,
timing, failure, and token-usage evidence. Durations use Go duration strings;
token counts preserve `value`, `known`, and `complete`.
Here is a native projection as rendered by the command:
```yaml
kind: reachability
supported: false
applicable: null
completed: true
reason: http_diagnostics_unavailable
target:
backend_id: ""
profile_id: spring
prompt_id: briefing
version: "1"
default_source_backend_id: ""
selection_source: profile
endpoint: https://example.invalid/v1
effective_settings:
endpoint: https://example.invalid/v1
model: spring-model
temperature: 0
max_tokens: 0
top_p: 0
timeout_seconds: 600
service_tier: ""
reasoning_effort: ""
api_key_env: ""
extra_params:
campaign: spring
settings_presence:
temperature: false
max_tokens: false
top_p: false
timeout_seconds: false
facts:
credentials_supplied: null
endpoint_reachable: null
credentials_accepted: null
response_received: null
generation_completed: null
text_returned: null
expected_text_matched: null
structured_output_requested: null
valid_json: null
schema_matched: null
timing:
started_at: 2026-10-17T19:05:36Z
duration: 35.685µs
capacity_wait: null
request_duration: null
retry_delay: null
evaluation_duration: null
attempts: []
usage:
prompt_tokens: {value: 0, known: true, complete: true}
completion_tokens: {value: 0, known: true, complete: true}
total_tokens: {value: 0, known: true, complete: true}
cached_tokens: {value: 0, known: true, complete: true}
cache_write_tokens: {value: 0, known: true, complete: true}
failure: ""
```
Aggregate output is one YAML mapping with ordered `tests` entries. Each entry
has selected `kind`, nullable `report`, and optional `error` text. The inner
report keeps its native `kind`. This cancellation example shows two attempted
operations with no reports; later kinds are absent because cancellation stops
the sequence:
```yaml
tests:
- kind: reachability
report: null
error: "reachability operation failed"
- kind: authentication
report: null
error: "context canceled"
```
A successful delivered single operation returns status `0`. For `all`, all
four callbacks must return without error for status `0`. Native false, unknown,
unsupported, or incomplete observations do not independently change the
outcome. Operation errors use operational status `1`; aggregate runs continue
after individual errors, retain their reports and ordered error text, and join
causes for supported `errors.Is`/`errors.As` access. Cancellation stops later
operations and preserves results already acquired. Report encoding or delivery
failures are recorded in `Outcome.Presentation.Output`, separately from
operation causes. Syntax, operand, unsupported format, and conflicting
namespace selection are usage status `2`.
Native configuration, selection, and diagnostic meanings remain owned by
PromptKit. See the [v0.16.0 consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md),
[smoke API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/smoke_diagnostics.go),
and [engine API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/engine.go).
## Outcome status ## Outcome status
CommandKit usage failures use status `2`; operational failures use status `1`. CommandKit usage failures use status `2`; operational failures use status `1`.
Successful operations use status `0`. Application handlers may return a domain Successful operations use status `0`. Application handlers may return a domain
classification and status of their choice, which `Execute` preserves. The caller classification and status of their choice, which `Execute` preserves. The caller
chooses how to map an outcome to process exit. chooses how to map an outcome to process exit. A rendering or delivery failure
changes a successful or usage outcome to operational status `1`. A domain
outcome keeps its classification and nonzero status; a zero-status domain
outcome becomes `1` if presentation fails. The returned `Outcome.Presentation`
records output and diagnostic delivery observations separately from its
operation `Cause`.

View File

@@ -1,88 +1,318 @@
# Consumer guide # Consumer guide
CommandKit's supported Go API is the root `commandkit` package. Start with an CommandKit is distributed as three Go modules. Start with the core and add an
`App`, register CommandKit's standard inspection families, register application integration module only when the application uses that library. Core and
commands, and call `App.Execute` with the caller's context and streams. The integration command factories return canonical `commandkit.Command` values.
caller retains process startup, signal handling, and exit mapping. Applications may install those alongside application-owned `RoutingRule`
declarations through `commandkit.New` or `App.Register`; routing rules are
installed at the application root, not as group children.
## Module selection
| Import | Use when |
| --- | --- |
| `gitea.maximumdirect.net/eric/CommandKit` | The application needs typed command declarations and common CLI behavior. |
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. |
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | The application wants PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, request validation, or native smoke testing. |
The two integrations are independent optional imports. `ckprompt` uses the
public scope and error contracts from `ckconfig`; importing it does not add the
`config` family. An application that wants both families passes both returned
declarations to core. The [maintained examples](../../README.md#examples) show
core-only, ConfigKit-only, and composed applications.
## Go compatibility ## Go compatibility
The consumer minimum is declared in [go.mod](../../go.mod): Go 1.26.0. All three modules declare a consumer minimum of Go 1.26.0. The root module also
ReleaseKit v0.3.0 requires that version and is selected as a tool dependency in selects ReleaseKit v0.4.2 as a Go tool, which makes that tool's module graph part
the same module graph. The minimum therefore applies to library consumers even of the root consumer module requirements even though production packages do not
though production packages do not import ReleaseKit. Consumers using Go 1.25 import ReleaseKit. The [release policy](../policy/release.md#consumer-minimum-and-release-toolchain)
need a Go 1.26 or newer toolchain to adopt this revision. defines the support assumption. Maintainers use Go 1.26.8 for validation and
publication; see the [release procedure](../release.md#release-toolchain).
The [release policy](../policy/release.md#consumer-minimum-and-release-toolchain) ## Core commands
defines support assumptions and versioning for changes to that minimum.
Maintainers select their validation toolchain separately in the
[release procedure](../release.md#release-toolchain). Updating that toolchain's
patch version alone leaves the consumer minimum unchanged.
## A configuration scope Declare typed flags and operands with `CommandSpec[I]`, then capture them with
`NewCommand`. Bindings deliver `Supplied[T]`, which distinguishes an omitted
Use [`NewConfigKitScope`](../../config.go) to adapt one ConfigKit loader. The default from an explicitly supplied empty, false, or zero value.
loader receives the invocation's explicit `Selection`; the application decides
how those supplied config and overlay values map to its inputs.
```go ```go
scope, err := commandkit.NewConfigKitScope[appContext, appConfig]( package example
"app",
"Application settings.", import (
func(ctx context.Context, selection commandkit.Selection, app appContext) (*configkit.Result[appConfig], error) { "fmt"
return loadAppConfig(ctx, selection, app) "strconv"
},
commandkit "gitea.maximumdirect.net/eric/CommandKit"
) )
if err != nil {
return err type GreetInput struct {
Count commandkit.Supplied[int]
}
func Greeting() commandkit.Command {
countDefault := 1
return commandkit.NewCommand(commandkit.CommandSpec[GreetInput]{
Name: "greet", Summary: "Write a greeting.",
Flags: []commandkit.Flag[GreetInput]{
commandkit.ValueFlag(
commandkit.FlagMeta{Name: "count", Usage: "Number of greetings."},
&countDefault, strconv.Atoi,
func(input *GreetInput, value commandkit.Supplied[int]) {
input.Count = value
},
),
},
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
return commandkit.Failure("Could not write the greeting.").WithCause(err)
}
return commandkit.Success()
},
})
} }
err = commandkit.RegisterConfig(app, commandkit.ConfigSuite[appContext]{
Scopes: []commandkit.ConfigScope[appContext]{scope},
})
``` ```
`config show`, `config sources`, and `config explain` use the configuration The application supplies `context.Context`, argv without the executable name,
captured by that load. A scope can support only the operations it provides. and output streams to `App.Execute`; it decides how the returned status maps to
When several scopes share invocation-local application state, create it in process exit. `New` installs generated help/version and caller declarations as
`ConfigSuite.NewContext`; use typed `Selector` bindings to copy only the values one batch. `Register` validates each call as a batch before changing that App.
that apply to each scope. See the [composed example](../../examples/composed/README.md) Invalid declaration errors retain their wrapped identity for `errors.Is` and
for a cache shared by the `app` and `promptkit` scopes. `errors.As`.
## PromptKit configuration and definitions Declarations copy their metadata and slice inputs when captured, retain callback
references, and can be reused across applications. Copies of a typed binding
preserve its identity; a separately constructed lookalike does not. Supplied
values convert before input construction. A command's `NewInput` creates input
only when execution reaches it; help does not construct caller input or run
provider callbacks.
Register `NewPromptKitScope` with the config suite when you want the effective ## Invocation reporting
PromptKit configuration and its ConfigKit provenance available under `config`.
Register `NewPromptKitSuite` and `RegisterPromptKit` separately to enable
`promptkit show prompt` and/or `promptkit show profile`. Each adapter calls the
owning public inspection method and preserves acquisition diagnostics with a
failed named lookup. Definition inspection does not invoke execution APIs or
resolve provider credentials.
The [composed example](../../examples/composed/README.md) shares its ordinary app and `Invocation.Reporter()` supplies the reporter for the current handler. Use
engine-loading functions across the configuration and definition suites. It `Output()` or `RenderOutput()` for substantive results; use `Warn`, `Info`,
also keeps `session restore` as an application-owned typed command, including `Progress`, and `Debug` for categorized messages. Queue a summary with
its session data and domain outcome mapping. `AtEnd().Info` when it belongs after the handler returns. The
[CLI reference](../cli.md#reporting) owns flag grammar, visibility, prefixes,
and status behavior.
## Typed application commands Return terminal text with `Failure(message, details...)`, `Usage(message,
details...)`, or `WithExplanation` on a domain outcome. Attach an optional Go
error separately with `WithCause`. A cause is preserved for inspection, but it
does not choose the outcome or become user-facing text. Rendering callbacks
write to their supplied writer only while the callback runs; they must not
submit another message or start another rendering operation through the same
reporter.
Declare flags, operands, and a typed input struct with `NewCommand`. Bindings ```go
preserve whether a value was explicitly supplied, so an explicit zero can differ Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
from an omitted default. `RepeatFlag` retains occurrence order. The if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
[simple example](../../examples/simple/README.md) combines both features with a typed return commandkit.Failure("Could not write the greeting.").WithCause(err)
ConfigKit scope. }
return commandkit.Success()
}
```
## Outcomes and process exit After `App.Execute`, inspect the returned `Outcome`. Its `Cause` retains the
operation error; `Presentation.Output` and `Presentation.Diagnostics` report
delivery state and the first rendering or write errors for their destinations.
CommandKit does not retain a message transcript. Reporter operations for one
invocation are safe concurrently, but concurrent submission order is not
specified. The caller owns the writers passed to `Execute`; CommandKit does not
close them.
Handlers return `Success`, `Failure`, or an application-owned `Outcome`. The ### Migrating from the previous reporting API
caller chooses how a nonzero status maps to process exit; `Execute` never exits
the process. It writes CommandKit diagnostics to the supplied error stream and
returns the resulting classification and delivery information. Applications
keep their domain meanings and output in their own handlers.
Exact CommandKit contracts are documented on the exported declarations and | Previous API | Current API |
their GoDoc comments. ConfigKit owns configuration decoding and provenance; | --- | --- |
consult its [inspection API](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd/inspection.go). | `invocation.Streams.Out` / `.Err` | `invocation.Reporter().Output()` and categorized reporter methods. `Streams` remains the argument to `App.Execute`. |
PromptKit owns prompt formats and profile resolution; consult its [public | `Failure(err)` / `Usage(err)` | `Failure("operator-facing explanation").WithCause(err)` / `Usage("usage explanation").WithCause(err)`. Choose the explanation deliberately; the cause alone is not printed. |
engine methods](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/engine.go), | `Outcome.Diagnostics` or terminal `SeverityError` | Submit independent messages through reporter methods. Put failure context in `Explanation`; provider `Acquired.Diagnostics` remains passive data for the owning integration to submit. |
[configuration inspection](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/configuration_inspection.go), | `ReportIncomplete`, `DiagnosticDelivery`, or `DeliveryError` | Inspect `Outcome.Presentation.Output` and `.Diagnostics`, including each `DeliveryResult.State`, `RenderError`, and `WriteError`. |
and [inspection types](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/types.go).
## Application-owned aliases
Pass commands and aliases together as declarations. Integration factories can
be called directly in the installation list, and command-scoped flag aliases
name the canonical command path:
```go
app, err := commandkit.New(
identity,
ckprompt.Command(suite),
commandkit.Alias("pk", "promptkit"),
commandkit.FlagAliasFor("promptkit show prompt", "--fmt", "--format"),
)
```
For a reusable collection, use `[]commandkit.Declaration`. Go does not expand a
`[]commandkit.Command` into `...commandkit.Declaration`, so convert command-only
slices when composing them with routing rules:
```go
commands := []commandkit.Command{ckprompt.Command(suite)}
declarations := make([]commandkit.Declaration, 0, len(commands)+1)
for _, command := range commands {
declarations = append(declarations, command)
}
declarations = append(declarations, commandkit.Alias("pk", "promptkit"))
app, err := commandkit.New(identity, declarations...)
```
New and Register validate each supplied batch before publishing its commands
and routing rules. Factories capture their inputs eagerly; malformed or
conflicting rules are reported at installation. Identical rules are safe to
contribute from reusable helpers. The [core example](../../examples/core) and
[composed consumer](../../integrations/ckprompt/examples/composed) show complete
maintained wiring with ordinary command behavior alongside aliases. The
[Go declarations and GoDoc](../../command.go) own the public API contracts;
the [CLI reference](../cli.md#application-owned-aliases) owns routing and
operator-visible behavior.
## ConfigKit scopes
For an application with one ordinary app configuration loader,
`ckconfig.AppCommand` supplies the state-free convenience declaration:
```go
app, err := commandkit.New(
commandkit.Identity{Name: "Example", Version: "dev"},
ckconfig.AppCommand(loadAppConfig),
)
```
`loadAppConfig` has the signature
`func(context.Context, ckconfig.Selection) (*configkit.Result[T], error)`.
Construction, installation, and help do not call it. `Selection` preserves
whether config and overlay paths were explicitly supplied, including empty
values.
Use `ckconfig.Command(ckconfig.Suite[I, S]{...})` when the application needs
multiple named scopes, selector flags, scope-aware input checks, or shared
acquisition state or a custom comparison provider. A declared `Diff` capability
uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand` provide the
ConfigKit-backed implementation. Each comparison side gets its own initialized
and checked input, while state is shared by the invocation. Factory results
remain caller-owned, so separate initialization calls do not guarantee distinct
objects. See
[ckconfig.Suite's GoDoc](../../integrations/ckconfig/contracts.go) for factory
timing, omitted-factory zero values (including nil pointers), and ownership.
The application owns the state type and what it caches. `ckconfig.FromConfigKit`
adapts a typed ConfigKit loader without calling it during construction.
The `config` family provides scope discovery, validation, native effective YAML,
source inventory, path explanation, and ConfigKit-backed comparison for the
capabilities declared by its scopes. The [CLI reference](../cli.md#configuration-scopes)
owns operator syntax and report details.
## PromptKit configuration, definitions, and requirements
`ckprompt.ConfigScope(loadEngine)` returns a `ckconfig.Scope` for the PromptKit
engine's captured configuration. Add that scope to a `ckconfig.Suite` to expose
it under `config` with validate/show/sources/explain and native diff operations;
it does not install PromptKit catalog, definition, or requirements commands.
`ckprompt.FromEngine(loadEngine)` supplies eight callbacks: prompt, profile,
backend, namespace, and collection catalogs; named prompt and profile
inspection; and native requirements discovery. Concrete request validation and
smoke testing are separate capabilities, added explicitly with
`ckprompt.WithRequestValidation` and `ckprompt.WithSmokeTesting`. The suite has
ten independently optional callbacks in total. `ckprompt.Command(suite)` creates
only operations whose callbacks are present; discovery, validation, and smoke
testing can be installed independently. A composed suite can enable both
additional operations:
```go
suite := ckprompt.FromEngine(loadEngine)
suite = ckprompt.WithRequestValidation(suite, loadEngine, buildRequest)
suite = ckprompt.WithSmokeTesting(suite, loadEngine, nil)
suite.NewInput = newInput
suite.NewState = newState
suite.Selectors = selectors
definitionCommand := ckprompt.Command(suite)
```
A validation-only suite can start empty. With application types named `Input`
and `State`, a nil builder forwards the request fields populated by standard
flags directly to PromptKit:
```go
suite := ckprompt.WithRequestValidation(
ckprompt.Suite[Input, State]{}, loadEngine, nil,
)
validationCommand := ckprompt.Command(suite)
```
If the loader is nil, the returned suite retains a wiring issue for installation
to report, even if capabilities are narrowed later. A builder runs before the
engine loader and receives the seed request, selection, typed input, shared
state, and invocation context. It returns the
concrete request; the adapter does not merge seed fields back afterward. The
loader receives the original selection. Builders should start from the seed,
preserve operator values and bindings, and apply defaults only when a map key is
absent, so explicitly supplied empty values stay meaningful. Applications that
validate and then execute a request are encouraged to share their request
construction logic between those paths.
The eight catalog and inspection callbacks and the two explicit additions are
independently optional. For catalogs without named or requirements inspection,
narrow the suite before command capture:
```go
definitions := ckprompt.FromEngine(loadEngine)
definitions.InspectPrompt = nil
definitions.InspectProfile = nil
definitions.InspectRequirements = nil
definitionCommand := ckprompt.Command(definitions)
```
Each selected inspection, requirements, validation, smoke, or catalog operation
prepares one input, applies its captured selectors, checks that input, and
initializes one state value when those steps are reached. The bundled validation
adapter delegates concrete request preparation to PromptKit and carries its
native errors plus engine-loading diagnostics through the acquisition. It does
not call the model or retain a prepared execution snapshot. See
[ckprompt.Suite's GoDoc](../../integrations/ckprompt/adapters.go) for factory
timing, omitted-factory zero values, and caller-owned objects and effects.
The loader receives invocation context, explicitly supplied selection, input,
and state. `FromEngine` uses PromptKit's public listing and inspection methods,
including `Engine.InspectRequirements`; validation must be opted into separately
and uses `Engine.ValidateRequest`.
Catalog, named definition inspection, and requirements discovery do not render
messages or load request artifacts. Concrete request validation follows native
PromptKit preparation, which can resolve prompt/profile resources, load
artifacts and schemas, render messages, and perform applicable configuration
and credential checks. It does not make a model call or certify that a later
execution will succeed. Application loaders and direct callbacks retain their
own effects. Smoke testing explicitly invokes one native PromptKit diagnostic
or all four in order. Native inference checks use the configured public
`promptkit.LLMClient`; applications retain ownership of client and probe
configuration. A builder may customize the seeded native request, and its
complete return value is authoritative. The [CLI reference](../cli.md#promptkit-smoke-testing),
[definition inspection](../cli.md#promptkit-definition-inspection), and
[requirements contract](../cli.md#promptkit-requirements) own operator syntax
and reports.
The [composed example](../../integrations/ckprompt/examples/composed/README.md)
combines app and PromptKit config scopes, comparison and catalogs, named and
requirements inspection, request validation, smoke testing, and an
application-owned `session restore` command.
## Outcomes and ownership
Core classifies usage and operational failures while preserving application
domain outcomes and supported error identity. Applications choose domain
classification and status. A terminal explanation is presented separately from
the optional operation cause, while report and diagnostic delivery observations
are available in `Outcome.Presentation`. The caller owns process startup,
signal handling, and exit mapping; `Execute` never exits the process.
Go declarations and GoDoc own exact exported contracts. ConfigKit owns
configuration decoding, validation, and provenance. PromptKit owns prompt
formats, profile resolution, and inspection details. See the
[ConfigKit v0.8.0 inspection API](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/inspection.go),
[PromptKit engine methods](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/engine.go),
[configuration inspection](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/configuration_inspection.go),
[smoke diagnostics](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/smoke_diagnostics.go),
and [PromptKit v0.16.0 consumer guidance](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md).

View File

@@ -1,17 +1,23 @@
# Developing CommandKit # Developing CommandKit
CommandKit currently contains adopted repository policies, accepted architectural CommandKit is distributed as a core Go module and two optional integration
decisions, identity and outcome primitives, typed command modules. Core owns captured declarations, typed input preparation, installation,
declarations, an instance-owned registry, a Cobra-backed routing and parsing dispatch, invocation reporting and outcomes, help, and version.
adapter, public `App.Execute` coordination, and generated help/version commands. `integrations/ckconfig` owns ConfigKit scope adaptation and reports;
Configuration suite registration, scope discovery, ConfigKit validation and `integrations/ckprompt` owns PromptKit engine,
reports, PromptKit engine configuration inspection, and optional PromptKit definition inspection, requirements discovery, and concrete request validation.
prompt/profile inspection commands are implemented. The maintained The maintained [consumer guide](consumers/guide.md) links to the three maintained
[consumer guide](consumers/guide.md) links to maintained examples. The consumers.
[README](../README.md) provides project orientation; the The [README](../README.md) provides project orientation; the
[documentation policy](policy/documentation.md#canonical-ownership) assigns [documentation policy](policy/documentation.md#canonical-ownership) assigns
canonical documentation owners. canonical documentation owners.
For application-owned aliases, use the
[CLI reference](cli.md#application-owned-aliases),
[Go declarations and GoDoc](../command.go), and
[ADR 0013](adr/0013-application-alias-routing.md). Keep deferred capabilities in
[deferred work](roadmap/deferred.md).
## Initial orientation ## Initial orientation
1. Inspect the worktree and preserve unrelated staged and unstaged changes. 1. Inspect the worktree and preserve unrelated staged and unstaged changes.
@@ -48,12 +54,15 @@ canonical documentation owners.
| Task | Read | | Task | Read |
| --- | --- | | --- | --- |
| Implemented CLI behavior | [CLI reference](cli.md) | | Implemented CLI behavior | [CLI reference](cli.md) |
| Public API usage and standard-suite wiring | [Consumer guide](consumers/guide.md), [configuration declarations](../config.go), [PromptKit declarations](../promptkit.go), and applicable ADRs | | Invocation reporting | [CLI reporting contract](cli.md#reporting), [consumer guide](consumers/guide.md#invocation-reporting), core GoDoc, [reporting roadmap](roadmap/reporting.md), and [ADR 0014](adr/0014-invocation-reporting.md) |
| Dispatcher, registration, or typed inputs | [Command declarations](../command.go), [bindings](../binding.go), [invocation](../execute.go), and [ADR 0001](adr/0001-command-dispatch-and-inputs.md) | | Application-owned aliases | [CLI reference](cli.md#application-owned-aliases), [Go declarations and GoDoc](../command.go), [consumer guide](consumers/guide.md#application-owned-aliases), and [ADR 0013](adr/0013-application-alias-routing.md) |
| Public API usage and standard-suite wiring | [Consumer guide](consumers/guide.md), [ckconfig package](../integrations/ckconfig/doc.go), [ckprompt package](../integrations/ckprompt/doc.go), and applicable ADRs |
| Dispatcher, registration, or typed inputs | [Command declarations](../command.go), [bindings](../binding.go), [invocation](../execute.go), and [ADR 0001](adr/0001-command-dispatch-and-inputs.md), refined by [ADR 0011](adr/0011-captured-declarations-and-installation.md) |
| Backend integration | [Internal overview](internal/overview.md), [adapter implementation](../internal/cobra/parse.go), and [ADR 0002](adr/0002-cobra-backend.md) | | Backend integration | [Internal overview](internal/overview.md), [adapter implementation](../internal/cobra/parse.go), and [ADR 0002](adr/0002-cobra-backend.md) |
| Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0003](adr/0003-public-facade.md), and the architecture policy | | Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0003](adr/0003-public-facade.md) as scoped by [ADR 0010](adr/0010-optional-integration-modules.md), and the architecture policy |
| Module ownership and public contracts | [Consumer guide](consumers/guide.md), [internal overview](internal/overview.md), [core GoDoc](../doc.go), [ckconfig GoDoc](../integrations/ckconfig/doc.go), [ckprompt GoDoc](../integrations/ckprompt/doc.go), and applicable ADRs |
| Deferred capabilities and proposals | [Deferred work](roadmap/deferred.md) | | Deferred capabilities and proposals | [Deferred work](roadmap/deferred.md) |
| Completed planning and implementation history | [Retired records in Git history](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap) | | Completed planning and implementation history | [Completed dependency upgrades, PromptKit validation, discovery, smoke testing, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/a6278a6d9f940948ec7745955d745523aaff0734/docs/roadmap), [earlier ConfigKit and PromptKit adoption and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap), [earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap), and [completed module implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/bb2528532b78d30e52f5082e491a5e1fd9b0fb99/docs/roadmap) in Git history |
| Release tooling | [Maintainer release procedure](release.md) | | Release tooling | [Maintainer release procedure](release.md) |
| Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [deferred efficiency work](roadmap/deferred.md#possible-further-efficiency-work) | | Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [deferred efficiency work](roadmap/deferred.md#possible-further-efficiency-work) |
| Licensing or incorporated material | [Project grant](../COPYRIGHT), [license texts](../LICENSE), and [attribution](oss/attribution.md) | | Licensing or incorporated material | [Project grant](../COPYRIGHT), [license texts](../LICENSE), and [attribution](oss/attribution.md) |
@@ -62,8 +71,10 @@ canonical documentation owners.
Read all applicable rows for cross-cutting work. Go declarations and GoDoc own Read all applicable rows for cross-cutting work. Go declarations and GoDoc own
current exported contracts; the CLI reference owns operator behavior. The current exported contracts; the CLI reference owns operator behavior. The
[maintainer release procedure](release.md) owns current release operations. [maintainer release procedure](release.md) owns current release operations.
Completed roadmaps and implementation evidence remain available in Git history; Completed roadmaps and implementation evidence remain available in Git history,
they are not assignments to restart completed work. The including [application-owned alias planning, implementation, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/f42aefe02d84e154855ac922173e6f367180929f/docs/roadmap)
and [PromptKit requirements implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/40499a43608bf60d1104cd8ee24caf18ac0e9e66/docs/roadmap).
These records are not assignments to restart completed work. The
[deferred-work record](roadmap/deferred.md) preserves future capability targets [deferred-work record](roadmap/deferred.md) preserves future capability targets
and unselected proposals separately from current contracts. and unselected proposals separately from current contracts.
@@ -88,8 +99,26 @@ Sibling-checkout references in retired planning records are historical review
evidence, not build dependencies. Implemented contracts belong in CommandKit evidence, not build dependencies. Implemented contracts belong in CommandKit
under their canonical owners. under their canonical owners.
Run `go test ./...` from the repository root to compile the module and run its The root module does not traverse nested modules. During development, use a
deterministic tests. Format changed Go files with `gofmt`. The local workspace for all three modules; it is ignored and must not be committed.
If a local `go.work` already exists, preserve it and add these module directories:
```sh
if [ -f go.work ]; then
go work use . ./integrations/ckconfig ./integrations/ckprompt
else
go work init . ./integrations/ckconfig ./integrations/ckprompt
fi
go test ./...
(cd integrations/ckconfig && go test ./...)
(cd integrations/ckprompt && go test ./...)
```
The workspace checks package behavior. Workspace-off checks with each module's
own dependency graph are separate evidence. For unpublished CommandKit dependency
versions, the [completed integration validation record](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap/implementation.md#stage-12-validate-the-complete-end-state-and-finish)
describes the disposable module-file procedure used during implementation.
Format changed Go files with `gofmt`. The
[maintainer release procedure](release.md) owns candidate preparation and [maintainer release procedure](release.md) owns candidate preparation and
publication commands. publication commands.
@@ -111,15 +140,30 @@ or publishing.
## Current public examples ## Current public examples
The maintained consumers under `examples/` compile with the module suite and The maintained consumers compile with their owning module suites and run without
run without external services: external services. The PromptKit smoke examples use local fixtures and the
example's configured client:
```sh ```sh
go run ./examples/simple config show go run ./examples/core workspace greet --count=0 --tag=first --tag second
go run ./examples/simple greet --count=0 --tag=first --tag second (cd integrations/ckconfig/examples/simple && go run . config show)
go run ./examples/composed config validate --scope all --campaign spring (cd integrations/ckconfig/examples/simple && go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml)
go run ./examples/composed promptkit show prompt briefing --campaign spring --format yaml (cd integrations/ckprompt/examples/composed && go run . config validate --scope all --campaign spring --limit=0)
go run ./examples/composed session restore session-42 --campaign spring (cd integrations/ckprompt/examples/composed && go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml)
(cd integrations/ckprompt/examples/composed && go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring)
(cd integrations/ckprompt/examples/composed && go run . promptkit list prompts --config fixtures/before.yml --campaign spring)
(cd integrations/ckprompt/examples/composed && go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml)
(cd integrations/ckprompt/examples/composed && go run . promptkit list backends --namespace team)
(cd integrations/ckprompt/examples/composed && go run . promptkit list namespaces --kind prompt --kind profile)
(cd integrations/ckprompt/examples/composed && go run . promptkit list collections)
(cd integrations/ckprompt/examples/composed && go run . promptkit show prompt briefing --campaign spring --format yaml)
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements show briefing --campaign spring)
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml)
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt)
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt --var mode=full --input notes=fixtures/notes.txt --format yaml)
(cd integrations/ckprompt/examples/composed && go run . promptkit test reachability --campaign spring --prompt briefing --profile spring)
(cd integrations/ckprompt/examples/composed && go run . promptkit test all --campaign spring --prompt briefing --profile spring)
(cd integrations/ckprompt/examples/composed && go run . session restore session-42 --campaign spring)
``` ```
The [consumer guide](consumers/guide.md) explains how their public API usage The [consumer guide](consumers/guide.md) explains how their public API usage

View File

@@ -1,48 +1,118 @@
# Internal package overview # Internal package overview
CommandKit ships one public package, `commandkit`, and one internal package, CommandKit's core module contains the public `commandkit` facade, the private
`internal/cobra`. This inventory describes the current implementation; the neutral metadata package, and the internal Cobra adapter. Two nested Go modules
[architecture policy](../policy/architecture.md) owns normative boundaries and own the optional ConfigKit and PromptKit integrations. The
[ADR 0003](../adr/0003-public-facade.md) records the public package boundary. [architecture policy](../policy/architecture.md) owns normative boundaries;
this page inventories the implemented owners.
## `commandkit` ## Root `commandkit`
The root package declares the public API and owns the instance registry, typed The root facade owns captured canonical command declarations, application-owned
command input preparation, invocation coordination, standard handlers, and routing rules, binding conversion, invocation coordination, the per-App
provider adapters. Configuration reports adapt ConfigKit's public records and registries, standard help/version commands, invocation reporting, and outcome
writers. PromptKit scope and definition handlers use PromptKit's public finalization.
inspection methods and map their results to CommandKit-owned reports. Its dependency graph has no ConfigKit or PromptKit integration dependency.
Applications provide typed callbacks and install routing rules alongside the
complete canonical command tree through the same batch API.
The root lazily caches one immutable neutral bridge tree per `App`, invalidating Canonical commands own handlers and parsing/help metadata; routing rules resolve
it after successful nonempty registration. For each `Execute`, it prepares one entered command paths and flag spellings to those canonical records without
adapter session and reuses that session for parsing, help, and usage. If a adding command nodes or handlers. The shared command compiler prepares supplied
callback registers commands during execution, metadata-dependent presentation values before constructing input. The batch installer validates retained
refreshes without repeating input callbacks. The root still prepares typed issues, placement, and collisions across both registries before publishing
input, invokes application/standard handlers, and coordinates outcomes and either. Each invocation uses one routing snapshot and backend session; a
diagnostic delivery. Application callbacks and their supported error identity successful registration invalidates the snapshot for a later invocation. The
are handled in the root through its typed contracts. root package owns app input and callback execution, not provider configuration
semantics.
Configuration registration owns and normalizes its declarations, then prepares Each execution creates one reporter backed by the caller's result and diagnostic
ordered scopes, name lookup, installed operations, and a discovery DTO. Command writers. The Cobra adapter returns parsed values, flag spellings, and reporting
handlers reuse that private metadata; each invocation still creates its context flag occurrences. Core reduces those occurrences in entered order after route
and invokes acquisition only for selected scopes. resolution, applies the final mode and progress selection, and presents any
collected parser events through that selection. The selected mode therefore
applies to command execution, help, and version; routing and parsing failures
remain visible in every mode.
The reporter owns invocation-scoped visibility, message formatting, managed
report rendering, delivery observations, and finalization. It presents queued
messages after the handler returns, then a failed outcome's explanation, and
closes its handles before `Execute` returns. It records rendering and write
errors separately for result and diagnostic destinations; the returned
`Outcome.Presentation` exposes those observations independently from the
operation cause. The reporter does not retain a transcript and does not close
or flush caller-owned writers.
Internally, one reporting-state mutex guards visibility, queued messages,
destination state, and delivery errors while serializing reporter operations
and writes. Rendering callbacks execute within that coordination boundary, so
they must not recursively submit or render through the same reporter. This is
an implementation detail supporting the callback contract; the mutex does not
coordinate writes made outside the reporter.
## `internal/alias` and `internal/commandpath`
`internal/commandpath` provides shared path parsing and token operations used by
declaration capture, routing, and backend selection. `internal/alias` compiles
application routing rules, resolves command prefixes while retaining token
provenance, and validates command-route termination. Neither package owns
handlers, application factories, or backend types.
## `internal/commandmeta`
This package owns the private neutral command, flag, argument, parsed-value, and
failure records shared between the public facade and backend adapter. It does
not import Cobra or optional integration modules. The facade and adapter
exchange these records without exporting backend-specific types.
## `internal/cobra` ## `internal/cobra`
This package owns the Cobra-specific command tree, argument/flag parsing, This package owns the Cobra command tree, argument/flag parsing, and help
and help rendering. A session owns one mutable backend tree for an invocation; rendering. A session owns the mutable backend tree for one invocation; parsing
parsing and presentation reuse it. Backend commands map directly to neutral and presentation reuse that session. The adapter returns raw values and neutral
declarations, and Cobra parent links reconstruct only the selected command path. syntax failures to core. It does not own the registry, typed input preparation,
The adapter returns raw parsed values and neutral syntax-failure data consumed integration declarations, provider acquisition, or native report rendering.
by the root package. It does not import `commandkit` and does not own registry Cobra and pflag imports remain inside this package.
policy, public command declarations, provider loading, or standard inspection
report rendering.
## External implementation boundaries ## Optional modules
ConfigKit owns typed configuration loading, source history, validation, `integrations/ckconfig` owns ConfigKit adaptation, scope capture, the standard
resolution, and path explanations. PromptKit owns captured engine `config` command family, and native validate/show/sources/explain/diff behavior.
configuration, prompt definitions, profile inheritance, and named inspections. It depends on the public core facade and ConfigKit; it does not depend on
CommandKit depends on their public module APIs and does not maintain parallel PromptKit. Each selected scope or comparison side receives an independently
schemas. Cobra and pflag are confined behind the internal adapter; YAML report initialized input, while one suite-owned state value is shared by an invocation's
encoding is performed by the root package. acquisitions or comparison callback.
`integrations/ckprompt` owns PromptKit engine loading, configuration-scope
adaptation and native comparison, plus optional prompt/profile/backend/namespace/
collection catalogs, named inspection, native requirements discovery,
concrete request validation, and native smoke testing. Validation and smoke
testing delegate to PromptKit and are independently opted into a suite. Smoke
operations use the application's native engine and its configured client or
probes. The integration uses public core and ckconfig contracts plus PromptKit's
public APIs. It does not add a configuration family when its declaration is
installed; callers compose `ckprompt.ConfigScope` with `ckconfig.Command`
separately when they want configuration operations alongside PromptKit
commands.
The three maintained consumers live at `examples/core`,
`integrations/ckconfig/examples/simple`, and
`integrations/ckprompt/examples/composed`. The
[consumer guide](../consumers/guide.md) links to those examples and explains the
public wiring. Run tests from each module; root `./...` does not traverse the
nested module directories. The [development guide](../development.md) gives the
workspace test commands. Workspace builds do not establish independent module
graph isolation.
## Upstream implementation boundaries
ConfigKit owns configuration decoding, validation, source history, and path
explanations. PromptKit owns its captured engine configuration, prompt
definitions, profile inheritance, named inspection, requirements discovery,
and concrete request validation, and native smoke-test semantics. CommandKit
adapts those public contracts without maintaining parallel schemas. Explicit
native smoke operations may use the configured client or probes; other
inspection, catalog, and request-validation paths do not make model calls.
Native request validation may load resources and artifacts, render messages,
and perform applicable configuration or credential checks through PromptKit.
Application loaders and domain behavior remain application owned.

View File

@@ -1,57 +1,5 @@
# Open Source Attribution Notices # Open Source Attribution Notices
## ConfigKit
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.5.0`.
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [LGPL v3.0](../../LICENSE) |
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd).
## PromptKit
Selected module: `gitea.maximumdirect.net/eric/promptkit` at `v0.12.0`.
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [LGPL v3.0](../../LICENSE) |
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3).
## PromptKit OpenRouter Catalog
Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3` at `v3.20260930.0` (transitive dependency).
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: MIT
License and notices: [MIT](licenses/MIT.txt) |
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0).
## PromptKit Rakestrawhome Catalog
Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3` at `v3.20260930.0` (transitive dependency).
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: MIT
License and notices: [MIT](licenses/MIT.txt) |
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/993443d88c451901f4c917fa00c204fd3b54d28d/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/993443d88c451901f4c917fa00c204fd3b54d28d).
## Cobra ## Cobra
Selected module: `github.com/spf13/cobra` at `v1.10.1`. Selected module: `github.com/spf13/cobra` at `v1.10.1`.
@@ -149,31 +97,6 @@ License and notices: [BSD 2-Clause](licenses/BSD-2-Clause.txt) |
[Source](https://github.com/russross/blackfriday/tree/4c9bf9512682b995722660a4196c0013228e2049). [Source](https://github.com/russross/blackfriday/tree/4c9bf9512682b995722660a4196c0013228e2049).
## JSON Schema
Selected module: `github.com/santhosh-tekuri/jsonschema/v6` at `v6.0.2` (transitive dependency).
SPDX-License-Identifier: Apache-2.0
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
[LICENSE](https://github.com/santhosh-tekuri/jsonschema/blob/29cbed948d24a04700eb94436416b18a07953b71/LICENSE).
[Source](https://github.com/santhosh-tekuri/jsonschema/tree/29cbed948d24a04700eb94436416b18a07953b71).
## Go text
Selected module: `golang.org/x/text` at `v0.14.0` (transitive dependency).
Copyright (c) 2009 The Go Authors. All rights reserved.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
[LICENSE](https://go.googlesource.com/text/+/6c97a165dd661335ff7bce6104a008558123c353/LICENSE) |
[PATENTS](https://go.googlesource.com/text/+/6c97a165dd661335ff7bce6104a008558123c353/PATENTS).
[Source](https://go.googlesource.com/text/+/6c97a165dd661335ff7bce6104a008558123c353).
## gocheck ## gocheck
Selected module: `gopkg.in/check.v1` at `v0.0.0-20161208181325-20d25e280405` (transitive test dependency). Selected module: `gopkg.in/check.v1` at `v0.0.0-20161208181325-20d25e280405` (transitive test dependency).
@@ -213,3 +136,55 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843). [Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348). [Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
## ReleaseKit
Selected module: `gitea.maximumdirect.net/eric/ReleaseKit` at `v0.4.2` (selected Go tool; not a runtime import).
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [LGPL v3.0](../../LICENSE) |
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8).
## Goldmark
Selected module: `github.com/yuin/goldmark/v2` at `v2.1.6` (ReleaseKit tool dependency).
Copyright (c) 2019 Yusuke Inuzuka
SPDX-License-Identifier: MIT
License and notices: [MIT](licenses/MIT.txt) |
[LICENSE](https://github.com/yuin/goldmark/blob/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c/LICENSE).
[Source](https://github.com/yuin/goldmark/tree/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c).
## Go modules
Selected module: `golang.org/x/mod` at `v0.40.0` (ReleaseKit tool dependency).
Copyright 2009 The Go Authors.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
[LICENSE](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f/LICENSE).
[Source](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f).
## Go tools
Selected module: `golang.org/x/tools` at `v0.49.0` (ReleaseKit tool graph dependency).
Copyright (c) 2009 The Go Authors. All rights reserved.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
[LICENSE](https://go.googlesource.com/tools/+/18332fec72972efbb8ab9881984fec2d8cfc2b58/LICENSE).
[Source](https://go.googlesource.com/tools/+/18332fec72972efbb8ab9881984fec2d8cfc2b58).

View File

@@ -2,16 +2,18 @@
**Base Policy Version:** 1.3 **Base Policy Version:** 1.3
**Last Updated**: 2026-10-08 **Last Updated**: 2026-10-26
**Upstream Source:** [architecture.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md) **Upstream Source:** [architecture.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
## Purpose And Ownership ## Purpose And Ownership
CommandKit is an opinionated Go library for standard inspection commands, CommandKit is an opinionated Go library for standard inspection commands,
consistent CLI syntax, and reduced application command infrastructure. This consistent CLI syntax, and reduced application command infrastructure. It provides
policy owns normative responsibility boundaries, dependency direction, and useful operator access to capabilities owned by its integrated libraries through
architectural constraints. ADRs own decision rationale; the shared command vocabulary, registration, selection, coordination, presentation,
and invocation outcomes. This policy owns normative responsibility boundaries,
dependency direction, and architectural constraints. ADRs own decision rationale; the
[implementation roadmaps](../development.md#task-specific-reading-guide) own [implementation roadmaps](../development.md#task-specific-reading-guide) own
proposals, sequencing, and implementation status. proposals, sequencing, and implementation status.
@@ -44,14 +46,23 @@ flexibility to express genuine application requirements within one command model
[ADR 0001](../adr/0001-command-dispatch-and-inputs.md) records the selected [ADR 0001](../adr/0001-command-dispatch-and-inputs.md) records the selected
CommandKit-owned dispatcher, per-instance registry, and typed-input model. CommandKit-owned dispatcher, per-instance registry, and typed-input model.
[ADR 0011](../adr/0011-captured-declarations-and-installation.md) refines declaration
capture, installation, optional-family ownership, and invocation input lifetimes.
CommandKit owns dispatch, common parsing, presence tracking, help, shared syntax CommandKit owns dispatch, common parsing, presence tracking, help, shared syntax
diagnostics, and the handlers and observable contracts of its standard families. diagnostics, and the handlers and CLI contracts of its standard families.
The integrated libraries own the domain semantics exposed through those handlers.
Applications register domain commands outside those families and own their Applications register domain commands outside those families and own their
meaning, effects, domain validation, artifacts, and operational outcomes. meaning, effects, domain validation, artifacts, and operational outcomes.
Namespaces provide the ownership boundary: standard `config` and an installed Namespaces provide the ownership boundary. Core construction reserves help and
`promptkit` family remain CommandKit-owned. CommandKit reports registration errors version. Optional standard families, including config and promptkit, own their
for attempts to replace their handlers or add private operations beneath them. root namespace when installed; importing an integration or declaring an absent
family claims no namespace. An application may occupy an uninstalled family name.
Installing the standard family then reports a collision, as does application
registration after the standard family is installed. Neither order merges groups
or replaces handlers. Installed standard families cannot acquire private
application operations beneath them. Standard-family declarations are root-only;
nesting a real family beneath another group reports an assembly error.
Applications own their executable entry points, context creation, signals, Applications own their executable entry points, context creation, signals,
stream selection, runtime construction, and final process exit. CommandKit stream selection, runtime construction, and final process exit. CommandKit
@@ -64,16 +75,31 @@ Its documentation recommends the same grammar and vocabulary for application-own
commands, including those outside its dispatcher, under commands, including those outside its dispatcher, under
[ADR 0006](../adr/0006-command-grammar.md). [ADR 0006](../adr/0006-command-grammar.md).
CommandKit preserves application-supplied outcome classifications and supported
error identity. It assigns responsibility for its diagnostics, avoids duplicate
presentation through its own paths, and reports write failures. Caller callbacks
retain control of their own output. [ADR 0005](../adr/0005-invocation-outcomes.md)
describes the outcome and delivery model.
Use explicit instance construction and composable command registration, not Use explicit instance construction and composable command registration, not
package-global or import-time registries. One definition supplies routing, package-global or import-time registries. Applications and integrations supply
parsing, and help metadata. Duplicate paths and attempts to replace reserved captured canonical command declarations through the core installation API.
commands report errors rather than letting registration order select a winner. Declaration factories capture metadata, bindings, and callback references eagerly;
construction errors remain attached for installation to report. Capture the
metadata collections needed to keep later edits to a source declaration from
changing a returned command. Application callback objects retain their ownership.
Canonical command declarations own handlers, parsing metadata, and help
metadata. Applications may provide separate routing rules in the same
installation batch; these rules resolve invocations to canonical commands and
do not add command nodes, handlers, or flags.
Validate each installation batch before mutating the instance registry. A failed
construction yields no usable application; a failed later registration leaves the
existing registry intact. Separate successful registration calls are not one
transaction. Duplicate canonical paths and attempts to replace reserved commands
report errors rather than letting registration order select a winner. Identical
routing rules are idempotent; competing targets for the same source and scope
report an error. Command-scoped flag routing refers to an exact canonical
command path.
Explicit absent-command declarations contribute no commands, help entries, or
namespace claims; attached errors are reported before skipping absence. An invalid
zero declaration is not absence. Integrations define their own empty-suite
semantics and retain invalid wiring errors even when capabilities are narrowed.
Standard families are assembled from declared provider capabilities. Each family Standard families are assembled from declared provider capabilities. Each family
specifies its own resources and supported operations; shared verbs retain specifies its own resources and supported operations; shared verbs retain
@@ -95,12 +121,69 @@ inspection/execution loading and focused acquisition callbacks are recommended
integration practices. Independent consumer use of other CLI frameworks remains integration practices. Independent consumer use of other CLI frameworks remains
application-owned. application-owned.
## Invocation Reporting And Outcomes
Use one reporting facility per invocation for command results, immediate messages,
and deferred messages. Core owns shared visibility, formatting, delivery, and
finalization. Applications and integrations choose their report content and
messages; callers choose the destinations. [ADR 0014](../adr/0014-invocation-reporting.md)
extends the outcome and delivery model in [ADR 0005](../adr/0005-invocation-outcomes.md).
Applications decide how to respond to domain-specific errors. CommandKit may
provide shared helpers for carrying, inspecting, and presenting errors while
leaving those decisions with the application. Supplying an error cause alone
does not select an outcome or generate an operator-facing message. CommandKit
handles its own errors, including routing, parsing, and presentation failures.
Preserve application-defined domain classifications and supplied error identity.
Report rendering and delivery failures separately from the command's explanation
and cause, applying the outcome rules in ADR 0005 and ADR 0014. Intentionally
suppressed output is not a delivery failure. Present terminal failure explanations
after normal reporting finishes, avoid duplicate presentation through core's own
paths, and retain the explanation and any supplied cause in the returned outcome.
Keep reporting state scoped to the invocation's presentation and returned outcome.
Logging and message history remain application concerns.
## Handler Inputs And Invocation State ## Handler Inputs And Invocation State
Handlers receive typed input structs with explicit flag and operand bindings. Handlers receive typed input structs with explicit flag and operand bindings.
Use presence-aware fields where omission matters; preserve explicitly supplied Use presence-aware fields where omission matters; preserve explicitly supplied
empty, false, and zero values independently of parser defaults. Context, streams, empty, false, and zero values independently of parser defaults. Context, streams,
and execution dependencies are separate from CLI inputs. and execution dependencies are separate from CLI inputs. Integration suites keep
their typed CLI input separate from shared acquisition state. For a configuration
operation, initialize and populate each selected scope's input independently,
applying only its applicable selector bindings; initialize shared state once for
the selected inspection invocation. Configuration input validation receives the
selected scope identity together with its applicable typed input. Named definition
inspection uses one input and one shared state. Complete applicable preflight
and input checks before shared state initialization and provider acquisition.
Factories need not run after a failure and remain uninvoked during construction,
installation, help, version, and scope discovery.
For two-selection configuration comparison, prepare and check each side's input
separately with its scope identity and selection, applying the same applicable
converted selector values to both. Finish both checks before initializing shared
state once and invoking the integration-owned pair provider under
[ADR 0012](../adr/0012-typed-configuration-comparison-pairs.md). Required provider
wiring follows declared capabilities; a comparison-only scope does not require
an unrelated ordinary acquisition callback.
Omitted input and state factories supply the respective type's zero value,
including nil for pointers. Caller factories and callbacks retain responsibility
for objects they return or reference; do not promise arbitrary deep copies,
repair caller-created aliases, or silently allocate arbitrary pointer types.
Core-owned parsed collections stay independent where values are applied to
separate inputs.
Provide typed binding composition through supported public core contracts when
integrations need to apply captured converted values to selected inputs. Preserve
binding identity and presence without exposing private setters or backend state.
Required-value checks apply to the selected binding subset. Missing required
operator input is a usage failure; invalid binding selection or destination is a
wiring failure with operational classification when detected during invocation.
Metadata defects detectable during assembly remain installation errors. Input
composition does not create a second parser or a raw-argument handler model.
CommandKit keeps parsed values, collections, and presence from one invocation out CommandKit keeps parsed values, collections, and presence from one invocation out
of subsequent runs. Storage allocation and safe reuse are implementation choices; of subsequent runs. Storage allocation and safe reuse are implementation choices;
@@ -114,10 +197,20 @@ Keep input declarations available without application initialization.
## Public Facade And Dependency Direction ## Public Facade And Dependency Direction
[ADR 0003](../adr/0003-public-facade.md) records the root `commandkit` facade and [ADR 0010](../adr/0010-optional-integration-modules.md) scopes the root
internal adapter boundary. The root is the sole initial supported public package `commandkit` facade to the core module and selects separate optional integration
and defines CommandKit's public named types. Additional public packages require a modules with their own public package contracts. It partially supersedes
deliberate decision based on a durable consumer responsibility. [ADR 0003](../adr/0003-public-facade.md), whose remaining facade and internal
adapter principles apply to the core and integrations alike.
The core owns command declarations, registration, bindings, dispatch, help/version,
shared syntax, diagnostics, and outcomes. Each integration owns its standard
handlers, provider contracts, reports, adapters, and integration-specific public
types. Dependencies point from integrations toward the core; an integration may
depend on another integration when its responsibility requires that relationship.
Document those dependencies. Core public signatures, implementation imports, and
module requirements remain independent of optional integration libraries,
including dependencies introduced through tests, examples, or tools.
Public handles retain private implementation state and expose explicitly selected Public handles retain private implementation state and expose explicitly selected
methods. Any forwarding and conversions must preserve type identity, value methods. Any forwarding and conversions must preserve type identity, value
@@ -125,9 +218,9 @@ ownership, callback lifetime, presence, and supported public error semantics. Do
not alias public types to internal or backend types, anonymously embed those types not alias public types to internal or backend types, anonymously embed those types
to promote methods, or expose implementation bridge methods. to promote methods, or expose implementation bridge methods.
Straightforward implementation may live in the root package without expanding Straightforward implementation may live in an owning public package without
its exports. Introduce internal packages, interfaces, and conversions where they expanding its exports. Introduce internal packages, interfaces, and conversions
simplify implementation or protect the backend boundary. Package organization and where they simplify implementation or protect the backend boundary. Package organization and
the amount of internal layering follow implementation needs. the amount of internal layering follow implementation needs.
Public signatures, fields, callbacks, constraints, and method sets must not expose Public signatures, fields, callbacks, constraints, and method sets must not expose
@@ -138,9 +231,13 @@ retain their documented handling; this boundary does not require inspecting or
transforming arbitrary application objects. transforming arbitrary application objects.
Standard contracts such as `context.Context` and `io.Writer` remain appropriate. Standard contracts such as `context.Context` and `io.Writer` remain appropriate.
Public types from integration libraries, such as ConfigKit and PromptKit, may be Upstream public types may be selected as deliberate dependencies in the
selected as deliberate dependencies. Application-owned input types remain integrations that own them; that permission does not justify optional library
application-owned. Do not expand exports merely to simplify internal assembly. dependencies in the core. Core owns shared presentation categories,
and integrations adapt upstream diagnostics while preserving selected outcome
and error semantics. Application-owned input types remain application-owned.
Expose narrow core composition contracts for demonstrated integration needs,
without exporting private assembly or backend structures.
## Backend And Library Integration ## Backend And Library Integration
@@ -158,9 +255,22 @@ them; a public backend selection or plugin framework is not part of the design.
ConfigKit and PromptKit own configuration composition, provenance, inspection ConfigKit and PromptKit own configuration composition, provenance, inspection
semantics, secret classification, and native rendering. Applications provide semantics, secret classification, and native rendering. Applications provide
schemas, acquisition, domain resolution and validation, and construction of schemas, acquisition, domain resolution and validation, and construction of
inspection providers. CommandKit exposes those capabilities without implementing inspection providers. CommandKit exposes those capabilities through supported
a parallel configuration or prompt engine. Use independently released public upstream APIs. The public contract of the adopted library version governs their
contracts; do not import sibling internal packages or depend on adjacent checkouts. meaning and disclosure rules. When CommandKit adopts an upstream revision,
changes to those semantics flow through its integration. Adaptation and
documentation follow the adopted contract; descriptions of upstream behavior
do not establish a separate, fixed CommandKit specification of that behavior.
CommandKit owns presentation and may summarize or transform returned safe data
for different operator needs. Reports can evolve in format, organization, and
detail while respecting upstream disclosure rules and the evidence supplied by
the capability. Exact report contracts belong in the CLI reference when
implemented; changes to CommandKit's published contracts follow the release
policy.
Use independently released public contracts; do not import sibling internal
packages or depend on adjacent checkouts.
Use narrow integration contracts, reusing upstream public types where useful and Use narrow integration contracts, reusing upstream public types where useful and
introducing CommandKit-owned abstractions where they simplify integration. introducing CommandKit-owned abstractions where they simplify integration.
@@ -170,8 +280,10 @@ Concrete integration needs guide provider representations under
Native report writers and safe inspection data preserve owning-library semantics. Native report writers and safe inspection data preserve owning-library semantics.
Loading all selected views before report output is a `config show` decision under Loading all selected views before report output is a `config show` decision under
[ADR 0007](../adr/0007-native-yaml-reports.md); other inspection operations define [ADR 0007](../adr/0007-native-yaml-reports.md); other inspection operations define
their own output timing. Semantic comparison uses supported safe snapshots and their own output timing. Semantic comparison exposes the adopted upstream
reports sensitivity gaps under [ADR 0008](../adr/0008-semantic-configuration-diff.md). comparison API, carrying its results and relevant metadata through the provider
boundary. CommandKit owns report presentation under
[ADR 0008](../adr/0008-semantic-configuration-diff.md).
## Scope And Non-goals ## Scope And Non-goals

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.11 **Base Policy Version:** 1.11
**Last Updated**: 2026-10-06 **Last Updated**: 2026-10-10
**Upstream Source:** [documentation.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md) **Upstream Source:** [documentation.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md)
@@ -147,6 +147,15 @@ mechanisms only.
## Architecture Decision Records ## Architecture Decision Records
ADRs record durable architectural decisions: responsibility boundaries,
ownership, significant constraints, and the reasons for choosing them. A settled
implementation or presentation choice does not automatically belong in an ADR.
Ask whether changing the detail would alter the architecture or represent
reasonable evolution within it. Details that can evolve within the chosen
architecture belong in API or CLI contracts, implementation documentation, or
roadmaps. Describe upstream behavior as context without turning it into an
independent project guarantee.
Use sequentially numbered ADR filenames such as Use sequentially numbered ADR filenames such as
`0001-record-architecture-decisions.md`. Follow the lightweight Nygard format: `0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.6 **Base Policy Version:** 1.6
**Last Updated**: 2026-10-08 **Last Updated**: 2026-10-11
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md) **Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
@@ -16,17 +16,20 @@ Adopt stronger verification or reproducibility requirements through an explicit
project-specific decision justified by the intended audience and support needs. project-specific decision justified by the intended audience and support needs.
The repository-specific commands belong in `docs/release.md`. Historical release The repository-specific commands belong in `docs/release.md`. Historical release
summaries belong in `docs/releases/`. The procedure must agree with the adopted summaries belong in each released module's `docs/releases/` directory. The
policy and link to it rather than introduce additional release requirements. procedure must agree with the adopted policy and link to it rather than introduce
additional release requirements.
## CommandKit Release Scope ## CommandKit Release Scope
CommandKit releases source-only Go modules. Standard CLI behavior is exposed by CommandKit independently versions three source-only Go modules: the root core,
consuming applications; CommandKit does not declare binary deliverables or a `integrations/ckconfig`, and `integrations/ckprompt`. Standard CLI behavior is
cross-platform build matrix. ReleaseKit v0.3.0 is selected as the maintainer exposed by consuming applications; CommandKit does not declare binary
publication tool. The [release procedure](../release.md) owns its source-only deliverables or a cross-platform build matrix. ReleaseKit v0.4.2 is selected as
workflow and repository-specific commands; the integration adds no application the maintainer publication tool. The [release procedure](../release.md) owns
command family or runtime ReleaseKit import. the selected-module workflow, release order, and repository-specific commands;
the integration adds no application command family or runtime ReleaseKit
import.
## Release Model And Completion ## Release Model And Completion
@@ -123,9 +126,18 @@ rules above.
## Release Documentation And Preparation ## Release Documentation And Preparation
Every new release must include `docs/releases/<version>.md` in the tagged Every new module release must include `docs/releases/<version>.md` relative to
source tree. Use the complete version tag as the filename and begin with a the selected module directory in the tagged source tree. Here `<version>` is
title containing the product name and that exact version. that module's stable semantic version, `vMAJOR.MINOR.PATCH`; use it as the
filename and begin with a title containing the selected release profile's
product name and that exact semantic version.
The semantic version and full Git tag are distinct. The root module's tag is
its semantic version; an integration's full tag includes its module prefix,
as recorded in the [release procedure](../release.md#modules-versions-notes-and-shared-files).
Use the full tag for Git refs and immutable source URLs, with repository-relative
paths identifying the selected module's note, grant, license, and attribution.
The module prefix is not part of the note filename or title's version.
Include a short summary and any relevant compatibility impact or upgrade Include a short summary and any relevant compatibility impact or upgrade
instructions. Choose sections suited to the release and keep the note instructions. Choose sections suited to the release and keep the note

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.3 **Base Policy Version:** 1.3
**Last Updated**: 2026-10-07 **Last Updated**: 2026-10-17
**Upstream Source:** [testing.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/testing.md) **Upstream Source:** [testing.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/testing.md)
@@ -70,7 +70,7 @@ implemented:
Use external-package consumer tests for facade semantics and real backend Use external-package consumer tests for facade semantics and real backend
integration where correctness depends on that interaction. Internal package integration where correctness depends on that interaction. Internal package
tests own focused mechanisms. Do not retest the dependencies' complete suites. tests own focused mechanisms.
Boundary checks protect selected architectural constraints; behavioral evidence Boundary checks protect selected architectural constraints; behavioral evidence
protects invocation semantics and ownership through conversions. protects invocation semantics and ownership through conversions.
@@ -79,7 +79,7 @@ concurrency guarantees only if deliberately selected. Coverage targets, race
testing, static analysis, example gates, and platform matrices require their own testing, static analysis, example gates, and platform matrices require their own
risk justification; another library's workflow does not establish them here. risk justification; another library's workflow does not establish them here.
To the extent the following features are present in the application, prioritize tests for: For behavior owned by CommandKit, prioritize tests for:
1. Public and package-level contracts. 1. Public and package-level contracts.
2. Domain rules and important invariants. 2. Domain rules and important invariants.
@@ -106,6 +106,41 @@ application's guarantees. Account for protection already provided at other layer
Do not add tests merely because a function, branch, or line exists. Do not add a test when the same meaningful risk is already adequately protected elsewhere. Do not add tests merely because a function, branch, or line exists. Do not add a test when the same meaningful risk is already adequately protected elsewhere.
## Upstream integration testing ownership
CommandKit tests the interfaces and adapters through which it exposes upstream
APIs. ConfigKit, PromptKit, and other integrated libraries own tests establishing
the correctness of their domain functionality. Trust their public contracts;
CommandKit tests must not independently establish whether those libraries
implement their claimed domain functionality. This applies to partial feature
checks as well as complete suites.
Integration assertions should establish CommandKit-owned behavior:
- CLI choices, typed inputs, configuration selection, and context reach the
appropriate upstream API with the intended values.
- CommandKit performs its selected coordination and preserves returned data,
diagnostics, and supported error identity across adaptation.
- CommandKit's reports and invocation outcomes correctly represent the returned
evidence, including presence, null, empty, and redacted values where applicable.
- Command availability, input/state lifetimes, cancellation handling, and output
delivery follow CommandKit's contracts.
For example, test that a namespace choice becomes the intended native listing
filter, rather than testing which resources PromptKit considers members of a
namespace. Test faithful presentation of ConfigKit's returned source evidence,
rather than its destination resolution, version checking, or sensitivity
classification. Test request forwarding and returned-error handling, rather than
whether PromptKit correctly detects missing artifacts or invalid templates.
A real upstream engine or result can be a useful fixture for a focused
integration test. Its use does not change assertion ownership: the test should
detect incorrect CommandKit adaptation, not independently establish upstream
domain correctness. Use the simplest suitable fixture or test double; this
boundary does not require new mocking interfaces or prohibit real dependencies
in tests. CommandKit's own parsing, registration, coordination, presentation,
and outcome behavior remain within its testing scope.
## Choose the right test boundary ## Choose the right test boundary
Choose the narrowest stable boundary that expresses the behavior clearly and Choose the narrowest stable boundary that expresses the behavior clearly and

View File

@@ -1,47 +1,46 @@
# CommandKit release procedure # CommandKit release procedure
## Scope and prerequisites ## Scope and release order
CommandKit publishes source-only Go module releases: an immutable annotated CommandKit publishes source-only Go module releases. Each module has an
version tag for one committed candidate and its matching checked-in release independent version and immutable annotated tag. This procedure publishes no
note. This procedure does not publish a hosted release page, binaries, hosted release page, binaries, checksums, or other delivery assets. A successful
checksums, or other delivery assets, and it requires no delivery credentials. source-tag push is release completion; CI, module proxy availability, and
consumer installation are not completion gates.
Use branch `main`, remote `origin`, and the expected effective push endpoint Publish in dependency order: core, then `ckconfig`, then `ckprompt`. The
`https://gitea.maximumdirect.net/eric/CommandKit.git`. The profile enforces that integration modules require the preceding CommandKit releases, so prepare and
endpoint during publication. The maintainer needs the selected publish each module only after its CommandKit dependencies are available. A root
[release toolchain](#release-toolchain), Git with push access to `origin`, and a release runs only the root module's default suite; it need not run suites for
POSIX shell. independently released integrations.
This procedure follows the [release policy](policy/release.md); the release-note Use branch `main`, remote `origin`, and expected effective push endpoint
footer follows the [OSS discoverability policy](policy/oss.md#discoverability). `https://gitea.maximumdirect.net/eric/CommandKit.git`. The selected module profile
enforces the endpoint. The maintainer needs the selected
[release toolchain](#release-toolchain), Git push access, and a POSIX shell.
This procedure follows the [release policy](policy/release.md); release-note
footers follow the [OSS discoverability policy](policy/oss.md#discoverability).
ReleaseKit v0.3.0 owns candidate validation, publication guards, annotated tag ReleaseKit v0.4.2 owns candidate validation, publication guards, tag creation,
creation, tag publication, and outcome reporting. The repository profile selects tag publication, and outcome reporting. Its pinned public contracts are the
only its default candidate suite. The launcher bootstraps the module-selected [CLI reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/cli.md),
native executable into ignored `.releasekit/` storage and invokes its `publish` [profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/config.md),
command. The upstream [CLI reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/e43df4416e0ed987349b2ea04a2c68c25e552772/docs/cli.md), and [publication guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/consumers/publish.md).
[profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/e43df4416e0ed987349b2ea04a2c68c25e552772/docs/config.md), The repository selects one default module suite per invocation. The launcher
and [publication guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/e43df4416e0ed987349b2ea04a2c68c25e552772/docs/consumers/publish.md) bootstraps the native tool into ignored `.releasekit/` storage and delegates to
own detailed command, profile, and status contracts. its `publish` command.
## Release toolchain ## Release toolchain
The selected release toolchain is **Go 1.26.8**. The consumer minimum is The selected release toolchain is **Go 1.26.8**. Each module declares a consumer
**Go 1.26.0**; validation uses a later patch within that supported Go series. minimum of **Go 1.26.0**. Maintainers use Go 1.26.8 for dependency preparation,
The [consumer guide](consumers/guide.md#go-compatibility) explains the minimum and candidate validation, and the ReleaseKit bootstrap. The
its shared tool-module requirement; the [consumer guide](consumers/guide.md#go-compatibility) and
[release policy](policy/release.md#consumer-minimum-and-release-toolchain) governs [release policy](policy/release.md#consumer-minimum-and-release-toolchain)
changes to either selection. describe these selections.
Use this toolchain for candidate validation and the native ReleaseKit bootstrap, Select Go 1.26.8 on `PATH`; the launcher uses `GOTOOLCHAIN=local`. If needed,
including CI jobs that perform those tasks. The launcher and ReleaseKit invoke install it with the standard Go tools:
`go` from `PATH` with `GOTOOLCHAIN=local`, so select that installation in the
shell used for preparation and publication.
If it is not installed, use the [standard Go installation method](https://go.dev/doc/manage-install).
Ensure Go's executable installation directory (`GOBIN`, or `GOPATH/bin` by
default) is on `PATH` so the downloaded-version wrapper can be invoked:
```sh ```sh
GOWORK=off GOTOOLCHAIN=local go install golang.org/dl/go1.26.8@latest GOWORK=off GOTOOLCHAIN=local go install golang.org/dl/go1.26.8@latest
@@ -51,115 +50,88 @@ hash -r
GOWORK=off GOTOOLCHAIN=local go version GOWORK=off GOTOOLCHAIN=local go version
``` ```
The final command should identify Go 1.26.8. An existing Go 1.26.8 installation ## Modules, versions, notes, and shared files
may be selected directly on `PATH` instead. Toolchain installation and dependency
preparation precede the offline publication checks; they add no validation pass.
## Prepare and publish a candidate Select exactly one module with the launcher:
Choose a fresh, unused stable Semantic Version tag in the form | Selector | Physical module directory | Release note | Full tag |
`vMAJOR.MINOR.PATCH`, with no leading zeroes. Before `v1.0.0`, a minor release | --- | --- | --- | --- |
may change a documented contract if its note explains the impact and consumer | `core` | repository root | `docs/releases/<version>.md` | `<version>` |
action; a patch release must not intentionally break contracts within its | `ckconfig` | `integrations/ckconfig` | `integrations/ckconfig/docs/releases/<version>.md` | `integrations/ckconfig/<version>` |
minor line. Never reuse a version previously published through any release | `ckprompt` | `integrations/ckprompt` | `integrations/ckprompt/docs/releases/<version>.md` | `integrations/ckprompt/<version>` |
channel. The first version is selected during release preparation, not embedded
in the tool or profile.
Start from `main` and prepare a note at `docs/releases/<version>.md`. Begin with Use a fresh stable semantic version (`vMAJOR.MINOR.PATCH`) for the selected
a heading containing `CommandKit` and the exact version. Include a useful module. Before `v1.0.0`, a minor release may change a documented contract when
summary and any compatibility impact or upgrade instructions. End it with the its note explains the impact and consumer action. Never reuse a published
license and attribution links at that version's immutable source tag, replacing version. Each release note starts with a product title and exact version, gives
`VERSION` with the selected tag: a proportionate summary and compatibility guidance, and ends with immutable
links to that module's local grant, license, and attribution paths. The full tag
in each URL includes the integration directory where applicable. Future tag
URLs need not resolve before publication.
```markdown All three release profiles explicitly select the repository-root `COPYRIGHT`,
[COPYRIGHT](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/VERSION/COPYRIGHT) | `LICENSE`, and `docs/oss/attribution.md` through `validation.shared_files`.
[LICENSE](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/VERSION/LICENSE) | ReleaseKit requires these committed files to exist in the candidate even when
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/VERSION/docs/oss/attribution.md) no optional file checks are enabled. The list does not enable content checks
``` or expand Go validation scope; see the pinned [profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/b96262eb0ce9c8f2eb3e948a5841db2b799bc91b/docs/config.md).
Review these paths in the candidate; the future tag URLs do not need to resolve Each integration also distributes its own `COPYRIGHT`, `LICENSE`, and
before publication. `docs/oss/attribution.md`. These module-local materials are separate from the
explicit root shared-file list and remain subject to the [OSS policy](policy/oss.md).
Automatic module file inputs include applicable module-local files and stop at
independent nested-module boundaries. Their inclusion does not itself enable
license or content validation.
Prepare dependencies separately from the offline candidate suite: The current profiles enable no optional file checks or additional product
checks. Publication runs the selected module's standard offline suite and
ReleaseKit's candidate/note and publication guards. The profiles select their
physical module directory while using the same repository and endpoint.
Run these commands in the same shell session so the version variable remains ## Prepare and publish one candidate
set:
Prepare dependencies in the selected module before the candidate is finalized.
```sh Do this after the preceding CommandKit module has been published and before the
commandkit_version=vX.Y.Z selected candidate suite is run. For `core`, run the command at the repository
GOWORK=off GOTOOLCHAIN=local go mod download root; for integrations, run it from that module directory. Commit any resulting
``` `go.mod` or `go.sum` changes with the release candidate. The sum files must
reflect dependencies actually downloaded for that module; do not fabricate
If dependency preparation changes `go.mod` or `go.sum`, review and commit those future sums.
changes with the candidate. Commit the note and all candidate changes on `main`,
then publish that commit to `origin`: Then commit the selected module's release note and all intended candidate
changes on `main`, and publish the candidate commit to `origin`. Set one
selector, module directory, and version from the table above, then run:
```sh ```sh
module_dir=.
module_selector=core
module_version=v0.2.0
(cd "$module_dir" && GOWORK=off GOTOOLCHAIN=local go mod download)
# Commit reviewed dependency changes and the selected module's note on main.
git push origin HEAD:refs/heads/main git push origin HEAD:refs/heads/main
./scripts/release "$commandkit_version" ./scripts/release "$module_selector" "$module_version"
``` ```
The launcher builds the ReleaseKit version selected by the module in readonly Run only the command for the selected module. Its profile runs the complete
mode. After preflight, `publish` runs the offline default Go suite once, checks offline deterministic default suite once for that module, checks candidate and
the candidate and remote guards, creates an annotated tag from the committed remote guards, creates the annotated tag from the matching note, and pushes
note, and pushes only that tag object. The launcher does not push `main`; push only that full tag. `check` is available for diagnosis and is not a required
the candidate first. `check` is available for optional diagnosis and is not a pre-publication command. The launcher does not push `main`; publish the
required step before `publish`. The automated publication workflow runs the candidate commit first. Successful source publication does not wait for CI,
final-candidate suite once; configured additional checks should avoid duplicating proxy availability, or downstream installation.
that suite. Maintainers may rerun diagnostics when useful. Successful source
publication does not wait for CI, module availability, or consumer installation.
## Completion and statuses ## Completion and ambiguous publication
Publication is complete when the intended annotated tag is successfully pushed A successful push to the expected endpoint completes the source release. On an
to the expected endpoint, or when an exact tag-object query confirms that push ambiguous result, use the exact endpoint, full tag, and annotated tag object
after an error. Status 0 reports confirmed publication. Status 1 reports a reported by that invocation. Confirm endpoint resolution has not changed, then
known failure or conflict; status 2 reports invalid input or profile; status 3 query only that exact tag ref. Compare the remote annotated tag-object ID with
means the push outcome remains unresolved. See the upstream [CLI status the reported object: an exact match confirms publication, a different object is
contract](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/e43df4416e0ed987349b2ea04a2c68c25e552772/docs/cli.md). a conflict, successful output without the ref proves absence at that endpoint,
and a failed query leaves the result unresolved. Do not infer the captured
endpoint's state from another remote or a rewritten URL.
For a failed or ambiguous push, use the exact endpoint, version, and annotated ReleaseKit may retain a local tag after a failed push. Remove an unpublished
tag object reported by the invocation. Confirm that Git still resolves the local tag only after proving absence at the captured endpoint and confirming
captured endpoint to itself before querying only that tag ref: that the tag was never published or used by another release channel. Never
move, overwrite, recreate, or delete a published tag. Resolve conflicts with a
```sh fresh version; corrections to published source require a new version.
releasekit_endpoint='COPY THE CAPTURED ENDPOINT EXACTLY'
releasekit_version='vX.Y.Z'
releasekit_tag_object='COPY THE REPORTED TAG OBJECT ID'
releasekit_ref="refs/tags/$releasekit_version"
releasekit_resolved_endpoint=$(git ls-remote --get-url -- "$releasekit_endpoint") || {
printf '%s\n' 'publication state is unresolved: endpoint resolution failed' >&2
exit 1
}
if [ "$releasekit_resolved_endpoint" != "$releasekit_endpoint" ]; then
printf '%s\n' 'publication state is unresolved: endpoint resolution changed' >&2
exit 1
fi
releasekit_remote_tag=$(git ls-remote --refs -- "$releasekit_endpoint" "$releasekit_ref") || {
printf '%s\n' 'publication state is unresolved: remote query failed' >&2
exit 1
}
printf '%s\n' "$releasekit_remote_tag"
```
`--refs` returns the annotated tag object's ID, not its peeled commit. Compare
that ID with `releasekit_tag_object`: an exact match confirms publication; a
different object is a conflict; successful output with no matching ref proves
absence at the captured endpoint. A failed query or changed URL resolution is
unresolved, never evidence of absence. Do not query a different fetch URL or
follow a rewritten endpoint to infer the captured destination's state.
ReleaseKit retains a local tag after a failed push, so the tag blocks an ordinary
retry. After proving the tag absent at the captured endpoint,
confirm that it was never published or used by another release channel before
deliberately removing the unpublished local tag:
```sh
git tag -d "$releasekit_version"
```
Do not move, overwrite, recreate, or delete a published tag. If the exact tag
object is present remotely, keep it. Resolve conflicts with a fresh version;
corrections to published source require a new version. A transport failure
alone does not require a new version.

52
docs/releases/v0.2.0.md Normal file
View File

@@ -0,0 +1,52 @@
# CommandKit v0.2.0
## Summary
CommandKit v0.2.0 replaces the core registration API with reusable captured
command declarations and moves ConfigKit and PromptKit support into independently
versioned optional modules. This is a source-only release with incompatible API
changes from v0.1.0.
## Changes
- Capture typed command specifications with `NewCommand`, then install the
resulting `Command` declarations through `commandkit.New` or `App.Register`.
Installation validates a batch before changing the application's registry.
- Reuse captured declarations across applications. Typed bindings preserve
supplied values separately from defaults, and input construction occurs only
when execution reaches the command.
- Keep ConfigKit and PromptKit dependencies outside the core module. Applications
explicitly import integrations and install their returned declarations.
- Adopt ReleaseKit v0.4.2 for independently versioned module publication. It
remains a maintainer tool dependency; production packages do not import it.
## Compatibility and upgrade
The consumer minimum remains **Go 1.26.0**. The selected maintainer release
toolchain remains Go 1.26.8.
Consumers upgrading from v0.1.0 must rewrite command declarations, typed
bindings, and installation wiring for the captured declaration API. There is no
compatibility layer for the earlier registration API. Move configuration
integration code to
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig`, and PromptKit
integration code to
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt`.
The integrations' initial releases are each v0.1.0; their version numbers are
independent of core and upstream dependency versions. They add native
configuration comparison, richer source evidence, PromptKit requirements
discovery and validation, five catalogs, and opt-in smoke testing. Their own
release notes describe the features and operator changes:
- [ckconfig v0.1.0](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/integrations/ckconfig/docs/releases/v0.1.0.md)
- [ckprompt v0.1.0](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckprompt/v0.1.0/integrations/ckprompt/docs/releases/v0.1.0.md)
See the [consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/docs/consumers/guide.md)
for migration examples and the
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/docs/cli.md)
for standard command and report contracts.
[COPYRIGHT](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/LICENSE) |
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/docs/oss/attribution.md)

74
docs/releases/v0.3.0.md Normal file
View File

@@ -0,0 +1,74 @@
# CommandKit v0.3.0
## Summary
CommandKit v0.3.0 adds application-owned command and flag aliases and shared
invocation reporting. This pre-1.0 minor release also updates the installation
declaration type and Invocation struct shape, replaces the error-first outcome
and diagnostic-delivery APIs, and reserves reporting flag names; review the
migration guidance before upgrading.
## Changes
- Declare command shortcuts and global or canonical-command-scoped flag
spellings with `Alias`, `FlagAlias`, and `FlagAliasFor`. Install routing rules
beside canonical commands through `New` or `App.Register`. Aliases route to
existing handlers and preserve the target command's operand and binding
behavior.
- Read the entered and canonical command paths from `Invocation` with
`EnteredPath` and `CanonicalPath`.
- Interpret a canonical short value flag such as `-c=` as an explicitly
supplied empty value, consistent with `--color=` and `-c ""`.
- Obtain an invocation-scoped `Reporter` from each handler's `Invocation` for
substantive output, Warning/Info/Progress/Debug messages, deferred messages,
and managed report rendering. The shared `--quiet`, `--verbose`, `--debug`,
and `--progress` flags apply across core and integration commands.
- Return terminal explanation text with `Failure`, `Usage`, or
`Outcome.WithExplanation`; attach an optional operation error separately with
`Outcome.WithCause`. Returned `Outcome.Presentation` records output and
diagnostic delivery observations without changing the operation cause.
## Compatibility and migration
`New` and `App.Register` now accept `...Declaration` instead of `...Command`.
Command, group, and integration command factories still return `Command`, so
individual factory calls can be passed directly. A `[]Command` cannot be
expanded into `...Declaration`; use a `[]Declaration` for mixed reusable
collections or convert each command element before expanding it. Function
values whose parameter type is `...Command`, including wrappers assigned from
`New` or `Register`, must update their signature to `...Declaration`.
Adding private route fields to `Invocation` means positional struct literals no
longer compile. `Invocation` no longer contains `Streams`; `App.Execute` supplies
the reporter and route paths to handlers. `Streams` remains the argument to
`App.Execute`, where the caller owns the result and diagnostic writers.
Handlers that used `invocation.Streams.Out` or `.Err` should use
`invocation.Reporter().Output()` or the categorized reporting methods. Replace
`Failure(err)` and `Usage(err)` with an operator-facing explanation and an
optional `.WithCause(err)`; causes alone are not rendered. Replace
`Outcome.Diagnostics` submissions and terminal `SeverityError` records with
reporter messages and an `Outcome` explanation. Provider-returned
`Acquired.Diagnostics` remain passive data for the owning integration to
submit. Replace `ReportIncomplete`, `DiagnosticDelivery`, and `DeliveryError`
with `Outcome.Presentation.Output` and `.Diagnostics`, whose `DeliveryResult`
contains a state plus first render and write errors.
The long flags `--quiet`, `--verbose`, `--debug`, and `--progress` are reserved
for CommandKit reporting and cannot be declared by applications. Application
aliases may target them. See the current [CLI reporting contract](../cli.md#reporting)
and [consumer migration guide](../consumers/guide.md#invocation-reporting) for
their behavior and API examples.
The consumer minimum remains Go 1.26.0 and the maintainer release toolchain
remains Go 1.26.8. Core v0.3.0 must be published before preparing dependent
integration releases that adopt it and record downloaded dependency sums; this
release does not change their v0.1.0 release records.
See the [consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.3.0/docs/consumers/guide.md),
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.3.0/docs/cli.md),
and [maintainer release procedure](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.3.0/docs/release.md).
[COPYRIGHT](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.3.0/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.3.0/LICENSE) |
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.3.0/docs/oss/attribution.md)

View File

@@ -5,8 +5,8 @@ Status: retained future work and unselected proposals; no implementation schedul
## Purpose and authority ## Purpose and authority
Completed initial delivery, performance, ReleaseKit upgrade, and remediation Completed initial delivery, performance, ReleaseKit upgrade, and remediation
records have been retired. This document preserves their unfinished capability records have been retired. This document preserves remaining deferred
targets and deferred ideas under the capabilities and unselected ideas under the
[release policy](../policy/release.md#release-documentation-and-preparation). [release policy](../policy/release.md#release-documentation-and-preparation).
The original records remain available in The original records remain available in
[Git history](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap). [Git history](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap).
@@ -18,36 +18,12 @@ an idea here does not select its syntax, API, priority, or delivery date.
## Deferred capability targets ## Deferred capability targets
### DIFF-01: semantic configuration comparison ### Comparison extensions
Expose comparison of two independently resolved selections of one scope using Independent per-side domain selectors and an optional assertion mode remain
owning-library safe snapshots. [ADR 0008](../adr/0008-semantic-configuration-diff.md) unselected extensions to the delivered ConfigKit comparison. Consider them only
owns the accepted comparison semantics, sensitivity union, and coverage-gap for a concrete consumer need and a deliberate API/behavior decision under the
handling. The target includes text/YAML reports of differences and gaps; relevant ADRs.
completed comparisons can succeed even when they find either.
The initial review of ConfigKit v0.5.0 and PromptKit v0.12.0 did not find the
complete public snapshot/sensitivity contract needed for that target. Reassess
released upstream APIs when planning it; do not treat that historical review as
a claim about newer versions. Hidden-path metadata and supported gap reasons
must be available without fetching secrets. Rendered YAML and sibling internals
are not substitutes for an agreed public comparison representation.
Comparison selection syntax, context inheritance, and report layout remain
future design work. Earlier possibilities included right-side root/overlay
selection, shared versus independent domain context, and an optional assertion
mode. Those choices are unselected. Add public types and handlers when the
capability is supported, rather than exposing placeholders.
### CAT-01: PromptKit catalogs
List captured prompt/version entries and named profiles through owning public
catalog APIs, with deterministic reports of loaded definitions. Resource names,
enumeration details, and report shapes remain future design work.
The initial PromptKit v0.12.0 review did not find public enumeration APIs.
Reassess current released capabilities before planning delivery. Do not infer
catalogs by reparsing configuration YAML or inspecting sibling internals.
## Other deferred proposals ## Other deferred proposals
@@ -57,7 +33,8 @@ operator or consumer need and an appropriate ownership/behavior decision:
- JSON reports, shell completion, and output-file management or reloadable export. - JSON reports, shell completion, and output-file management or reloadable export.
- Configuration mutation, generic value assignment, and exhaustive field-origin - Configuration mutation, generic value assignment, and exhaustive field-origin
inventories. inventories.
- Credential diagnostics and live provider probes. - Credential diagnostics beyond PromptKit's native smoke checks and additional
live provider probes.
- Additional component identity registration and domain artifact catalogs. - Additional component identity registration and domain artifact catalogs.
- Additional library families, including possible FeedKit integration; families - Additional library families, including possible FeedKit integration; families
can define suitable verbs without requiring comparison or configuration scopes. can define suitable verbs without requiring comparison or configuration scopes.

View File

@@ -0,0 +1,820 @@
# Invocation reporting implementation plan
Status: Stages 1–20 complete.
## Objective and authority
Implement the end state selected in the [reporting roadmap](reporting.md), under
[ADR 0014](../adr/0014-invocation-reporting.md),
[ADR 0005](../adr/0005-invocation-outcomes.md), and the
[architecture policy](../policy/architecture.md). The roadmap owns the selected
behavior and target API; this document owns the steps from the current code to
that end state. Read those sources before starting.
CommandKit owns presentation, invocation coordination, and its own failures.
Applications own their domain responses and selected outcomes. ConfigKit and
PromptKit own their native semantics, diagnostic evidence, and disclosure rules.
The [testing policy](../policy/testing.md#upstream-integration-testing-ownership)
applies to every stage: test CommandKit's adaptation and presentation, without
retesting upstream functionality.
## Sprint instructions
- Follow the stages in order. Each stage is sized as a separate Luna task.
Read the entire plan initially and the owning roadmap sections for each stage.
- This is one end-to-end sprint. Intermediate stages need not compile or leave
the repository coherent. Reach the clean final API directly; do not introduce
compatibility constructors, duplicate submission paths, or temporary adapters
merely to make intermediate commits buildable.
- Preserve behavior outside the selected reporting changes, including command
and flag aliases, typed input semantics, provider lifetimes, scope selection,
native reports, and optional-module dependency direction.
- Inspect existing tests before adding coverage. Migrate tests whose contracts
intentionally change; retain protection for unaffected behavior. Each stage's
validation list identifies risks to protect, not a requirement for one test
per bullet or an exhaustive cross product.
- Add or revise focused tests with the owning implementation. Run executable
focused checks when their dependencies are ready. Record deferred validation
accurately; a broken intermediate build is not evidence of a passing test.
Stage 20 validates the assembled repository.
- Follow the [agent policy](../policy/agents.md), including focused local commits
and preservation of unrelated changes. Update this plan's stage status as work
completes. Do not push, tag, publish, or retire these planning documents as
part of this sprint.
## Baseline and implementation boundaries
At planning baseline `1d29cdd`, core handlers receive raw `Invocation.Streams`.
`Outcome` combines causes, queued diagnostics, and older delivery fields.
`execution.present` coordinates final diagnostics. The Cobra adapter returns
per-binding flag values and spellings, but not their order across different
flags. Reporting modes and an invocation reporter do not exist.
Core remains responsible for the public reporter, outcome types, visibility,
delivery accounting, flag policy, and automatic finalization. Keep ordinary
private implementation beside the root facade where appropriate; introducing a
new public package is unnecessary. `internal/commandmeta` carries neutral parser
metadata, and `internal/cobra` continues to contain Cobra/pflag-specific code.
`integrations/ckconfig` owns ConfigKit adaptation and configuration command
presentation. `integrations/ckprompt` owns PromptKit adaptation and commands and
may use ckconfig. Neither integration becomes a dependency of core, and ckconfig
does not import ckprompt. Providers return passive data; they do not acquire a
reporter parameter.
Use one shared private state per reporter and serialize operations with a mutex.
Use synchronous writes rather than adding a buffering or worker subsystem.
Render callbacks receive a writer for the already-coordinated operation; that
writer must not reacquire the reporter lock. Callback reentrancy restrictions
are those selected in the roadmap, without runtime detection machinery.
## Stage 1: Replace outcome and provider data declarations
Status: complete.
Primary files: [outcome.go](../../outcome.go) and their focused contract tests.
1. Implement the exact `Explanation`, `Outcome`, `PresentationResult`,
`DeliveryResult`, and `DeliveryState` declarations selected in the roadmap.
Use the selected `uint8` delivery enumeration, including `DeliverySuppressed`.
Remove the superseded outcome diagnostic/delivery fields.
2. Implement `Success`, `Failure(message, details...)`, `Usage(message,
details...)`, `Domain(name, status)`, `WithExplanation`, and `WithCause`.
Copy accepted detail slices so replacing or modifying the caller's slice
does not change a captured callback list. Callback-captured data remains
caller-owned. Constructors and modifiers emit nothing.
3. Preserve optional `Cause` unchanged. It does not select status, category, or
explanation. Preserve zero-value outcome success without treating a cause
as a reason to infer failure. `WithExplanation` preserves cause;
`WithCause` preserves classification, status, and explanation.
4. Add `Explanation` to `Acquired[T]`. Keep `Diagnostic` as passive severity and
renderer data. Retain `SeverityWarning` and add `SeverityInfo`,
`SeverityDebug`, and `SeverityProgress`; remove `SeverityError` because
terminal content now belongs to `Explanation`. Retain `MessageDiagnostic`
as a passive literal-message renderer helper, without emitting or selecting
an outcome.
5. Declare `ErrReporterClosed` with the selected error text. Do not retain the
old API through overloaded or parallel constructors.
Validation: protect constructor/modifier independence, copied callback lists,
non-emission, and cause identity using `errors.Is` and `errors.As`. Existing
callers are migrated in later stages.
## Stage 2: Implement reporter state and managed output writers
Status: complete.
Primary files: new private reporting implementation in core, plus
[identity.go](../../identity.go) as needed for internal wiring.
1. Add the public `Reporter` facade backed by one private invocation state.
Its private constructor accepts caller-selected destinations and starts in
ordinary mode. Keep `Streams` as the `App.Execute` input; preserve existing
nil-destination normalization.
2. Implement `Output()` using a managed `io.Writer`. Serialize each write,
preserve ordinary writer return behavior, and record the first destination
failure even if the handler ignores it. Normalize a short write with nil
error to `io.ErrShortWrite`.
3. Track result and diagnostic destinations independently. A failed destination
receives no more writes; a healthy destination remains usable. Keep the
first render error and first write error per destination, without a history
or content buffer.
4. Implement private visibility state for ordinary, quiet, verbose, and debug,
plus explicit progress value and presence. Quiet output writes discard
bytes successfully without calling the supplied destination.
5. Aggregate delivery observations according to the roadmap: incomplete when
rendering or writing failed; otherwise complete when requested delivery
completed, suppressed when all submitted content was hidden, and none when
no content was submitted. Merely obtaining an output handle submits nothing.
6. Retained output handles consult shared lifecycle state on every write.
After closure they return `ErrReporterClosed`, including under quiet mode.
Do not close or automatically flush caller-owned destinations.
Validation: use controlled writers to cover visible and suppressed output,
short writes, ignored errors, bounded first-error retention, destination
independence, and retained-handle closure. No concurrent `App.Execute` support is
introduced by reporter synchronization.
## Stage 3: Implement atomic rendering and categorized messages
Status: complete.
1. Implement `RenderOutput`, `Info`, `Warn`, `Debug`, and `Progress` with the
exact roadmap signatures. A render operation or complete categorized message
owns the reporting lock for its heading and all details.
2. Render callbacks through their supplied writer. The internal writer records
destination failures without recursively locking the reporter. Multiple raw
`Output().Write` calls remain separate operations.
3. Implement literal headings, the selected prefixes and channels, newline
termination, empty-heading behavior, and ordered details. Skip nil details.
Preserve native multiline content and distinct submissions with equal text.
4. Apply the selected visibility table and progress precedence. Suppressed rich
renderers execute against discard initially; simple hidden headings need no
formatting or destination write. Do not retain hidden content.
5. Record callback failures as presentation failures even when ignored. Distinguish
them from sink failures: a callback error alone does not mark the destination
unusable. A propagated managed-writer error is evidence of the write failure,
not a second independently discovered rendering failure; independently
returned renderer errors remain available separately.
6. Keep the directly invoked encoder boundary: an encoder called by application
code on `Output()` owns its encoding error, while managed writer errors are
still tracked. CommandKit owns callback errors from `RenderOutput` and details.
Validation: cover category visibility and destination, prefixes, empty/native
headings, detail order, rendering failure before any write, rendering failure
under quiet mode, and independent rendering/write failures. Exercise concurrent
complete messages and rendering operations with deterministic synchronization;
assert intact operations without imposing an order among concurrent callers.
## Stage 4: Implement deferred delivery and finalization
Status: complete.
1. Implement `AtEnd` and the selected value-type `DeferredReporter` methods.
Copy callback lists on submission. Queue messages in submission order,
without sorting by severity or retaining a delivered transcript.
2. Implement one private finalization path: drain eligible deferred messages;
finish ordinary reporting including the completion event supplied later;
account for failures; present the terminal explanation; close the reporter;
populate and return `Outcome.Presentation`.
3. Deferred messages remain eligible on failure. Handler Go defers run before
finalization. Applications finish reporting goroutines before returning;
this is not a task-waiting or cancellation subsystem.
4. Before terminal delivery, insert and retain `Command failed.` only for a
non-zero status with an empty heading and no non-nil detail callback.
Trust a supplied renderer even if it emits no bytes. Do not inspect its
output by buffering or replaying it.
5. Apply the selected failure table: any presentation failure changes a standard
outcome, including usage, to operational status `1`. Preserve domain name
and non-zero status; change domain status `0` to `1`. Preserve the original
explanation and optional cause, recording presentation evidence separately.
6. If presentation failure turns success into failure without an explanation,
supply a useful core-owned explanation through a healthy diagnostic
destination. Never derive application text from `Cause`, redirect failed
diagnostics into results, or recursively report a broken diagnostic sink.
7. Terminal delivery failures update observations and outcome classification
without scheduling another terminal explanation. Close every shared handle
before returning. Clear consumed deferred/parser state.
Validation: cover immediate/deferred/terminal ordering, deferred failures,
generic fallback and retention, explicit zero-byte native explanations,
success/usage/operational/domain transitions, preserved causes and explanations,
healthy diagnostic delivery following renderer failure, and closed retained
reporter/deferred/output handles. Include failures during terminal delivery.
## Stage 5: Install shared reporting flag metadata
Status: complete.
Primary files: [command.go](../../command.go),
[parser.go](../../parser.go), [flag_aliases.go](../../flag_aliases.go),
[help_view.go](../../help_view.go), and
[internal/commandmeta/metadata.go](../../internal/commandmeta/metadata.go).
1. Define shared neutral metadata for the four long boolean flags: `quiet`,
`verbose`, `debug`, and `progress`. Supply it to every parse/help context:
root, canonical groups, runnable commands, built-ins, and alias-only discovery.
Keep Cobra/pflag declarations inside the adapter.
2. Extend reserved-name validation to those four long names. Preserve capture
error retention, installation collision reporting, and batch atomicity,
including later registration. Do not reserve unselected short spellings.
3. Include core flags among alias targets and canonical spelling collisions in
every applicable context. Applications may register their own short aliases
through the existing API; core supplies none for reporting flags.
4. Keep reporting flags separate from consumer typed bindings. Do not shift
`BindingSet` identities or change the number/order of values supplied to a
captured input preparer.
5. Extend help metadata without manufacturing canonical nodes for discovery
contexts or changing entered/canonical identity rules.
Validation: protect installation collisions and atomicity, aliases to each core
flag, canonical-spelling collisions, unchanged typed binding indexes, and flag
availability in root/group/built-in/discovery help.
Focused metadata and Cobra adapter tests pass. Root-package registration and
alias tests are included, but their execution is deferred until the planned API
migration restores root-package buildability.
## Stage 6: Preserve occurrence order through the normal parser
Status: complete.
Primary files: [internal/cobra/flags.go](../../internal/cobra/flags.go),
[internal/cobra/parse.go](../../internal/cobra/parse.go), and neutral metadata.
1. Extend the neutral parse result with ordered flag-occurrence evidence carrying
canonical flag identity, entered spelling, and raw value. Keep existing
per-binding values and spelling information for typed inputs.
2. Record occurrences when the normal parser consumes flags, using the existing
alias interpreter and raw-value collection. Do not add a second raw-argv
scanner for verbosity. Preserve the consumed prefix when parsing fails;
do not invent occurrences after the failing token.
3. Reduce reporting occurrences in argv order. Convert every supplied boolean,
including overwritten occurrences. True selects its mode; false clears only
the currently selected matching mode to ordinary. Track the last explicit
progress value and its presence independently.
4. Ensure aliased and canonical spellings share one order, including existing
short-alias clusters and boolean syntax. Keep option-looking flag values,
operands after `--`, and ordinary consumer flag values unchanged.
5. Preserve command-path placement rules, interspersed operands after the
complete path, existing failure precedence where unaffected, and help
boolean handling. Unknown flags and invalid booleans remain usage failures.
6. Make the resolved presentation selection and relevant neutral parser events
available to core before output or input factories run. Partial selection
on failure must not affect unconditional terminal-error visibility.
Validation: own the dense ordering cases at the parser/reducer boundary: every
roadmap reset example, mixed aliases, repeated booleans, invalid earlier values,
explicit progress before/after mode flags, values containing reporting-like
tokens, terminators, and malformed paths. Retain coverage for input spelling
provenance and consumer binding conversion.
Focused internal package suites pass. Root reducer tests are included, but their
execution is deferred until the planned API migration restores root-package
buildability.
## Stage 7: Connect invocation execution to one reporter
Status: complete.
Primary files: [identity.go](../../identity.go),
[execute.go](../../execute.go), [route.go](../../route.go),
[flag_values.go](../../flag_values.go), and their invocation tests.
1. Replace handler-facing `Invocation.Streams` with a private reporter reference
and the public `Invocation.Reporter()` method. Retain context and entered/
canonical accessors. `App.Execute` continues accepting caller `Streams`.
2. Construct one reporter for every invocation, including paths that fail before
a handler exists. Apply parsed selection before successful output, input
preparation, providers, or the handler.
3. Replace old `present` bookkeeping with the single finalization path. Route
every normal return through it: nil application/context errors, route and
parse failures, preparation failures, group/help results, and handler outcomes.
4. Convert CommandKit-owned errors deliberately into the new message-first
constructors, preserving applicable cause identity with `WithCause`.
Preserve `ErrFlagInput`/`ErrFlagWiring` classification and input validation.
Do not use a generic rule that turns any application cause into terminal text.
5. Attach usage guidance as terminal explanation detail, visible under quiet.
Preserve entered alias paths, custom discovery usage, and the distinction
between unresolved routes and canonical command identity.
6. Populate presentation observations from actual reporter state rather than
accepting handler-supplied observation fields as delivery evidence.
Preserve fresh input/state lifetimes and registration from handlers; a
refreshed registry snapshot must not replace the invocation's reporter.
Validation: exercise early failures, handler success and domain outcomes,
cleanup writes, ignored writer failures, repeated invocations, alias identity,
and usage guidance in quiet. Preserve factory-free help/preflight behavior and
existing input conversion/cause tests under the new API.
The invocation now owns one reporter, applies ordered reporting selection before
preparation and handler execution, and finalizes normal outcomes through the
reporter. Focused internal suites pass. Root-package execution remains blocked
by the planned help/version migration and remaining legacy test callers; the
root test command was run and its compile errors are recorded for those later
steps. Focused invocation coverage has been added for reporter freshness,
selection, quiet usage guidance, early errors, and ignored output failures.
## Stage 8: Migrate help and version presentation
Status: complete.
Primary files: [builtins.go](../../builtins.go), help metadata/rendering,
and built-in consumer tests.
1. Route successful help and version results through substantive reporting.
Quiet success emits neither report nor a terminal explanation.
2. Keep help-path validation and invalid component/version selection as usage
handling. An unknown help target must not become a rendering failure merely
because help uses `RenderOutput` for presentation.
3. Put actual help/version rendering and delivery under managed reporting,
using the supplied callback writer. Preserve the internal backend boundary
and discovery help metadata. Render quietly into discard where required.
4. Keep application version as the default and existing explicit component
selection intact. Do not add new version scopes or dependency inspection.
5. Remove built-in paths that write directly to invocation streams or manually
populate superseded incomplete-report fields.
Validation: cover `help --quiet`, `version --quiet`, canonical and alias help,
alias-only discovery, shared reporting flags, invalid help/version requests,
factory-free behavior, and help/version destination failures. Invalid requests
remain usage outcomes unless presentation subsequently fails.
Help and version reports now render through the invocation reporter, including
quiet-mode rendering and destination-failure accounting. Tests cover quiet
success, canonical and alias help, alias-only discovery, invalid requests, and
destination failures. The internal suites pass; root-package execution remains
deferred because the existing external test package still references APIs
scheduled for the later suite reconciliation.
## Stage 9: Add core debug events
Status: complete.
1. Instrument routing, command-alias resolution, verbosity selection, and handler
completion. Explain the relevant entered/canonical paths and decisions.
Include flag-alias identity when it explains reporting selection.
2. Collect early routing/parser events temporarily and submit them only after
final mode selection. Clear them after submission; do not add a transcript.
3. Submit the completion event after the handler returns and after deferred
messages, before terminal presentation. Describe the handler's classification
and status; the returned outcome remains authoritative after delivery.
4. Use shared Debug reporting for events and normal presentation failure handling
for their delivery. Do not dump argv values, configuration values, causes, or
provider objects. Installation errors remain ordinary returned Go errors.
5. Other candidate events in the roadmap are opportunities, not mandatory
instrumentation for every internal step. Do not turn supported repetition
into a warning.
Validation: `--verbose --debug` reveals earlier selection events;
`--debug --quiet` hides them. Cover alias paths, completion before terminal
explanation, absence of completion when no handler runs, and debug delivery
failure. Assert selected meaning and order rather than incidental prose.
Routing, reporting-mode selection, and handler-completion events now use shared
Debug reporting. Early events are submitted after mode selection, and completion
is submitted after deferred messages. Focused tests cover alias identity,
selection order, quiet suppression, completion ordering, no-handler behavior,
cause privacy, and debug delivery failure. Internal suites pass; execution of
root tests remains deferred until the legacy external callers are reconciled.
## Stage 10: Separate ConfigKit provider messages and explanations
Status: complete.
Primary files:
[configkit_adapter.go](../../integrations/ckconfig/configkit_adapter.go),
[contracts.go](../../integrations/ckconfig/contracts.go), and adapter tests.
1. Migrate `FromConfigKit`, comparison acquisition, and diagnostic helpers to
`Acquired.Diagnostics` plus `Acquired.Explanation`. Public provider callbacks
remain passive and retain their existing context/request/input/state shape.
2. Replace highest-severity whole-batch promotion. Partition native records into
independent nonterminal messages and identified terminal context. Native
error records belong to the explanation; independent warnings remain warnings.
Preserve explicitly related native context with its owning record.
3. Preserve native diagnostic writers, source identifiers/legends, selected
scope and comparison-side context, safe disclosure, and record order within
each resulting presentation path. Snapshot renderer inputs as needed for
deferred use; do not infer relationships from text or error-chain wording.
4. Preserve data returned alongside errors and supported Go error identity.
A native terminal explanation must not also be submitted as an independent
diagnostic or repeated through a generic cause-derived message.
5. Preserve comparison left/right preparation and failure behavior. Carry
already-acquired independent messages through a later acquisition failure.
Validation: adapter tests establish faithful record separation, source/side
context, passive acquisition, retained causes, and data alongside errors.
Use native results as fixtures without testing ConfigKit's resolution or
diagnostic classification algorithms.
The ConfigKit adapter now carries independent warnings and terminal native
records separately, preserves partial values and wrapped errors, and snapshots
native writer inputs. Focused tests cover mixed records, source and comparison
side context, related locations, partial values, error identity, and messages
retained through cancellation. The ckconfig suite cannot run yet because
`diff.go`, `explain.go`, and `family.go` still use APIs scheduled for later
migration; Stage 20 will validate the assembled modules.
## Stage 11: Migrate configuration show and validate
Status: complete.
Primary files: [show.go](../../integrations/ckconfig/show.go),
[validate.go](../../integrations/ckconfig/validate.go), and
[operations.go](../../integrations/ckconfig/operations.go).
1. Introduce small integration-private helpers to submit passive diagnostics to
the corresponding reporter methods, using the deferred view for end-of-run
warnings/context. Native fully formatted renderers use empty headings.
Progress, if present, is immediate; no deferred Progress API is added.
2. Replace old `configOperationFailure`/incomplete-report bookkeeping with explicit
explanations, optional original causes, and reporter-owned delivery evidence.
Use an acquired native explanation when available; otherwise select a useful
integration-owned operation explanation. Do not add a second cause printout.
3. Migrate `config show` to managed rendering. Preserve loading all selected
views before any report output, single-scope native YAML, and ordered
`--scope all` framing, including existing directive/document handling.
Keep necessary per-document buffers; do not introduce whole-run buffering.
4. Include serialization, native rendering, and framing inside the managed
rendering operation so callback failures are recorded under quiet as well.
Report additional messages outside callbacks through the reporter.
5. Migrate `config validate`, including selected-scope iteration, result records,
acquisition failure, and terminal explanation. Preserve existing request and
invocation-state lifetimes, cancellation, and preflight selection.
Validation: preserve show YAML/framing and load-before-output protections.
Add representative mixed warning/failure visibility in ordinary/verbose/quiet,
deferred context ordering, rendering failures, and report write failures.
Update tests of superseded outcome fields to observable reports and
`Presentation`, rather than dropping their original failure protection.
Configuration show and validate now submit provider messages through the shared
reporter and render results through managed output. Tests retain acquisition
ordering, shared state, YAML framing, cancellation with partial validation
output, and cover warning visibility, terminal context, quiet rendering, and
destination failures through `Presentation`. The ckconfig tests remain
unrunnable because `sources.go`, `explain.go`, `diff.go`, and family wiring and
tests still reference APIs scheduled for later migrations; Stage 20 will run the
assembled suites.
## Stage 12: Migrate configuration sources, explain, and diff
Status: complete.
Primary files: [sources.go](../../integrations/ckconfig/sources.go),
[explain.go](../../integrations/ckconfig/explain.go),
[diff.go](../../integrations/ckconfig/diff.go), and remaining report helpers.
1. Migrate remaining commands to the same reporting helpers and deliberate
operation explanations. Preserve scope availability, selection, source
evidence, redaction, and ConfigKit-owned comparison semantics.
2. Wrap the complete CommandKit-owned report rendering in `RenderOutput`,
including preparation/encoding that can fail before destination writes.
Keep existing safe diff YAML and changed/deleted-field presentation.
3. Submit independent provider messages once and attach terminal explanation
only to the returned failed outcome. Preserve partial results already
delivered according to each command's existing behavior.
4. Remove obsolete integration delivery flags, outcome diagnostic attachment,
manual writer tracking, and helpers superseded by the shared reporter.
Preserve meaningful operation causes; do not assign a presentation error as
the operation cause merely to reproduce old bookkeeping.
Validation: retain comparison preflight, selector, safe-report, and native
forwarding tests. Cover a representative failure on each destination and quiet
rendering without duplicating core's complete visibility matrix in every command.
Configuration sources, explanation, diff, and scope inventory now submit
provider messages through the shared reporter and render through managed output.
Native explanation rendering retains already-written partial output, while
renderer and destination failures are recorded in `Presentation` independently
of operation causes. Focused ckconfig package tests pass, including comparison
preflight, source evidence, safe reports, native forwarding, warning/context
ordering, quiet rendering, and result/diagnostic destination failures. The full
ckconfig module command still fails to compile `examples/simple`, which uses the
old invocation API and is scheduled for migration in Stage 17.
## Stage 13: Migrate PromptKit acquisition adapters
Status: complete.
Primary files: [adapters.go](../../integrations/ckprompt/adapters.go),
[validation.go](../../integrations/ckprompt/validation.go),
[smoke.go](../../integrations/ckprompt/smoke.go), and provider contracts.
1. Carry passive independent diagnostics and terminal explanation separately
through engine loading, definition/requirements acquisition, request
validation, and smoke acquisition. Use a private acquisition bundle where
necessary to avoid losing either field between helpers.
2. Replace mixed-batch severity promotion while preserving native rendering,
primary/related source context, source legends, scope/side labels, and
disclosure. Keep unrelated warnings out of terminal explanations.
3. Update `ConfigScope` and engine comparison to use the migrated ckconfig
contracts. Keep dependency direction ckprompt to ckconfig.
4. Preserve original returned errors, useful reports alongside errors, native
validation/smoke evidence, builder/preflight order, and selected request
lifetimes. Providers remain independent of the reporter.
5. Use upstream APIs as adopted. Do not add probes, diagnostic interpretation,
extra inference calls, capability warnings, or newly required configuration.
Validation: establish correct forwarding, passive data, separated context,
and supported error identity. Preserve offline fixtures and existing builder/
acquisition lifecycle protections. No live provider or credentials are needed.
PromptKit engine loading now separates independent warning batches from native
terminal records and carries both through catalogs, inspection, requirements,
ConfigScope comparison, concrete request validation, and smoke acquisition.
Native rendering retains scope/side headings, source legends, and diagnostic
locations; original errors and reports returned with errors remain available.
Offline adapter tests cover mixed record separation, writer propagation,
comparison-side context, error identity, and existing request lifetimes. The
ckprompt suite cannot execute yet: compiler diagnostics identify legacy
reporting calls in `command.go`, `smoke_command.go`, and their tests, which are
scheduled for Stages 14 and 15. No compile errors were reported in the migrated
adapter files; Stage 20 will run the assembled suite.
## Stage 14: Migrate PromptKit listing and inspection commands
Status: complete.
Primary file: [command.go](../../integrations/ckprompt/command.go) and its
catalog, definition, and requirements report helpers/tests.
1. Migrate catalog listing, definition inspection, and requirements inspection
to the reporter, replacing raw stream writes and old `definitionFailure`
diagnostic bookkeeping.
2. Submit independent diagnostics once through small integration-private
category helpers; use acquired terminal context on failure and preserve
optional original causes. Native labelled detail uses an empty heading.
3. Enclose report preparation, native rendering/encoding, and delivery in
managed rendering where CommandKit invokes them. Quiet runs still discover
report-generation failures without touching caller result destinations.
4. Preserve native optional/version fields, text/YAML formats, selection,
aliases, request bindings, and command availability. The reporting feature
does not change what an inspection means or which APIs it invokes.
Validation: migrate existing report/selection tests; add representative
successful quiet/verbose/debug execution and native acquisition failure with an
independent warning. Reuse core reporting coverage for universal mechanics.
PromptKit catalog listing, definition inspection, and requirements inspection
now submit provider messages through the reporter and render complete reports
inside managed output. Acquisition failures use native terminal context when
available and preserve the original cause. Tests cover independent warning and
terminal-context delivery, successful ordinary/quiet/verbose/debug execution,
quiet encoding failure, cancellation, and result delivery observations. The
ckprompt suite cannot run yet because `validation_command.go` and
`smoke_command.go` still use the legacy reporting API scheduled for Stage 15;
Stage 20 will validate the assembled module.
## Stage 15: Migrate request validation and smoke commands
Status: complete.
Primary files:
[validation_command.go](../../integrations/ckprompt/validation_command.go) and
[smoke_command.go](../../integrations/ckprompt/smoke_command.go).
1. Migrate concrete requirements validation to managed output, passive-message
submission, explicit terminal explanation, and optional cause transport.
Preserve its native report and established status decisions.
2. Migrate each `promptkit test` command and `test all`. Keep the established
four-test order, aggregate request/state reuse, continuation/stop behavior,
cancellation, and operation-error handling except for selected shared
presentation changes.
3. Preserve a substantive smoke report returned alongside an operation error.
Render it before finalization, then attach the terminal explanation. Under
quiet, render into discard while retaining terminal failure visibility.
4. Preserve aggregate collection before its single report is encoded and
submitted. Move aggregate encoding and writing into managed rendering;
do not introduce per-test streaming or change which native tests run because
presentation now uses a reporter. Replace old `smokeDeliveryFailure` flags
with reporter evidence, preserving operation causes and explanations
independently of presentation failures.
5. Remove obsolete manually tracked delivery state. Do not reinterpret native
supported/completed/result fields or add unsolicited warnings.
Validation: use offline stubs/native fixtures to protect aggregate order and
bindings, reports alongside errors, diagnostic ordering, cancellation,
render/write failures, and quiet semantics. Test CommandKit coordination,
not whether PromptKit's validation or smoke checks implement their domain rules.
Concrete request validation and PromptKit smoke commands now submit passive
messages through the reporter and render confirmation, native, and aggregate
reports inside managed output. Smoke reports remain available alongside
operation failures, with native explanations and causes retained separately;
aggregate collection order and shared input/state lifetimes remain covered.
`GOTOOLCHAIN=local go test -count=1 .` passes in `integrations/ckprompt`. The
module-wide `go test ./...` reaches and passes that package but cannot compile
`examples/composed`, whose legacy API callers are scheduled for Stage 17.
## Stage 16: Maintain a core reporting consumer
Status: complete.
Primary files: [examples/core/main.go](../../examples/core/main.go) and its
existing example tests.
1. Migrate current custom commands to `Invocation.Reporter()` while preserving
their existing input and alias demonstrations.
2. Add one small application-owned reporting demonstration to this existing
example, using deterministic local operations/fixtures. Demonstrate ordinary
result rendering, optional Warning and Info, Progress, Debug, and a deferred
summary. Avoid adding another module or an external service.
3. Provide simple selectable scenarios for silent recovery, an explicit warning
with/without a Go error, and a terminal explanation without a cause. Include
failure after partial output and optional cause transport independent of text.
4. Show rendering helpers using their supplied callback writer and additional
message submissions outside callbacks. Keep examples focused on public usage;
delivery accounting mechanics belong in tests/internal documentation.
Validation: keep the example runnable offline and cover representative modes
and failure paths with its owning suite. Use package tests for detailed cause
identity and delivery states rather than expanding the example into a framework.
Completion: migrated the core example command to the invocation reporter and
added a deterministic reporting command with result, warning, info, progress,
debug, recovery, failure, and partial-output scenarios. Its tests cover ordinary,
progress, verbose, debug, and quiet output, plus cause identity and terminal
explanations. `GOTOOLCHAIN=local go test -count=1 ./examples/core` and the debug
demo pass; the partial-output demo prints its completed section and terminal
explanation, then exits with status 1 as expected.
## Stage 17: Migrate maintained integration consumers
Status: complete.
Primary directories:
[ckconfig example](../../integrations/ckconfig/examples/simple) and
[composed PromptKit example](../../integrations/ckprompt/examples/composed).
1. Migrate custom handlers and provider diagnostics to the selected APIs.
Preserve composition, aliases, configuration scopes, input/state sharing,
and the offline smoke client/fixtures.
2. Ensure examples demonstrate independent provider warnings separately from
native terminal context where applicable. Do not add artificial upstream
capabilities merely to produce every message category.
3. Adapt their existing tests to reporters and presentation observations while
retaining native YAML, discovery, and custom-command behavior protections.
4. Exercise ordinary/quiet configuration output, a verbose provider warning,
aliased help, and a smoke report plus terminal failure using controlled
fixture evidence. Avoid multiplying identical mode cases across all commands.
Validation: both example packages compile in their owning modules and run
without external services. Confirm custom commands and integration commands
share the same reporting flags and terminal behavior.
Completion: migrated both example-owned handlers to invocation reporters and
explicit explanations with optional causes. The ConfigKit consumer demonstrates
a provider schema warning in verbose mode, ordinary report output, and quiet
suppression. The composed consumer retains aliased help and offline smoke usage;
its tests cover a native report with an independent provider warning, terminal
context, and an operation error, plus quiet custom-command success and domain
failure. Both integration modules pass `GOTOOLCHAIN=local go test ./...`,
including both example packages. Representative verbose ConfigKit, quiet
ConfigKit, aliased quiet help, and offline PromptKit smoke commands also pass.
## Stage 18: Reconcile the full test suite and architectural boundaries
Status: complete.
1. Find and migrate remaining callers of removed constructors, raw invocation
streams, terminal `SeverityError`, outcome diagnostic submission, and old
delivery fields across tests, benchmarks, and supporting code. `Streams`
supplied to `Execute` remains valid; passive provider diagnostics remain valid.
2. Preserve regression coverage for alias routing, discovery, installation
atomicity, typed presence/false/zero values, invocation freshness, help without
loading, native report contracts, cancellation, and supported error identity.
Update expectations only where the selected reporting behavior changes them.
3. Consolidate reporting coverage at its owning boundary. Include all selected
delivery states, first-error retention per destination, ignored immediate
failures, deferred/terminal failures, domain status handling, caller-writer
ownership, late handles in quiet, and healthy sinks after renderer failure.
4. Verify reporter operations are safe concurrently and keep complete messages/
callbacks intact. Use coordination primitives rather than sleeps or assumed
scheduling. Do not test recursive reporter callbacks as supported use or
infer concurrent dispatcher support.
5. Extend existing facade/import-boundary checks as needed: all new public types
belong to core, public signatures contain no Cobra/pflag/internal types,
backend imports remain internal, and optional dependencies remain optional.
6. Remove obsolete production helpers only after their responsibilities are
accounted for. Do not retain parallel delivery paths for historical tests.
Validation: the suite should now build as a whole using the local three-module
workspace. Focused reporter race testing is justified by the selected concurrent
reporter contract; it does not establish a new permanent repository-wide gate.
Completion: migrated the remaining core tests and benchmark caller to reporter,
explicit explanation/cause, and presentation-result APIs. Updated reporting
expectations for unprefixed Info, routed flag-alias parsing, quiet selection on
routing failures, and causes remaining separate from terminal explanations.
Reporter tests cover delivery states, retained failures, ignored errors,
finalization, domain statuses, quiet late handles, independent healthy
destinations, complete concurrent operations, and caller-owned writers.
`GOTOOLCHAIN=local go test -count=1 ./...` passes in the root, ckconfig, and
ckprompt modules; `GOTOOLCHAIN=local go test -race -run '^TestReporter' .` passes.
Boundary checks confirm Cobra/pflag imports stay under `internal/cobra`, public
Go documentation exposes no backend or internal types, and core does not depend
on either optional integration module.
## Stage 19: Update canonical documentation and compatibility guidance
Status: complete.
1. Update public Go declarations/GoDoc for exact reporter, outcome, provider,
lifecycle, cause, and presentation-observation contracts. Describe callback
writer scope and the selected non-reentrant callback boundary.
2. Update [CLI reference](../cli.md) with the implemented mode/progress policy,
flag placement and aliases, prefixes/channels, quiet help/version, usage
guidance, and presentation-failure status behavior. Keep full operator
definitions here rather than duplicating them in consumer prose.
3. Update [consumer guide](../consumers/guide.md), [README](../../README.md),
integration package documentation, and [developer guide](../development.md)
as affected. Link to maintained examples; explain migration from raw
invocation streams, error-first constructors, outcome diagnostics, terminal
provider diagnostics, and old delivery fields.
4. Update [internal overview](../internal/overview.md) with implemented ownership,
parse occurrence flow, visibility selection, and finalization. Describe
synchronization as implementation mechanics without expanding contracts.
5. Update the unpublished [core v0.3.0 note](../releases/v0.3.0.md) with reporting
scope and the intentional API/CLI compatibility impact, including newly
reserved flag names. Preserve published historical notes and tags. Integration
release-version selection and publication remain separate release preparation;
do not invent release versions or edit historical integration notes.
6. Mark feature implementation status accurately in the roadmap. Preserve the
accepted ADRs and deferred work; do not create new architectural decisions
for settled API details or retire the planning record during implementation.
Validation: review prose against implemented behavior, check local links and
fragments, and run the Markdown whitespace checks from the developer guide.
Current contract documentation must no longer present the removed APIs as valid.
Completion: updated exported GoDoc, the CLI reference, consumer migration
guidance, README, integration package documentation, developer guide, internal
overview, and the unpublished core v0.3.0 note. Current contracts direct callers
to the invocation reporter and keep operation causes separate from explanations
and presentation observations. Reviewed the prose against the implementation
and selected reporting policy; `go doc` checks, local Markdown link and fragment
checks, and both Markdown whitespace checks pass. Historical integration release
notes, accepted ADRs, and deferred-work records remain unchanged.
## Stage 20: Validate the complete end state and finish
Status: complete.
1. Format changed Go files with `gofmt`; review the complete diff for unintended
behavior changes, unselected restrictions, compatibility machinery, public
backend leakage, or duplicate presentation paths.
2. Preserve the existing ignored local `go.work`. Ensure it includes core,
ckconfig, and ckprompt using the developer guide procedure. Keep unpublished
sibling code under local workspace resolution. Do not commit workspace files,
fabricate dependency sums, or change dependency versions solely to make an
unreleased core API resolve through published versions.
3. Run the ordinary suites for all three modules with `GOTOOLCHAIN=local`, using
the workspace for integration development. The commands below are separate
executions with the indicated working directories:
| Working directory | Command |
| --- | --- |
| Repository root | `GOTOOLCHAIN=local go test ./...` |
| `integrations/ckconfig` | `GOTOOLCHAIN=local go test ./...` |
| `integrations/ckprompt` | `GOTOOLCHAIN=local go test ./...` |
| Repository root | `GOWORK=off GOTOOLCHAIN=local go test ./...` |
4. Run focused core reporter tests with the race detector, choosing `-run` to
match the tests created during the sprint. Exercise maintained example
commands covering core result/messaging, quiet help/version, scoped config
YAML, aliases, and offline PromptKit report-plus-failure presentation.
5. Treat workspace-based integration validation accurately: it validates this
sprint's local modules. Independent integration dependency graphs are updated
and validated during release preparation after their new dependencies are
published, following the existing core → ckconfig → ckprompt release order.
No release-tool publication exercise, remote push, or new toolchain/dependency
upgrade is part of this feature validation.
6. Check that every selected roadmap behavior is implemented or has a clearly
identified blocker. Resolve sprint defects before declaring completion.
Keep incidental debug wording, concurrent scheduling order, and private file
layout out of contractual assertions.
7. Review staged/untracked contents, local links, and `git diff --check` plus
`git diff --cached --check`. Finish with focused local commits and report
validation results, any genuine limitations, and worktree status. Do not
declare a release or remove the roadmap/plan automatically.
Completion: `gofmt` left the changed Go files formatted. The root, ckconfig, and
ckprompt workspace suites pass; the root workspace-off suite and focused
reporter race suite pass. Maintained examples pass for core reporting, quiet
help/version, scoped PromptKit YAML, and aliases. The composed-example failure
test confirms an offline smoke report remains available alongside an injected
operational error and terminal context. The ignored `go.work` still includes
all three modules and was not committed. Final Markdown links and fragments and
staged and unstaged whitespace checks pass. All selected roadmap behaviors are
implemented; no release or publication action was performed.
## Completion criteria
The sprint is complete when the selected public API replaces the old paths;
normal parsing supplies reporting selection in every context; one reporter owns
each invocation's presentation and finalization; core and optional integrations
share that policy; native upstream evidence remains authoritative; maintained
examples and current documentation use the implemented API; and complete local
validation succeeds. No additional product decisions are required for this plan.

523
docs/roadmap/remediation.md Normal file
View File

@@ -0,0 +1,523 @@
# Invocation reporting audit remediation
Status: remediation complete; all 7 findings addressed.
## Review scope
Reviewed the reporting implementation from `2a1096b` through `7c035cc` against
the [feature roadmap](reporting.md), [implementation plan](implementation.md),
[ADR 0014](../adr/0014-invocation-reporting.md),
[ADR 0005](../adr/0005-invocation-outcomes.md), and the
[architecture policy](../policy/architecture.md).
The intended ownership is substantially implemented: core owns invocation
reporting, ordered presentation selection, coordinated delivery, and finalization;
the internal Cobra adapter supplies neutral parsing evidence; optional
integrations own native adaptation and command presentation. Providers remain
passive, and ckprompt depends on ckconfig without a reverse dependency.
The findings below concern missing presentation and error evidence within that
architecture. They do not require a different public architecture or automatic
rendering of application causes. No separate unrequested registration restriction,
concurrent-dispatch guarantee, or upstream-domain responsibility was identified.
Evidence includes source and test review, maintained example invocations, and
temporary public-API audit consumers outside the repository. The audit did not
modify implementation code or add tests, and did not rerun the full test suites.
The follow-up interaction audit at `1e532cd` reviewed reporting against existing
alias contexts, typed bindings, scope/input/state preparation, cancellation,
native reports, and maintained consumers. Representative successful invocations
covered command and scoped flag aliases with explicit zero and repeated values,
quiet help/version, aggregate configuration YAML, quiet comparison, concrete
request validation, and offline aggregate smoke testing. Findings 4 and 5 record
the additional gaps; findings 1–3 were still open at that review and were not
duplicated below.
The algorithmic audit at `ee76b0f` examined the reporting-specific parser passes,
delivery bookkeeping, deferred storage, native diagnostic partitioning, and
integration report coordination. It used source-level complexity analysis, not
benchmarks or real-world performance measurements. Selected algorithms were
excluded from reconsideration, including synchronous serialized reporting, FIFO
deferred delivery, rendering rich content under quiet mode, fresh backend trees,
and the chosen configuration/smoke report preparation boundaries. Findings 6 and
7 are implementation opportunities within those decisions.
No newly introduced quadratic-time algorithm was identified in the reviewed
paths. In particular:
- Reporting selection is linear in the number of flag occurrences. There are
four fixed reporting flags and at most two additional reverse searches for
selection provenance; these do not create a quadratic pass.
- Deferred insertion is amortized constant time plus callback-list copying;
draining is linear in queued messages and details, excluding callback/I/O
costs. The avoidable storage and handoff costs are described in finding 6.
- Native diagnostic snapshotting and partitioning are linear in source records,
diagnostic records, and the total related-location entries. Nested copying of
related locations is not quadratic in the actual input size.
- Explanation combination uses amortized slice appends. Configuration comparison
has two sides, and aggregate smoke execution has four selected operations;
neither introduces an unbounded nested traversal through all prior results.
The algorithmic audit found no additional ordinary correctness issue beyond
findings 1–5. It made no implementation changes and ran no tests or benchmarks.
## 1. Preserve native PromptKit operation failure explanations
Priority: P2. Status: complete.
- [x] Completed: bundled native operation failures now supply terminal explanations
without printing arbitrary application causes. Explicit provider context and
independent warnings retain their presentation paths. Native-error forwarding
and single-presentation regressions pass in ordinary and quiet modes.
Validation: `GOTOOLCHAIN=local go test ./...` in `integrations/ckprompt`.
### Finding and evidence
[WithRequestValidation and FromEngine](../../integrations/ckprompt/adapters.go)
carry native operation errors back to their callers, but their acquired terminal
explanations contain only configuration-loading diagnostic records.
[runRequestValidation](../../integrations/ckprompt/validation_command.go) and
[definitionFailure](../../integrations/ckprompt/command.go) then retain the native
error solely as `Cause` and present a generic operation heading when no acquired
explanation exists. Native request-validation and lookup failures consequently
lose their useful explanation at the CLI boundary.
Reproduced in the maintained composed example:
```sh
go run . promptkit requirements validate briefing --campaign spring
```
Its entire diagnostic is:
```text
ERROR: Unable to validate the PromptKit request for prompt "briefing".
```
A separate public-API consumer using the bundled validation adapter confirmed
that the native error contains `missing required input artifact: topic`, while
the acquisition has neither an explanation heading nor a detail renderer. The
cause survives, but the operator cannot see the missing input. The maintained
`promptkit show prompt missing --campaign spring --quiet` invocation likewise
omits the native lookup explanation.
This falls short of useful access to native capabilities and the roadmap's
requirement to keep failures understandable. Separating causes from presentation
does not prohibit an integration from deliberately adapting native failure
content into an explanation. Existing native-failure tests primarily check
classification and cause identity while discarding the diagnostic destination,
so they do not protect this handoff.
### Required work
- At the bundled PromptKit adapter/command boundary, deliberately provide the
relevant native operation failure explanation, using the owning API's public
presentation and disclosure rules. Cover validation and the other native
inspection/selection paths affected by the same migration.
- Preserve the original cause independently and preserve an explicitly supplied
provider explanation. Keep independent warnings separate and avoid presenting
the same native failure through two paths.
- Keep core's cause-neutral behavior. Do not solve this by printing arbitrary
`Outcome.Cause` values or treating every application-owned loader/provider
error as safe operator text.
### Acceptance evidence
Use controlled upstream failure evidence to verify that CommandKit forwards
useful native failure content into the terminal explanation in ordinary and
quiet modes, with cause identity retained and unrelated warnings still hidden
where selected. Check explicit provider explanations for single presentation.
These are adapter/presentation assertions, not tests of whether PromptKit
correctly detects missing artifacts or invalid selections.
## 2. Retain independent rendering errors joined with write errors
Priority: P2. Status: complete.
- [x] Completed: joined callback errors retain independent rendering evidence,
while plain and wrapped sink errors remain write failures. Output and detail
regressions pass, including joined destination errors.
Validation: `GOTOOLCHAIN=local go test ./...` in the repository root.
### Finding and evidence
In [reporter.go](../../reporter.go), `recordRendererErrorLocked` drops the entire
callback error whenever `errors.Is(renderErr, writeErr)` is true. That test also
matches `errors.Join(independentRenderErr, writeErr)`: the callback error can
contain an independent rendering failure as well as the propagated sink failure.
The shared helper serves `RenderOutput`, message details, and terminal details.
Dropping the whole joined error loses evidence both from the immediate returned
error and from `Presentation.*.RenderError`.
A public-API audit consumer used a failing result writer and this callback:
```go
_, writeErr := io.WriteString(w, "result")
return errors.Join(renderErr, writeErr)
```
The invocation failed as expected and retained the sink error, but
`errors.Is(returnedError, renderErr)` was false and the final output `RenderError`
was nil. This contradicts the selected retention of independently returned
rendering failures. The current separation test covers returning a standalone
rendering error after a failed write, but not a callback that already joins them.
### Required work
- Distinguish a callback merely propagating its managed-writer failure from a
callback carrying additional independent error evidence. Do not discard the
entire returned error just because its chain also contains the sink error.
- Preserve the independent callback error in the immediate error result and
bounded rendering observations. Retain the first write error separately and
continue avoiding further writes to the failed destination.
- Keep ordinary propagation of a write error from being misclassified as an
independently discovered rendering failure. No transcript or new public
error-classification API is needed.
### Acceptance evidence
Add a focused joined-error regression at the shared reporting boundary. Verify
`errors.Is` access to both failures in the returned error, rendering evidence in
`RenderError`, and sink evidence in `WriteError`. Preserve existing coverage for
plain/wrapped write-error propagation and first-error retention. Exercise a
detail-rendering path as needed to establish that the shared fix applies there.
## 3. Explain failures introduced by CommandKit presentation
Priority: P2. Status: complete.
- [x] Completed: presentation failures that turn success into failure now retain
a core-owned rendering/delivery explanation. Supplied explanations and causes,
generic handler fallback, quiet rendering, domain status, and failed-sink
behavior remain covered. Validation: `GOTOOLCHAIN=local go test ./...` at root.
### Finding and evidence
[Reporter.finalize](../../reporter.go) changes an otherwise successful outcome
to failure when presentation failed, then invokes the same
`ensureFallbackExplanation` used for an unexplained failed handler outcome.
Consequently, a CommandKit-invoked renderer failure or a result-write failure
produces only:
```text
ERROR: Command failed.
```
The diagnostic destination may be entirely healthy. The caller can inspect
`Presentation`, but the operator is not told that rendering or delivery failed.
This also affects successful domain outcomes changed to non-zero status.
Public-API consumers reproduced the generic explanation after both a callback
that failed before writing and a rejected result write. The corresponding
reporter and execution tests currently assert this generic text, so they preserve
the gap rather than the selected distinction.
[Stage 4](implementation.md#stage-4-implement-deferred-delivery-and-finalization)
requires a useful core-owned explanation when presentation turns success into
failure. The roadmap assigns handling of reporting failures to CommandKit and
distinguishes healthy diagnostic destinations from failed result destinations.
The generic fallback remains appropriate for a failed handler outcome that
supplies no explanation; it does not explain the presentation failure core
itself discovered.
### Required work
- When presentation changes a successful outcome into failure and no explanation
was supplied, attach and retain an explanation identifying the failed
presentation operation, such as result rendering or result delivery. Present
it through a healthy diagnostic destination.
- Keep the original optional cause unchanged and preserve supplied explanations.
An informative core-owned heading is sufficient; this does not require
automatically exposing callback error strings or application causes.
- Preserve the selected outcome/status table, exact generic fallback for
otherwise unexplained failed outcomes, and explicit native explanations,
including a non-nil renderer that emits no bytes.
- Do not retry a failed diagnostic destination, redirect into substantive output,
replay explanation callbacks, or introduce recursive reporting-failure messages.
### Acceptance evidence
Update focused tests so a successful operation with an output-rendering or
output-writing failure receives a retained presentation-specific explanation
through working diagnostics, including quiet rendering failure and a successful
domain outcome. Preserve evidence for supplied explanations/causes, generic
fallback on unexplained handler failure, and diagnostic-sink failure without
retry. Assert the meaningful failure category rather than inventing a new
public exact-wording guarantee.
## 4. Restore cancellation checks between report preparation and delivery
Priority: P2. Status: complete.
- [x] Completed: restored preparation-before-delivery cancellation checks for
PromptKit catalogs, definitions, requirements, and validation, and buffered
configuration diff/sources/YAML explanation reports. Cancellation retains its
operation cause without becoming a rendering failure; native streaming and
smoke-report behavior remain unchanged. Controlled preparation/serialization
checks cover ordinary and quiet modes. Validation: module suites pass in
`integrations/ckconfig` and `integrations/ckprompt`.
### Finding and evidence
Before the reporting migration, PromptKit's `writePromptReport` checked the
invocation context after a report had been prepared and before writing it.
The migrated [renderProfileReport](../../integrations/ckprompt/command.go)
formats its text report inside `RenderOutput`, writes it immediately, and
returns success without that intervening cancellation check. The surrounding
inspection command checks cancellation only before entering report preparation.
A deterministic public-API consumer supplied a profile inspection with a
controlled YAML marshaler in `EffectiveModelParams.ExtraParams`. The marshaler
cancelled the invocation context and returned a valid value while the text
report was being prepared. This uses the ordinary report serialization boundary;
it does not reenter the reporter or require concurrency or an external service.
Observed results for `promptkit show profile demo --format text`:
| Mode | Context at completion | Status | Cause matches cancellation | Report bytes |
| --- | --- | --- | --- | --- |
| Ordinary | `context.Canceled` | `0` | No | `277` |
| Quiet | `context.Canceled` | `0` | No | `0` |
Cancellation occurred before the first destination write. Previously, the shared
post-preparation check would have returned operational failure with the
cancellation cause and left the report destination untouched. The migration also
removed similar post-preparation checks from buffered configuration report paths,
including [config diff](../../integrations/ckconfig/diff.go). Its current
post-render cancellation branch runs only when rendering already returned an
error, so successful preparation does not preserve the old checkpoint.
The plan requires preserving existing cancellation behavior outside the selected
reporting changes. Quiet suppression is not intended to turn this cancellation
into success. Existing cancellation tests cover acquisition and write failures,
but do not cover cancellation during successful report preparation.
### Required work
- Restore the integration-owned cancellation checkpoint after report preparation
and before delivery in migrated paths that previously had it. Review the
removed `writePromptReport` callers and the buffered configuration paths rather
than patching only the demonstrated profile-text case.
- Preserve supported cancellation identity in the operation outcome, while
retaining reporter ownership of rendering and destination failures. Apply the
same operation decision under quiet mode.
- Keep this change bounded to the existing preparation/delivery behavior. Do not
introduce a core-wide rule that rejects every cancelled application invocation,
interrupt caller writers, withdraw delivered bytes, or promise cancellation
after delivery has already begun. New whole-run buffering is unnecessary.
### Acceptance evidence
Use a controlled serialization/preparation callback to cancel before the first
report write. Assert operational failure, `errors.Is` access to
`context.Canceled`, and no report delivery, including quiet mode. This verifies
CommandKit coordination, not upstream serialization or domain behavior. Preserve
existing coverage for cancellation during acquisition, partial output, and
caller-owned writes that have already started.
## 5. Apply reporting-flag aliases in unresolved-route parse contexts
Priority: P2. Status: complete.
- [x] Completed: unresolved routes obtain aliases from the context selected by
normal parsing, through neutral internal metadata. Root and nested-group
canonical/alias comparisons, quiet failure visibility, scoped precedence,
unchanged unresolved identity, and factory non-execution pass.
Validation: `GOTOOLCHAIN=local go test ./...` at the repository root.
### Finding and evidence
[compileFlagAliases](../../flag_aliases.go) now resolves application-wide aliases
to the new reporting flags in root and group contexts. However,
[execution.prepareSession](../../execute.go) supplies aliases to the parser only
for `routeCanonical` and `routeDiscovery`. For an unresolved command path it
supplies none, even when the normal parser selects the root or a known group
whose reporting flags and alias mappings are available.
With an installed application-wide `FlagAlias("-d", "--debug")`, a public-API
consumer reproduced:
| Invocation | Observed diagnostics |
| --- | --- |
| `app missing --debug` | Debug route/selection detail, then the unknown-command usage failure. |
| `app missing -d` | `unknown shorthand flag: 'd' in -d`, with no Debug detail. |
| `app group missing --debug` | Debug detail, then the group operand/command usage failure. |
| `app group missing -d` | `unknown shorthand flag: 'd' in -d`, with no Debug detail. |
| `app group run -d` | Successful command execution with Debug detail. |
The spelling registered for a reporting flag is thus treated as unknown on the
failure paths where operators may need debugging. Canonical mode selection on
these paths already works. This conflicts with the
[CLI alias contract](../cli.md#application-owned-aliases): an application-wide
alias is effective wherever its canonical target is available. The new reporting
flags make these existing parse-context gaps observable for mode selection.
Current reporting parse tests cover a canonical reporting flag after a missing
command and aliases on valid routes, but not their combination.
### Required work
- Supply the alias mapping applicable to the context used by normal parsing
when the full entered command path does not resolve. Cover root and known
group parse contexts, preserving existing scope/precedence rules where they
apply.
- Keep ordered canonical/alias occurrence handling in the normal parser. Do not
add a separate reporting-flag scanner or enable unrelated aliases whose
canonical target is unavailable.
- Preserve the original routing failure, handler/factory non-execution, and
entered-path usage behavior. Finding a parse context must not manufacture a
successfully resolved canonical command identity for the invalid route.
### Acceptance evidence
Compare canonical and application-wide aliased reporting flags on unresolved
root and nested-group paths. They should select the same mode and retain the
same underlying command-path failure rather than invent an unknown-flag error.
Include a quiet alias followed by another parse error, without suppressing the
terminal usage explanation. Retain scoped-alias precedence, unavailable-target,
terminator/value handling, and discovery-context coverage at their existing
owners.
## 6. Avoid retaining deferred messages that need no presentation work
Priority: P3. Status: complete.
- [x] Completed: renderer-free hidden deferred messages record suppression
without queue retention. Finalization detaches the queue without an array
copy and delivers captured callback lists without recopying them. Hidden rich
callbacks, FIFO order, no-content handling, capture, and closure remain covered.
Validation: root module suite and focused reporter race checks pass.
### Finding and complexity
[DeferredReporter.enqueue](../../reporter.go) unconditionally stores the category,
heading, and copied detail list. Visibility is checked only when `finalize`
drains the queue through `submitMessage`. Consequently, a long-running handler
that submits many simple deferred Debug messages in ordinary mode, or simple
Info/Warning messages in quiet mode, retains all of them despite there being
no output or renderer work to perform.
Let `M` be the number of these renderer-free hidden messages and `B` the bytes
of otherwise unreferenced distinct heading text. Reporter storage can grow as
`Θ(M + B)` until finalization. Finalization then makes a second `Θ(M)` queue
array copy before visiting and suppressing every entry. This is linear, not
quadratic, but the additional reporter storage for this case can be `O(1)`.
The `M` public submissions still require `Θ(M)` total call work; this is not a
claim that the whole invocation can become sublinear.
### Refactoring opportunity
Under the current execution flow, reporting selection is applied before handlers
and providers run, with no public operation to change it mid-handler. For a
hidden deferred submission with no non-nil detail callback, consider recording
the eventual suppression observation without retaining the heading or queue
entry. Preserve the existing no-content treatment of an empty heading with no
renderer. This removes the avoidable `Θ(M + B)` retention and its later sweep
for renderer-free suppressed messages.
Retain rich hidden submissions: their callbacks must still execute at the
selected deferred time against discard so rendering failures are preserved.
Retain visible messages in FIFO order. Closed-reporter checks must still run
before accepting or discarding any submission. This optimization does not add
a public immutability guarantee for future mode APIs or change the selected
emission-time visibility rules.
Independently, `finalize` can detach the reporter-owned queue under its existing
mutex instead of copying its array and then clearing the original reference.
That makes queue handoff `O(1)` instead of `Θ(M)` extra copying; actual visible/
rich message delivery remains linear. An internal delivery path can also avoid
recopying callback lists already captured by `enqueue`, while preserving the
selected defensive copy when first accepting caller-owned lists.
These opportunities preserve the chosen queue and synchronous delivery model.
They do not justify message caps, dropping visible messages, skipping rich
renderers, or a background delivery subsystem.
### Review evidence
The retention and copies follow directly from `enqueue`, the array copy at the
start of `finalize`, and `submitMessage`'s later visibility check. If selected,
use focused behavior checks for suppression observations, FIFO delivery,
callback capture, hidden renderer failures, and late submissions. Source-level
storage analysis is sufficient; no benchmark or allocation threshold is proposed.
## 7. Stop normalizing an argument tail that parsing cannot reach
Priority: P3. Status: complete.
- [x] Completed: interpretation returns the first fatal unknown-flag prefix,
retaining earlier canonical/alias occurrences and short-cluster members.
Temporary slices grow with that prefix; unreachable values and spellings are
no longer normalized or copied. Existing error precedence, values, and
terminator coverage passes. Validation: root module suite passes.
### Finding and complexity
In [interpretFlagTokens](../../internal/cobra/flags.go), the first unknown long
flag or unknown short-cluster member sets `recordingOccurrences = false`, but
interpretation continues through the remaining argv. Later recognized flags
still populate spelling lists and construct canonical `--name=value` strings.
[Session.Parse](../../internal/cobra/parse.go) then calls the backend, which
fails at the earlier unknown flag without consuming that normalized tail.
The adapter does not enable unknown-flag whitelisting.
For example, after `--unknown`, a later valid `--message=LARGE_VALUE` still
causes the value bytes to be copied into a new canonical argument, even though
no parser, converter, or handler reaches that value. The recent occurrence
tracking correctly excludes the tail from selection evidence but retains this
pre-existing unnecessary normalization work.
Let `F` and `A` be the flag/alias table sizes, `P` the token/byte processing cost
through the first fatal unknown flag, and `T` the remaining normalization work,
including copied value bytes. With expected constant-time map lookups, the helper
currently takes `O(F + A + P + T)` time on that failure path. It can take
`O(F + A + P)` by stopping at the failing token and growing temporary arrays
with the prefix instead of preallocating for all argv. The existing backend can
still produce the same failure from that prefix. This is an early-failure
improvement, not a quadratic-time finding or a change to the successful parse's
necessary linear work.
### Refactoring opportunity
Consider ending interpretation immediately after preserving the first unknown
flag or failing short-cluster suffix. Preserve any earlier valid occurrences and
their spellings, including recognized members preceding a bad short member.
Let the backend retain its existing failure classification, wording, and
precedence; do not replace normal parsing with a reporting-specific scanner.
This can also remove the repeated `recordingOccurrences` guards needed solely
because the current implementation continues past the failure.
The helper currently preallocates both output and occurrence slices from total
argv length, so an early return alone does not eliminate its `O(argc)` upfront
array allocation and initialization. To obtain the prefix-bounded helper cost,
grow those arrays with the interpreted prefix rather than reserving for the
unreachable tail. This changes allocation strategy, not accepted input sizes.
Other routing/parser steps still copy argv string headers, so this refactoring
does not make the entire invocation prefix-linear. It specifically removes
avoidable tail interpretation, spelling storage, and large-value byte copies.
There is no need to reconsider the selected backend or fresh-tree strategy.
### Review evidence
The unknown-flag branches and continued spelling/string construction establish
the extra work statically. Existing prefix-order, missing-value precedence,
short-cluster, and terminator/value tests provide relevant behavior coverage if
this is selected. Preserve the backend error and consumed occurrence prefix on
early failure; no timing threshold or real-world performance data is needed.
## Final remediation validation
All seven items have completion markers and focused regression evidence. The
following checks pass for the assembled changes:
- `GOTOOLCHAIN=local go test ./...` at the repository root and in each of
`integrations/ckconfig` and `integrations/ckprompt`, including maintained examples.
- `GOWORK=off GOTOOLCHAIN=local go test ./...` at the repository root.
- `GOTOOLCHAIN=local go test -race -run '^TestReporter|^TestHidden' .` at root.
- Go formatting, staged/unstaged whitespace review, and local document-link review.
The ignored local workspace is preserved. No dependency upgrade, release,
publication, or change to upstream domain behavior was part of this work.

890
docs/roadmap/reporting.md Normal file
View File

@@ -0,0 +1,890 @@
# Output policy and invocation reporting
Status: behavioral decisions selected through OD-4. Message categories, all four
output modes, uniform treatment of help and version, message prefixes, and
OD-1's flag grammar and scope (including independent Progress control) are selected.
OD-2's unified reporting direction, the terminal failure model, and application
ownership of domain-specific error decisions are selected.
OD-3's failure presentation and retention boundaries are selected.
OD-4's delivery lifecycle, write-failure handling, and quiet-mode rendering are
selected. The consumer API signatures, presentation-result model, provider
diagnostic boundary, fallback explanations, and rendering callback boundary
below are also selected. Architectural preparation is complete:
[ADR 0014](../adr/0014-invocation-reporting.md) is accepted, and the
[architecture policy](../policy/architecture.md) is aligned. Implementation and
final validation are complete; Stages 1–20 are complete. The [implementation
plan](implementation.md) records the stage order and validation evidence.
## Purpose
Provide consistent handling of `--quiet`, `--verbose`, `--debug`, and
`--progress` across application commands and optional integrations. Reduce
repeated output classification, diagnostic presentation, and delivery bookkeeping
where CommandKit can provide a meaningful benefit.
Ordinary Go writers and formatting functions already make application output
straightforward. Design reporting conveniences around the shared output policy
and invocation lifecycle. Applications continue to own their report content,
domain behavior, and operation outcomes.
## CLI presentation and error-handling ownership
Applications decide how to respond to domain-specific errors: recover, retry,
continue silently, report a Warning, or end the invocation with a failure.
CommandKit may provide shared helpers for carrying, inspecting, and presenting
errors while leaving those decisions with the application. A Go `error` alone
does not determine a CLI message category. Warnings may describe issues with or
without an underlying Go error, and handling an error does not require emitting
a message.
CommandKit handles the presentation the application requests: message category,
visibility, formatting, timing, and delivery. For terminal application failures,
the application supplies a non-zero outcome and an operator-facing explanation.
It may also supply an optional Go error cause for the execution caller to inspect.
CommandKit preserves that cause and its existing `errors.Is`/`errors.As` behavior.
Supplying a cause alone does not select an outcome, generate an operator-facing
message, or change visibility.
Applications can explicitly present `err.Error()` as a Warning through the
ordinary message API, illustrated by `report.Warn(err.Error())`. Defer a separate
`WarnError` convenience helper pending demonstrated value; this conversion
already requires little code.
CommandKit retains responsibility for handling its own errors, including
routing, parsing, and reporting failures. Integration-owned commands similarly
adapt their upstream APIs into deliberate command outcomes and presentations.
Ordinary Go error returns from CommandKit APIs and writers remain available where
appropriate.
## Current baseline
[`Streams` and `Invocation`](../../identity.go) expose caller-selected output
and diagnostic writers. [`Outcome` and `Diagnostic`](../../outcome.go) carry
operation causes, diagnostic writers, and delivery state. Handlers can return
`Failure(err)` or attach diagnostics to an outcome for presentation after the
handler returns. `MessageDiagnostic` currently writes the supplied message with
a newline; severity does not add a label automatically.
CommandKit coordinates final diagnostic delivery and retains delivery failures
separately from the operation cause. Custom handlers can also write directly to
the supplied streams and retain responsibility for their output. The existing
outcome and channel ownership decisions are recorded in
[ADR 0005](../adr/0005-invocation-outcomes.md).
Integration adapters already translate upstream diagnostic records into
CommandKit diagnostics while preserving native rendering and source context.
Some adapters wrap multiple records in one diagnostic writer and classify the
batch by its highest severity. This matters when designing selective visibility.
There is currently no shared verbosity policy or invocation reporter API.
## Selected output policy
Distinguish substantive application output from diagnostic message categories:
- **Substantive output (Normal):** The requested report or command result.
- **Error:** Terminal presentation of an unrecoverable invocation failure,
resulting in a non-zero status code.
- **Warning:** Something went wrong or deserves attention, but the invocation
can still succeed. The application chooses whether to report such an issue.
- **Info:** Informational context and useful explanation, such as which profile
was selected or a final operation summary.
- **Progress:** Streaming updates about work underway, such as items processed.
- **Debug:** Troubleshooting detail, including intermediate decisions and
execution-path information.
Debug mode adds troubleshooting detail to verbose output:
| Output category | `--quiet` | No flag | `--verbose` | `--debug` |
| --- | --- | --- | --- | --- |
| Substantive application output | Suppressed | Shown | Shown | Shown |
| Error | Shown | Shown | Shown | Shown |
| Warning | Suppressed | Suppressed | Shown | Shown |
| Info | Suppressed | Suppressed | Shown | Shown |
| Debug | Suppressed | Suppressed | Suppressed | Shown |
| Progress, without an explicit setting | Suppressed | Suppressed | Suppressed | Shown |
Info belongs in verbose mode; debug includes verbose messages. Normal output
remains a substantive result category, separate from diagnostic severity.
The category of a message determines its visibility; stream destination alone
cannot distinguish a warning from an error.
Progress is distinct from Info and is controlled separately as specified below.
Applications and integrations are not required to generate messages in any
category. Enabling a category adds no output when there are no messages in it;
other output remains subject to its own visibility rules.
Error presentation follows the terminal failure model below. An application
may report a recovered problem as a Warning or continue without a message.
Apply the same policy to help and version. There are no command-specific
exceptions for successful output:
```text
app help --quiet # succeeds silently
app version --quiet # succeeds silently
```
Usage and operational failures still report their errors in quiet mode.
The feature must distinguish successful output suppression from failure
diagnostic delivery.
### Message prefixes and channels
Substantive output uses the result destination, conventionally stdout. Warning,
Info, Progress, Debug, and terminal explanations use the diagnostic destination,
conventionally stderr. Callers supply both destinations.
Use `WARN:`, `DEBUG:`, and `ERROR:` prefixes for simple warning, debug, and error
messages respectively. Substantive output and Info messages have no prefix.
For multiline diagnostics, prefix the opening line and preserve the structured
detail. Integration adapters coordinate native diagnostic rendering to avoid
duplicate severity labels or loss of upstream formatting.
Usage guidance belongs to the terminal usage explanation and remains visible in
quiet mode. Successful help output follows the substantive-output policy.
Initially, Progress messages are unprefixed streamed lines. The diagnostic
destination keeps them separate from substantive output such as JSON or YAML.
Terminal redraw and progress-bar behavior are outside this implementation.
### Terminal failure model
During execution, applications can produce substantive output, Progress, Info,
Debug, and Warnings. An unrecoverable failure ends the handler by returning a
failed outcome. There is no standalone `report.Error(...)` method. Detailed
error explanations accompany the returned failure and are presented as part of
that terminal step.
CommandKit finalizes reporting, presents the terminal failure explanation, and
returns the non-zero outcome to the application entry point. It accepts no
further normal output for that invocation after terminal error presentation.
The same boundary applies to core routing and parsing failures that prevent
handler execution. The application entry point retains ownership of process
exit; CommandKit ends the invocation and returns its outcome.
For example, if an export operation fails and the application decides it cannot
continue, the handler returns a non-zero outcome with an explicit explanation
such as "Unable to export the requested report." The application can choose to
include detail from a Go error in that explanation. CommandKit presents the
supplied explanation without automatically deriving text from an application
error. The selected API below provides failure constructors for this purpose.
When a failed outcome supplies no explanation, CommandKit supplies the generic
fallback defined under terminal outcomes below.
Output streamed before the failure remains visible according to its selected
mode; terminal failure does not withdraw earlier bytes. Deferred messages,
cleanup writes, and delivery failures must respect the end of normal reporting.
Their finalization and late-write behavior follow OD-4 below. A terminal
failure may have multiple explanatory records, all belonging to that failure.
### Failure explanations and presentation ownership (OD-3)
Make a non-zero outcome with an explicit message the convenient failure path.
Support structured explanations, including multiple records, through the same
presentation facility for richer application and integration reports. Neither
form requires an underlying Go error.
The application or integration explicitly identifies the context belonging to
a terminal explanation. Present that context with the failure in every mode,
including quiet. Independent warnings and other messages retain their own
visibility rules. For mixed upstream batches, adapters preserve useful native
rendering while distinguishing terminal explanatory context from independent
messages. Core does not infer those relationships from error chains or text.
Assign each submitted message one presentation path. Returning a terminal
explanation schedules its presentation once; core does not append another
message derived from an application error cause. Native renderers can supply
already-labelled content without receiving duplicate prefixes. Avoid text-based
deduplication: separate events may legitimately produce identical messages.
Constructing a failure result attaches explanatory content without emitting or
independently scheduling an Error. Returning that result hands the explanation
to CommandKit for terminal delivery through the invocation's reporting facility.
The selected API expresses this distinction for both simple and structured
explanations. For example:
| Handler action | Presentation |
| --- | --- |
| Construct a non-zero result with "Unable to export the requested report," then return it. | Construction emits nothing. CommandKit presents the message once during terminal delivery. |
| Return a failure with native diagnostic records and their source context, submitting independent warnings separately. | CommandKit presents the structured terminal explanation in every mode. Independent warnings follow their own visibility policy. |
### Retention boundary (OD-3)
CommandKit provides invocation-scoped CLI presentation. Logging and message
history collection remain application concerns. Keep terminal explanations and
any supplied cause with the returned outcome, and keep the temporary state needed
for deferred delivery, parser events awaiting mode selection, and delivery
bookkeeping. The reporting facility does not collect a transcript or retain other
emitted or suppressed messages for later inspection. No optional message-history
facility is planned for this implementation.
## Selected flag grammar and scope
### Mode selection and resets
Model quiet, ordinary, verbose, and debug as mutually exclusive presentation
modes. Each invocation starts in ordinary mode. Process flag occurrences in
invocation order; the last enabled mode wins. Repetition does not increment
verbosity, and debug is selected explicitly.
Accept presence and the existing explicit boolean syntax, including
`--quiet=true` and `--quiet=false`. A false occurrence clears its mode only if
that mode is currently selected, returning to ordinary mode. It does not restore
an earlier selection or disable a different mode. Every occurrence is converted;
invalid boolean values remain usage errors even when a later flag would replace
the selection.
| Flag sequence | Final mode |
| --- | --- |
| `--quiet --verbose` | Verbose |
| `--debug --quiet` | Quiet |
| `--verbose --verbose` | Verbose |
| `--verbose --verbose=false` | Ordinary |
| `--quiet --verbose=false` | Quiet |
| `--verbose --quiet --quiet=false` | Ordinary |
| `--quiet=false --debug` | Debug |
Keep verbosity a presentation choice: quiet preserves the requested operation's
semantics and rendering failures. Delivery failures depend on the output actually
requested; suppressing a destination write can avoid a delivery failure. Apply
the policy through CommandKit-managed reporting and streams. Output written
directly to process streams or external destinations remains application-owned.
### Progress selection
`--progress` enables Progress without enabling Info, Warning, or Debug messages.
`--progress=false` explicitly disables it. Accept the existing boolean syntax;
repeated canonical or aliased progress settings use the last value. Retain
whether a setting was explicitly supplied, separately from its boolean value.
Determine effective Progress visibility from the final presentation mode and
explicit progress setting:
1. Quiet mode always suppresses Progress.
2. Otherwise, an explicit progress setting controls visibility.
3. Without an explicit setting, debug enables Progress; ordinary and verbose
modes leave it disabled.
An explicit progress setting overrides the mode's default regardless of its
position relative to mode flags. It does not select or reset the presentation
mode. Debug includes verbose detail and defaults Progress on; verbose alone
does not enable Progress.
| Flags | Result |
| --- | --- |
| `--verbose` | Warning and Info, without Progress. |
| `--verbose --progress` | Warning, Info, and Progress. |
| `--debug` | Warning, Info, Debug, and Progress. |
| `--debug --progress=false` or `--progress=false --debug` | Debug detail without Progress. |
| `--quiet --progress` or `--progress --quiet` | Only failure diagnostics. |
| `--debug --verbose` | Verbose detail without Progress, absent an explicit progress setting. |
Passing `--progress` to an application that emits no Progress messages adds no
visible output. It does not suppress that application's substantive results or
require a progress implementation.
### Placement, availability, and aliases
Core supplies `--quiet`, `--verbose`, `--debug`, and `--progress` for application
commands, integrations, the root, canonical groups, and alias-only discovery help.
Consumers do not register them individually. Preserve the existing placement
grammar: flags follow the complete command path and may be interspersed with
operands after that path. For example:
```text
app config show --quiet
app export destination --verbose
app help config show --quiet
app version --quiet
```
Options before or between command-path components are outside this grammar.
Tokens consumed as values and tokens following `--` retain their ordinary
meaning.
Reserve the four long spellings. Applications may add `-q`, `-v`, or `-d`
through the alias API; no built-in short spellings are selected. An application
flag declaration that defines a reserved long name fails installation with a
collision error. Aliases may target these flags but may not overload canonical
spellings. Apply the existing collision and atomic installation rules, including
later registration.
Aliased and canonical occurrences participate in the same ordering. With
`-q → --quiet`, `app export --verbose -q` selects quiet mode. Document the newly
reserved canonical names as a compatibility change.
### Routing and parsing failures
Determine the mode through the normal parser before execution or successful
help/report presentation. Do not add a separate verbosity scanner. This keeps
token interpretation consistent and avoids reinterpreting values or operands.
Routing and parsing failures report their errors regardless of verbosity. For
example, `app export --quiet --unknown` still reports the unknown flag. Error
visibility follows the selected output policy independently of how the mode is
determined; avoiding a separate scanner is an implementation simplification.
## CommandKit's own diagnostics
Use the shared reporting system for CommandKit's invocation diagnostics so
parser, integration, and application messages follow the same visibility and
delivery policy. Installation errors occur before an invocation exists and
continue to be returned to the application without automatic printing.
Supported repetition and last-occurrence overrides are routine decisions.
Repeated singleton flags, including `--verbose --verbose`, may produce debug
detail; repetition alone does not warrant a warning. Repeatable flags retain
their intended accumulation behavior. Add warnings for concrete concerns an
operator can act on, such as a deprecated spelling if deprecation is introduced.
Unknown flags, invalid values, and invalid command paths remain invocation
failures with error diagnostics.
Start debug instrumentation with routing, alias resolution, verbosity selection,
and handler completion. Other useful candidates can be added where they
explain a consequential decision:
| Event | Useful detail |
| --- | --- |
| Command-alias resolution | Entered path, intermediate redirects, and final canonical path. |
| Flag-alias resolution | Entered spelling and canonical flag name. |
| Scoped alias selection | A command-scoped mapping took precedence over an application-wide mapping. |
| Verbosity selection | A later occurrence selected a different mode; for `--verbose --debug`, debug wins because it appears last. |
| Help-route selection | A canonical command or alias-only discovery context was selected. |
| Repeated singleton flag | A later occurrence replaced an earlier occurrence, without printing their values. |
| Input preparation | Canonical input validation completed and the handler is starting. |
| Handler completion | Canonical command and the handler's returned classification and status, before final delivery. |
The handler-completion event describes the handler's result. The final returned
invocation outcome also includes delivery failures and remains authoritative.
Present this event before terminal failure presentation; do not emit a completion
message afterward.
Use the final selected mode to present earlier parser decisions. Collect the
relevant events during parsing and apply visibility after mode selection:
`--verbose --debug` shows the selection explanation, while `--debug --quiet`
suppresses it. This collection of parser events does not require whole-run
output buffering.
Debug messages should explain operator-relevant decisions independently of
incidental implementation steps. Use command paths, flag names, and decision
descriptions rather than raw argument or configuration-value dumps. Upstream
libraries retain ownership of their diagnostic detail and disclosure rules.
## Selected delivery lifecycle and quiet execution (OD-4)
### Ordering and finalization
Use synchronous delivery. Present immediate messages and substantive writes in
submission order. After the handler returns:
1. Deliver eligible deferred messages in queue order, without sorting by severity.
2. Finish ordinary reporting, including any handler-completion debug message and
flushing any CommandKit-owned buffers for ordinary output. Account for failures
discovered during this delivery before preparing the terminal explanation.
3. Present any terminal failure explanation and complete its delivery, including
any CommandKit-owned buffering of that explanation.
4. Close the invocation's reporting facility and return the final outcome.
Deferred messages remain eligible when the operation fails, subject to verbosity.
Deferral controls timing; applications queue success summaries only after the
relevant operation succeeds. Ordering governs CommandKit's writes; separate
stdout and stderr destinations may be buffered or displayed differently by the
caller.
CommandKit finalizes automatically. Consumers do not need a separate flush or
finish call. Handler cleanup, including Go `defer` functions, runs before the
handler returns and participates in normal reporting. Applications complete any
reporting goroutines before returning. After finalization, writes and message
submissions return a closed-reporter error and emit nothing.
Reporter operations within an invocation are safe to call concurrently.
Serialize access to reporting state and delivery so individual messages do not
interleave. Concurrent submissions have no predetermined order; deferred messages
follow the order in which they enter the queue. Multiple writes composing one
application report still require application coordination if they must remain
together. This contract covers operations through one invocation's reporter;
coordination with writes outside that reporter remains caller-owned.
Finalize any buffers owned by CommandKit. Caller-supplied writers remain
caller-owned; CommandKit does not automatically close them or invoke arbitrary
flushing interfaces.
Visibility applies when output is emitted. A verbose or debug run may show
informational detail before subsequently failing. Progress and substantive
output may likewise be partially delivered before a failure. Previously written
bytes cannot be withdrawn. Whole-run buffering solely to make failed runs
contain only error output is outside this implementation.
Keep errors understandable in every mode, including source locations and
related explanatory context. Unrelated warnings and informational messages
remain subject to the selected mode. Apply the selected retention boundary
without accumulating a message history.
### Rendering failures, write failures, and execution flow
Distinguish who invokes a renderer and whether its destination failed:
| Failure source | Responsibility and handling |
| --- | --- |
| An encoder or renderer called directly by the application | The application handles its returned error and chooses the outcome and explanation. |
| A rendering callback invoked by CommandKit | CommandKit handles its returned error as a presentation failure and reflects it in the invocation outcome. |
| A destination rejects a write | CommandKit records the delivery failure and stops further writes to that destination. |
A rendering failure may occur before any write or while the destination remains
healthy. It does not by itself establish a failed destination. CommandKit can
explain its rendering failure through a working diagnostic destination. These
responsibilities also apply when rendering into a discard destination under
quiet mode.
Failure to deliver requested output makes an otherwise successful invocation
unsuccessful. Apply this rule to substantive output, Warning, Info, Progress,
Debug, and terminal explanations. Intentional suppression requests no delivery
and is not a delivery failure.
Standard writer operations return write errors normally. Immediate reporting
methods also expose delivery errors. CommandKit records bounded delivery-failure
information so an ignored write error cannot yield a successful invocation.
Deferred delivery failures appear in the final outcome.
Apply ADR 0005's outcome rules when presentation fails. For standard outcomes,
return operational failure with status `1`, including when the original outcome
was a usage failure. Preserve the original explanation and any supplied cause;
record the presentation failure separately in `Presentation`.
Application-defined domain outcomes retain their domain classification. Preserve
an already non-zero domain status. If the domain status was `0`, change the
suggested status to `1` so presentation failure cannot yield a successful
invocation. The application entry point retains control of process exit.
| Outcome before presentation fails | Final outcome |
| --- | --- |
| Success, status `0` | Operational failure, status `1`. |
| Usage failure, status `2` | Operational failure, status `1`. |
| Operational failure, status `1` | Operational failure, status `1`. |
| Domain outcome, non-zero status | Same domain classification and status. |
| Domain outcome, status `0` | Same domain classification, status `1`. |
These rules apply to both destination-write failures and rendering failures from
callbacks invoked by CommandKit. Reporting failures remain CommandKit's own
error-handling responsibility.
Stop writing to a destination after it fails. A substantive-output failure can
receive a terminal explanation through the diagnostic destination if it still
works. If diagnostic delivery fails, return that information to the caller.
Do not redirect diagnostics into substantive output or recursively attempt to
report the reporting failure.
A failed write does not stop application code. The handler can inspect the error
and return immediately; otherwise, CommandKit records the failure and finalizes
when the handler returns. CommandKit does not use process exit or panic to
interrupt execution. Terminal failure presentation ends normal output.
### Quiet-mode rendering and suppression
Initially, run substantive report generation normally with its managed writer
discarding output in quiet mode. This preserves report generation, validation,
and serialization failures while changing presentation. For example, a report
encoder failure still fails the invocation under `--quiet`; the handler remains
responsible for handling errors returned by an encoder it calls.
Simple suppressed messages need no formatting or destination writes. For richer
report renderers, initially render into a discard destination. Rendering may be
skipped later where doing so preserves the command's operation and failure
semantics.
Treat suppression as intentional suppression, never as failed or incomplete
delivery. Do not retain suppressed content. Delivery needs only the deferred
queue, reporting lifecycle state, and bounded delivery-failure information,
alongside the temporary parser state and returned explanations described above.
## Selected consumer reporting API
Provide one reporting facility per invocation for substantive output, immediate
messages, and end-of-run diagnostics. The consumer API is chosen for its intended
long-term design; with no current consumers, preserving existing API shapes
carries no weight in this decision.
The following declarations specify the target API and are not yet implemented.
The reporter owns presentation: output visibility, progress settings, prefixes,
ordering, and delivery bookkeeping. Its views share invocation state, allowing
already-emitted messages, deferred messages, suppression, and write failures to
be coordinated in one place. Helpers may receive the reporter or a narrow
application-defined interface. Domain libraries can continue returning
diagnostic data for the handler to submit, independently of CommandKit.
Use the reporter as the common diagnostic submission path, replacing the earlier
proposal to split submission between a reporter and `Outcome.Diagnostics`.
Terminal explanations accompany returned outcomes and are delivered through the
same facility. Caller-selected destinations remain at `App.Execute`; handlers
use `Invocation.Reporter()` in place of raw invocation streams.
### Invocation reporter and structured detail
```go
func (inv Invocation) Reporter() *Reporter
type Reporter struct {
// Private invocation state.
}
func (r *Reporter) Output() io.Writer
func (r *Reporter) RenderOutput(write func(io.Writer) error) error
func (r *Reporter) Info(message string, details ...Detail) error
func (r *Reporter) Warn(message string, details ...Detail) error
func (r *Reporter) Debug(message string, details ...Detail) error
func (r *Reporter) Progress(message string, details ...Detail) error
func (r *Reporter) AtEnd() DeferredReporter
type Detail func(io.Writer) error
```
`Output()` supports ordinary writers and encoders. `RenderOutput()` coordinates
an entire rendering operation, captures its rendering error, and prevents other
reporter operations from interleaving with it. Rendering callbacks write through
their supplied writer. Each complete categorized message, including its details,
is likewise coordinated as one reporter operation.
Rendering callbacks use their supplied writer only during the callback. They may
call rendering helpers that write through it. While a callback is running, it
must not recursively submit messages, queue deferred messages, start another
rendering operation, or write through a separate `Output()` handle belonging to
the same invocation's reporter. Make additional reporter submissions before or
after the callback. A callback must also avoid waiting for another goroutine to
submit through that reporter, since the submission may be waiting for the
callback to finish.
This boundary applies to `RenderOutput`, immediate and deferred message details,
and terminal explanation details. Ordinary concurrent reporter calls remain
supported and serialized. Keeping each rendering operation together motivates
the boundary; it does not prescribe a lock implementation or promise runtime
detection of recursive calls.
Message strings are literal; consumers use `fmt.Sprintf` when formatting is
needed. Initially omit parallel `Infof`, `Warnf`, and similar methods. CommandKit
formats the opening message with its category's prefix and a terminating newline.
Optional details follow in order with their formatting preserved. An empty
opening message produces no heading, prefix, or blank opening line, supporting
native renderers that supply a complete, labelled diagnostic:
```go
_ = report.Warn("Using the default profile")
_ = report.Warn("Some configuration settings need attention.", writeNativeWarnings)
_ = report.Warn("", writeNativeWarnings)
```
These are alternative calls; `writeNativeWarnings` represents an
application- or integration-supplied function with signature
`func(io.Writer) error`. No generic logging method or severity enumeration is
needed to submit messages. Applications need not check verbosity before reporting
or implement categories they do not use.
Immediate methods return rendering or delivery errors, and CommandKit records
the corresponding presentation failure even if the caller ignores the error.
A caller can inspect the returned error and end its handler immediately.
### Deferred messages
```go
type DeferredReporter struct {
// Private reference to the same invocation state.
}
func (r DeferredReporter) Info(message string, details ...Detail) error
func (r DeferredReporter) Warn(message string, details ...Detail) error
func (r DeferredReporter) Debug(message string, details ...Detail) error
```
For example:
```go
_ = report.AtEnd().Warn("Three optional files were unavailable.")
```
Copies of the deferred view share invocation state. Its methods return submission
errors, such as attempting to use a closed reporter. Errors encountered during
deferred rendering appear in the final invocation outcome. Progress remains an
immediate streaming operation; substantive output uses the main reporter.
Copy callback lists when accepting a submission. Callers retain ownership of
data captured by rendering callbacks and keep that data valid until presentation.
### Terminal outcomes
Outcome construction requires no invocation state and emits nothing:
```go
type Explanation struct {
Message string
Details []Detail
}
type Outcome struct {
Kind OutcomeKind
Status int
Domain string
Cause error
Explanation Explanation
Presentation PresentationResult
}
func Success() Outcome
func Failure(message string, details ...Detail) Outcome
func Usage(message string, details ...Detail) Outcome
func Domain(name string, status int) Outcome
func (o Outcome) WithExplanation(message string, details ...Detail) Outcome
func (o Outcome) WithCause(cause error) Outcome
```
`Success` returns a successful outcome with status `0`. `Failure` produces an
operational failure with status `1`; `Usage` produces a usage failure with status
`2`. `Domain` constructs an outcome with the supplied application-defined
classification and status. Subsequent presentation failures follow OD-4's rules:
domain classification is preserved, and a zero status becomes `1` if presentation
fails.
`OutcomeKind` retains the success, usage, operational, and domain classifications.
`WithExplanation` returns a modified outcome value and replaces its terminal
explanation. Explanations belong to failed outcomes; successful summaries use
Info or substantive output. Ordinary application failure, native failure
rendering, and a custom domain failure respectively can be expressed as:
```go
return commandkit.Failure("Unable to export the requested report.")
```
```go
return commandkit.Failure("", writeNativeFailure)
```
```go
return commandkit.Domain("validation", 3).
WithExplanation("The document failed validation.", writeFindings)
```
The named renderers above have signature `func(io.Writer) error` and preserve the
owning library's safe diagnostic formatting. They run during terminal delivery,
not when constructing the outcome.
Before terminal delivery, supply the explanation `Command failed.` when a
non-zero outcome has an empty `Explanation.Message` and no non-nil callback in
`Explanation.Details`. This produces `ERROR: Command failed.` in every mode,
including quiet, and becomes the explanation retained in the returned outcome.
Adding the fallback leaves classification, status, and cause unchanged;
presentation failures still follow OD-4's rules. The fallback is based on the
failed outcome and absent explanation, without deriving text from `Cause.Error()`.
An empty heading with a non-nil detail renderer is an explicitly supplied
explanation. Trust that renderer to provide its content. Do not buffer or replay
it to decide whether the explanation is sufficiently informative, and do not
add the fallback merely because it writes no bytes.
| Returned outcome | Terminal presentation |
| --- | --- |
| `Failure("")` | Generic fallback, operational classification and status `1`. |
| `Domain("validation", 3)` | Generic fallback, domain classification and status `3`. |
| `Failure("").WithCause(err)` | Generic fallback; the supplied cause remains available without automatic rendering. |
| `Failure("", writeNativeFailure)` | Native explanation without an added generic heading. |
`Cause` is optional. `WithCause` returns a modified outcome value, replacing the
cause without changing the classification, status, or explanation. For example:
```go
return commandkit.Failure("Unable to open the configuration file.").
WithCause(err)
```
CommandKit presents the supplied explanation and carries the original error
through to the execution caller, which can inspect it with expressions such as
`errors.Is(outcome.Cause, fs.ErrNotExist)`. It does not automatically present
`Cause.Error()` or use the cause to decide success, failure, or message category.
Providing a cause is independent of choosing the terminal explanation; neither
is inferred from the other. Warnings do not require supplying or retaining causes.
Outcome processing preserves supplied causes, including their existing Go error
identity, when a subsequent rendering or delivery failure occurs. Presentation
failures are recorded separately in `Presentation`. The returned outcome retains
its terminal explanation and optional cause, with other retention limited to the
presentation needs described above.
### Provider-returned diagnostic data
Providers return values and diagnostic data without needing a reporter parameter.
Retain `Acquired[T]` with separate fields for independent messages and failure
explanations:
```go
type Acquired[T any] struct {
Value T
Diagnostics []Diagnostic
Explanation Explanation
}
```
`Diagnostics` carries independent, nonterminal messages. `Diagnostic` and
`Severity` remain passive data types for those messages, with each diagnostic
carrying its `Severity` and a `Write func(io.Writer) error` renderer. Adapt their
contracts to the nonterminal reporting categories. Integrations submit the
records through the corresponding reporter methods, including the deferred view
where appropriate. Removing `Outcome.Diagnostics` removes a submission path;
provider-returned records remain available for the integration to submit.
`Explanation` carries context to attach when the integration returns a failed
outcome. Terminal Error content uses this field rather than an independently
submitted diagnostic. An upstream warning that helps explain the failure may
belong in the explanation; unrelated warnings remain in `Diagnostics` and follow
their ordinary visibility rules. Adapters preserve this distinction and useful
native formatting when translating mixed upstream batches, instead of assigning
the whole batch its highest severity.
Providers may return acquired data alongside a Go error. The owning integration
chooses the command outcome, attaches any terminal explanation, and decides which
cause to preserve. Returning diagnostic data does not itself emit messages or
select the invocation outcome. Diagnostic renderers follow the same callback
boundary as other details when invoked through the reporter.
### Presentation observations and closed reporters
```go
type PresentationResult struct {
Output DeliveryResult
Diagnostics DeliveryResult
}
type DeliveryResult struct {
State DeliveryState
RenderError error
WriteError error
}
type DeliveryState uint8
const (
DeliveryNone DeliveryState = iota
DeliverySuppressed
DeliveryComplete
DeliveryIncomplete
)
var ErrReporterClosed = errors.New("reporter is closed")
```
| State | Meaning |
| --- | --- |
| `DeliveryNone` | No content was submitted for this destination. |
| `DeliverySuppressed` | All submitted content was intentionally suppressed, and rendering succeeded. |
| `DeliveryComplete` | All requested delivery completed; other content may have been suppressed. |
| `DeliveryIncomplete` | Rendering or destination writing failed. |
Retain the first rendering failure and first destination-write failure for each
destination. This provides bounded evidence without collecting an event history.
`Outcome.Presentation` is populated by CommandKit during execution and
finalization; it is observation data, not another submission API.
The invocation supplies the reporter. Consumers neither construct nor finalize
it. After closure, message submissions and writer operations return
`ErrReporterClosed`, including in quiet mode. The initial API needs no explicit
flush method, transcript accessor, or verbosity checks. Error-conversion helpers
are deferred pending demonstrated value.
### Application example
This target-API handler uses an application-owned `exportData` operation that
returns a JSON-encodable result with a `UsedDefaults` field:
```go
func runExport(inv commandkit.Invocation) commandkit.Outcome {
report := inv.Reporter()
result, err := exportData(inv.Context)
if err != nil {
return commandkit.Failure("Unable to export the requested data.")
}
if result.UsedDefaults {
_ = report.Warn("Some optional settings used defaults.")
}
if err := report.RenderOutput(func(w io.Writer) error {
return json.NewEncoder(w).Encode(result)
}); err != nil {
return commandkit.Failure("Unable to present the export result.")
}
_ = report.AtEnd().Info("Export completed.")
return commandkit.Success()
}
```
Ignored immediate reporting errors are still recorded and can make the final
invocation unsuccessful. The renderer runs in quiet mode against the discard
destination, preserving serialization failures. The deferred summary follows
the selected verbosity policy and describes the completed export operation;
the final invocation outcome also accounts for presentation failures.
## Upstream diagnostic ownership
ConfigKit and PromptKit remain authoritative for their diagnostic semantics,
severity, source context, and disclosure rules. Integration adapters expose
those capabilities through the shared CommandKit presentation policy.
Support consistent reporting of ConfigKit errors and warnings as its adopted
public API evolves. This roadmap does not assume that a future warning API
already exists or prescribe its upstream shape. Integration-owned commands
select the outcome and submit useful native diagnostic detail for presentation.
Core presents those records under the shared policy. Applications using upstream
libraries directly choose how to respond to domain-specific errors and which
outcomes and messages to supply, as for their other operations.
An integration may receive both a useful substantive report and an operation
failure, as PromptKit smoke testing supports. Preserve the report under the
ordinary substantive-output policy, then present the terminal explanation.
Quiet mode suppresses the report while retaining the explanation. Having a
report does not imply a successful outcome, and returning a failure does not
discard substantive output already prepared for delivery before the terminal step.
Debug output follows the same upstream disclosure and safe-rendering contracts.
Enabling debug does not authorize secret disclosure or bypass owning-library
rules.
## Delivery scope
The completed feature includes the selected reporter and outcome API, ordered
mode/progress selection through normal parsing, shared core presentation, and
both optional integration migrations. Passive provider diagnostics distinguish
independent messages from terminal explanations while preserving native evidence
and rendering. Existing command behavior remains unchanged outside the selected
reporting policy.
Maintained consumers demonstrate ordinary output, immediate/deferred messages,
silent recovery, explicit warnings, failure after partial output, optional cause
transport, and structured terminal explanations. Integration consumers include
native diagnostic context and useful reports returned alongside failures.
Behavioral coverage establishes CommandKit-owned parsing, visibility, ordering,
concurrent reporter coordination, closure, presentation observations, and failure
handling. It preserves existing alias, input/state, discovery, native-report,
and upstream-adapter protections without testing upstream domain correctness.
GoDoc, CLI documentation, consumer guidance, internal documentation, and release
notes describe the implemented boundary under their canonical ownership.
The [implementation plan](implementation.md) defines the concrete migration
steps, focused validation, and completion criteria. This roadmap defines the
target behavior and selected policy choices.
## Architectural preparation completed
- [x] Review and accept [ADR 0014](../adr/0014-invocation-reporting.md) as an
extension for shared reporting, retaining ADR 0005's outcome and supplied-error
preservation decisions.
- [x] Align the [architecture policy](../policy/architecture.md) with the reporting
ownership model, including application ownership of domain-specific error
decisions and scope for useful shared error helpers.
OD-1 through OD-4 and the consumer API signatures are selected. The target end
state includes maintained, executable consumers using those APIs; the
[implementation plan](implementation.md) owns their delivery sequence.

View File

@@ -1,18 +0,0 @@
# Composed consumer
This example composes application configuration, PromptKit configuration,
definition inspection, and one application-owned command:
```sh
go run . config scopes
go run . config validate --scope all --campaign spring
go run . promptkit show prompt briefing --campaign spring --format yaml
go run . promptkit show profile spring --campaign spring
go run . session restore session-42 --campaign spring
```
The sources and session store are small in-memory fixtures. `--campaign` is a
typed selector shared by the app and PromptKit configuration scopes. Their
scope contexts share a per-invocation cache created by `ConfigSuite.NewContext`.
Prompt/profile inspection has its own invocation context and composes the same
application and engine loader functions.

View File

@@ -1,223 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"os"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/promptkit"
)
type campaign struct {
PromptID string `yaml:"prompt_id"`
PromptVersion string `yaml:"prompt_version"`
ProfileID string `yaml:"profile_id"`
Model string `yaml:"model"`
}
type appConfiguration struct {
DefaultCampaign string `yaml:"default_campaign"`
Campaigns map[string]campaign `yaml:"campaigns"`
}
type invocationCache struct {
application *configkit.Result[appConfiguration]
}
type configContext struct {
Cache *invocationCache
Campaign commandkit.Supplied[string]
}
type definitionContext struct {
Cache *invocationCache
Campaign commandkit.Supplied[string]
}
type restoreInput struct {
Campaign commandkit.Supplied[string]
Session string
}
type session struct {
ID string
Campaign string
Summary string
}
type sessionStore struct {
active string
records map[string]session
}
func loadApplicationConfig(_ context.Context, _ commandkit.Selection, cache *invocationCache) (*configkit.Result[appConfiguration], error) {
if cache != nil && cache.application != nil {
return cache.application, nil
}
result, err := configkit.Load[appConfiguration](configkit.Mapping("composed app config", map[string]any{
"default_campaign": "spring",
"campaigns": map[string]any{
"spring": map[string]any{"prompt_id": "briefing", "prompt_version": "1", "profile_id": "spring", "model": "spring-model"},
"autumn": map[string]any{"prompt_id": "briefing", "prompt_version": "1", "profile_id": "autumn", "model": "autumn-model"},
},
}))
if err == nil && cache != nil {
cache.application = result
}
return result, err
}
func selectedCampaign(configuration appConfiguration, supplied commandkit.Supplied[string]) (string, campaign, error) {
name := configuration.DefaultCampaign
if supplied.Set {
name = supplied.Value
}
value, ok := configuration.Campaigns[name]
if !ok {
return "", campaign{}, fmt.Errorf("unknown campaign %q", name)
}
return name, value, nil
}
func loadPromptEngine(ctx context.Context, selection commandkit.Selection, cache *invocationCache, selected commandkit.Supplied[string]) (*promptkit.Engine, error) {
application, err := loadApplicationConfig(ctx, selection, cache)
if err != nil {
return nil, err
}
name, chosen, err := selectedCampaign(application.Config, selected)
if err != nil {
return nil, err
}
engineConfig := configkit.Mapping("campaign "+name, map[string]any{
"profiles": map[string]any{chosen.ProfileID: map[string]any{
"endpoint": "https://example.invalid/v1",
"model": chosen.Model,
"extra_params": map[string]any{
"campaign": name,
},
}},
"prompts": map[string]any{chosen.PromptID: map[string]any{
chosen.PromptVersion: map[string]any{
"default_profile": chosen.ProfileID,
"inputs": []any{map[string]any{"name": "topic", "required": true, "content_type": "text/plain", "description": "Topic to brief"}},
"messages": []any{map[string]any{"role": "user", "content": "Write a concise briefing about {{.topic}}."}},
"output": map[string]any{"format": "text", "validation_mode": "none"},
},
}},
})
return promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{Source: engineConfig}}})
}
func campaignSelector[C any](set func(*C, commandkit.Supplied[string])) commandkit.Selector[C] {
return commandkit.Selector[C]{
Binding: commandkit.ValueFlag[C](commandkit.FlagMeta{Name: "campaign", Usage: "Select a configured campaign."}, nil, func(value string) (string, error) { return value, nil }, set),
Scopes: []string{"app", "promptkit"},
}
}
func newApp() (*commandkit.App, error) {
app, err := commandkit.New(commandkit.Identity{Name: "composed-example", Version: "1.0"})
if err != nil {
return nil, err
}
appScope, err := commandkit.NewConfigKitScope[configContext, appConfiguration]("app", "Application campaigns.", func(ctx context.Context, selection commandkit.Selection, input configContext) (*configkit.Result[appConfiguration], error) {
return loadApplicationConfig(ctx, selection, input.Cache)
})
if err != nil {
return nil, err
}
promptScope, err := commandkit.NewPromptKitScope(func(ctx context.Context, selection commandkit.Selection, input configContext) (*promptkit.Engine, error) {
return loadPromptEngine(ctx, selection, input.Cache, input.Campaign)
})
if err != nil {
return nil, err
}
configSuite := commandkit.ConfigSuite[configContext]{
NewContext: func() configContext { return configContext{Cache: &invocationCache{}} },
Selectors: []commandkit.Selector[configContext]{campaignSelector(func(input *configContext, value commandkit.Supplied[string]) {
input.Campaign = value
})},
Scopes: []commandkit.ConfigScope[configContext]{appScope, promptScope},
}
if err := commandkit.RegisterConfig(app, configSuite); err != nil {
return nil, err
}
definitionSuite, err := commandkit.NewPromptKitSuite(func(ctx context.Context, selection commandkit.Selection, input definitionContext) (*promptkit.Engine, error) {
return loadPromptEngine(ctx, selection, input.Cache, input.Campaign)
})
if err != nil {
return nil, err
}
definitionSuite.NewContext = func() definitionContext { return definitionContext{Cache: &invocationCache{}} }
definitionSuite.Selectors = []commandkit.Selector[definitionContext]{campaignSelector(func(input *definitionContext, value commandkit.Supplied[string]) {
input.Campaign = value
})}
if err := commandkit.RegisterPromptKit(app, definitionSuite); err != nil {
return nil, err
}
store := &sessionStore{records: map[string]session{"session-42": {ID: "session-42", Campaign: "spring", Summary: "A synthetic planning session."}}}
restore, err := commandkit.NewCommand(commandkit.CommandSpec[restoreInput]{
Name: "restore",
Summary: "Restore one saved session.",
Flags: []commandkit.Flag[restoreInput]{commandkit.ValueFlag[restoreInput](commandkit.FlagMeta{Name: "campaign", Usage: "Campaign owning the session."}, nil, func(value string) (string, error) { return value, nil }, func(input *restoreInput, value commandkit.Supplied[string]) {
input.Campaign = value
})},
Arguments: []commandkit.Argument[restoreInput]{commandkit.ValueArgument[restoreInput](commandkit.ArgumentMeta{Name: "ID"}, true, func(value string) (string, error) { return value, nil }, func(input *restoreInput, value commandkit.Supplied[string]) {
input.Session = value.Value
})},
Run: func(invocation commandkit.Invocation, input restoreInput) commandkit.Outcome {
return restoreSession(invocation, store, input)
},
})
if err != nil {
return nil, err
}
sessionGroup, err := commandkit.Group("session", "Manage saved sessions.", restore)
if err != nil {
return nil, err
}
if err := app.Register(sessionGroup); err != nil {
return nil, err
}
return app, nil
}
func restoreSession(invocation commandkit.Invocation, store *sessionStore, input restoreInput) commandkit.Outcome {
application, err := loadApplicationConfig(invocation.Context, commandkit.Selection{}, nil)
if err != nil {
return commandkit.Failure(err)
}
name, _, err := selectedCampaign(application.Config, input.Campaign)
if err != nil {
return commandkit.Failure(err)
}
record, ok := store.records[input.Session]
if !ok {
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 4, Domain: "session_not_found", Cause: errors.New("saved session was not found")}
}
if record.Campaign != name {
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 3, Domain: "campaign_mismatch", Cause: fmt.Errorf("session belongs to campaign %q", record.Campaign)}
}
store.active = record.ID
if _, err := fmt.Fprintf(invocation.Streams.Out, "restored %s: %s\n", record.ID, record.Summary); err != nil {
return commandkit.Failure(err)
}
return commandkit.Success()
}
func main() {
app, err := newApp()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
outcome := app.Execute(context.Background(), os.Args[1:], commandkit.Streams{Out: os.Stdout, Err: os.Stderr})
if outcome.Status != 0 {
os.Exit(outcome.Status)
}
}

31
examples/core/README.md Normal file
View File

@@ -0,0 +1,31 @@
# Core example
Run the example from the repository root:
```sh
go run ./examples/core hello --times=0 --tag=first
go run ./examples/core ws greet --count=2
go run ./examples/core ws --help
go run ./examples/core hello --hlep
```
The example installs a canonical `workspace greet` command together with a
command shortcut, a group shortcut, a scoped flag alias, and a global help
alias. Its declaration slice mixes the canonical group and routing rules as
`[]commandkit.Declaration`. The handler prints `Invocation.EnteredPath()` and
`Invocation.CanonicalPath()` so the alternate route and its canonical target
are visible in the output.
Integration factories also return `commandkit.Command` and can be passed
directly to `New` beside `commandkit.Alias` or flag-alias declarations. For a
command-only `[]commandkit.Command`, convert its elements into a
`[]commandkit.Declaration` before appending routing rules; Go cannot expand the
command slice as `...commandkit.Declaration`. The
[consumer guide](../../docs/consumers/guide.md#application-owned-aliases)
shows both forms, and the [composed consumer](../../integrations/ckprompt/examples/composed)
is the maintained integration example.
These aliases use the current local CommandKit source. Published CommandKit
v0.2.0 does not include alias declarations; consumers can use them after the
next core release. Repository contributors can run its test with
`go test ./examples/core` from the root.

152
examples/core/main.go Normal file
View File

@@ -0,0 +1,152 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"strconv"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
)
type greetInput struct {
Count commandkit.Supplied[int]
Tags commandkit.Supplied[[]string]
}
type reportInput struct {
Scenario string
}
var (
errRecoveredFixture = errors.New("a local read failed and was recovered")
errWarningFixture = errors.New("a local warning has a cause")
errPartialFixture = errors.New("the local report could not be completed")
)
func newApp() (*commandkit.App, error) {
countDefault := 1
tagDefaults := []string{"demo"}
greet := commandkit.NewCommand(commandkit.CommandSpec[greetInput]{
Name: "greet",
Summary: "Show presence-aware values and repeated labels.",
Flags: []commandkit.Flag[greetInput]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Usage: "Greeting count."}, &countDefault, strconv.Atoi, func(input *greetInput, value commandkit.Supplied[int]) {
input.Count = value
}),
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "tag", Usage: "A repeatable label."}, tagDefaults, func(value string) (string, error) { return value, nil }, func(input *greetInput, value commandkit.Supplied[[]string]) {
input.Tags = value
}),
},
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
reporter := invocation.Reporter()
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
if input.Count.Set {
count = fmt.Sprintf("explicit %d", input.Count.Value)
}
tags := fmt.Sprintf("default %v (not supplied)", input.Tags.Value)
if input.Tags.Set {
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
}
_ = reporter.RenderOutput(func(writer io.Writer) error {
_, err := fmt.Fprintf(writer, "Hello; count=%s; tags=%s; entered=%q; canonical=%q\n", count, tags, invocation.EnteredPath(), invocation.CanonicalPath())
return err
})
return commandkit.Success()
},
})
report := commandkit.NewCommand(commandkit.CommandSpec[reportInput]{
Name: "report",
Summary: "Demonstrate invocation reporting with local scenarios.",
NewInput: func() reportInput {
return reportInput{Scenario: "demo"}
},
Arguments: []commandkit.Argument[reportInput]{
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "scenario", Usage: "demo, recover, warn, warn-error, fail, or partial."}, false, func(value string) (string, error) { return value, nil },
func(input *reportInput, value commandkit.Supplied[string]) { input.Scenario = value.Value }),
},
CheckInput: func(input reportInput) error {
switch input.Scenario {
case "demo", "recover", "warn", "warn-error", "fail", "partial":
return nil
default:
return fmt.Errorf("unknown reporting scenario %q", input.Scenario)
}
},
Run: func(invocation commandkit.Invocation, input reportInput) commandkit.Outcome {
return runReportingExample(invocation, input.Scenario)
},
})
workspace := commandkit.Group("workspace", "Workspace commands.", greet)
declarations := []commandkit.Declaration{
workspace,
report,
commandkit.Alias("hello", "workspace greet"),
commandkit.Alias("ws", "workspace"),
commandkit.FlagAliasFor("workspace greet", "--times", "--count"),
commandkit.FlagAlias("--hlep", "--help"),
}
return commandkit.New(commandkit.Identity{Name: "core-example", Version: "1.0"}, declarations...)
}
func runReportingExample(invocation commandkit.Invocation, scenario string) commandkit.Outcome {
reporter := invocation.Reporter()
deferred := reporter.AtEnd()
switch scenario {
case "demo":
_ = reporter.Progress("Loaded one local fixture.")
_ = deferred.Warn("Optional fixture warning", func(writer io.Writer) error {
_, err := io.WriteString(writer, "The sample contains an optional note.\n")
return err
})
_ = deferred.Info("Using deterministic local data.")
_ = deferred.Debug("Selected the built-in reporting example.")
_ = deferred.Info("Summary", writeExampleSummary)
_ = reporter.RenderOutput(writeExampleReport)
return commandkit.Success()
case "recover":
return commandkit.Success().WithCause(errRecoveredFixture)
case "warn":
_ = deferred.Warn("The local operation completed with a warning.")
return commandkit.Success()
case "warn-error":
_ = deferred.Warn("The local operation completed with a warning.")
return commandkit.Success().WithCause(errWarningFixture)
case "fail":
return commandkit.Failure("The selected local operation could not be completed.")
case "partial":
_ = reporter.RenderOutput(writePartialExampleReport)
return commandkit.Failure("The local report stopped after partial output.").WithCause(errPartialFixture)
default:
return commandkit.Usage(fmt.Sprintf("unknown reporting scenario %q", scenario))
}
}
func writeExampleReport(writer io.Writer) error {
_, err := fmt.Fprintln(writer, "Prepared a local report from one fixture.")
return err
}
func writePartialExampleReport(writer io.Writer) error {
_, err := io.WriteString(writer, "Partial local report: first section complete.\n")
return err
}
func writeExampleSummary(writer io.Writer) error {
_, err := io.WriteString(writer, "Processed one local fixture.\n")
return err
}
func main() {
app, err := newApp()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
outcome := app.Execute(context.Background(), os.Args[1:], commandkit.Streams{Out: os.Stdout, Err: os.Stderr})
if outcome.Status != 0 {
os.Exit(outcome.Status)
}
}

199
examples/core/main_test.go Normal file
View File

@@ -0,0 +1,199 @@
package main
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
)
func TestAliasesReachCoreExampleCommand(t *testing.T) {
app, err := newApp()
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
args []string
entered string
canonical string
greeting string
}{
{name: "command shortcut and scoped flag", args: []string{"hello", "--times=0", "--tag", "one"}, entered: "hello", canonical: "workspace greet", greeting: "count=explicit 0; tags=explicit [one]"},
{name: "group shortcut", args: []string{"ws", "greet", "--count=2"}, entered: "ws greet", canonical: "workspace greet", greeting: "count=explicit 2"},
{name: "canonical command", args: []string{"workspace", "greet"}, entered: "workspace greet", canonical: "workspace greet", greeting: "count=default 1 (not supplied)"},
} {
t.Run(test.name, func(t *testing.T) {
var output bytes.Buffer
outcome := app.Execute(context.Background(), test.args, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("Execute(%q) = %#v; output %q", test.args, outcome, output.String())
}
for _, fragment := range []string{
test.greeting,
"entered=\"" + test.entered + "\"",
"canonical=\"" + test.canonical + "\"",
} {
if !strings.Contains(output.String(), fragment) {
t.Errorf("output %q does not contain %q", output.String(), fragment)
}
}
})
}
}
func TestGlobalHelpAliasDoesNotRunCoreExampleCommand(t *testing.T) {
app, err := newApp()
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"hello", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "--hlep → --help") || strings.Contains(output.String(), "Hello;") {
t.Fatalf("help alias outcome/output = (%#v, %q)", outcome, output.String())
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"ws", "--help"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "greet") || strings.Contains(output.String(), "Hello;") {
t.Fatalf("group shortcut help outcome/output = (%#v, %q)", outcome, output.String())
}
}
func TestReportingExampleModes(t *testing.T) {
for _, test := range []struct {
name string
args []string
wantOutput []string
wantDiag []string
omitDiag []string
}{
{
name: "ordinary result",
args: []string{"report", "demo"},
wantOutput: []string{"Prepared a local report from one fixture."},
omitDiag: []string{"Optional fixture warning", "Using deterministic local data.", "Loaded one local fixture.", "DEBUG:"},
},
{
name: "progress selected",
args: []string{"report", "demo", "--progress"},
wantOutput: []string{"Prepared a local report from one fixture."},
wantDiag: []string{"Loaded one local fixture."},
omitDiag: []string{"WARN:", "Using deterministic local data.", "DEBUG:"},
},
{
name: "verbose messages",
args: []string{"report", "demo", "--verbose"},
wantOutput: []string{"Prepared a local report from one fixture."},
wantDiag: []string{"WARN: Optional fixture warning", "The sample contains an optional note.", "Using deterministic local data.", "Summary", "Processed one local fixture."},
omitDiag: []string{"Loaded one local fixture.", "DEBUG:"},
},
{
name: "debug messages",
args: []string{"report", "demo", "--debug"},
wantOutput: []string{"Prepared a local report from one fixture."},
wantDiag: []string{"WARN: Optional fixture warning", "Using deterministic local data.", "Loaded one local fixture.", "DEBUG: Selected the built-in reporting example.", "Processed one local fixture."},
},
{
name: "quiet suppresses result and messages",
args: []string{"report", "demo", "--quiet"},
},
} {
t.Run(test.name, func(t *testing.T) {
outcome, output, diagnostics := executeExample(t, test.args...)
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("Execute(%q) = %#v", test.args, outcome)
}
for _, fragment := range test.wantOutput {
if !strings.Contains(output, fragment) {
t.Errorf("output %q does not contain %q", output, fragment)
}
}
for _, fragment := range test.wantDiag {
if !strings.Contains(diagnostics, fragment) {
t.Errorf("diagnostics %q does not contain %q", diagnostics, fragment)
}
}
for _, fragment := range test.omitDiag {
if strings.Contains(diagnostics, fragment) {
t.Errorf("diagnostics %q unexpectedly contain %q", diagnostics, fragment)
}
}
if test.name == "quiet suppresses result and messages" && (output != "" || diagnostics != "") {
t.Errorf("quiet output/diagnostics = (%q, %q), want both empty", output, diagnostics)
}
})
}
}
func TestReportingExampleScenarios(t *testing.T) {
t.Run("silent recovery retains cause", func(t *testing.T) {
outcome, output, diagnostics := executeExample(t, "report", "recover")
if outcome.Kind != commandkit.OutcomeSuccess || !errors.Is(outcome.Cause, errRecoveredFixture) {
t.Fatalf("outcome = %#v, want successful recovery with original cause", outcome)
}
if output != "" || diagnostics != "" {
t.Fatalf("recovery output/diagnostics = (%q, %q), want silent recovery", output, diagnostics)
}
})
t.Run("warning without cause", func(t *testing.T) {
outcome, _, diagnostics := executeExample(t, "report", "warn", "--verbose")
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Cause != nil {
t.Fatalf("outcome = %#v, want success without cause", outcome)
}
if !strings.Contains(diagnostics, "WARN: The local operation completed with a warning.") {
t.Fatalf("diagnostics = %q, want explicit warning", diagnostics)
}
})
t.Run("warning with independent cause", func(t *testing.T) {
outcome, _, diagnostics := executeExample(t, "report", "warn-error", "--verbose")
if outcome.Kind != commandkit.OutcomeSuccess || !errors.Is(outcome.Cause, errWarningFixture) {
t.Fatalf("outcome = %#v, want successful warning with original cause", outcome)
}
if !strings.Contains(diagnostics, "WARN: The local operation completed with a warning.") || strings.Contains(diagnostics, errWarningFixture.Error()) {
t.Fatalf("diagnostics = %q, want warning text without cause text", diagnostics)
}
})
t.Run("terminal explanation without cause", func(t *testing.T) {
outcome, _, diagnostics := executeExample(t, "report", "fail", "--quiet")
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status == 0 || outcome.Cause != nil {
t.Fatalf("outcome = %#v, want terminal failure without cause", outcome)
}
if outcome.Explanation.Message != "The selected local operation could not be completed." || !strings.Contains(diagnostics, outcome.Explanation.Message) {
t.Fatalf("explanation/diagnostics = (%q, %q)", outcome.Explanation.Message, diagnostics)
}
})
t.Run("partial output keeps cause independent", func(t *testing.T) {
outcome, output, diagnostics := executeExample(t, "report", "partial")
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, errPartialFixture) {
t.Fatalf("outcome = %#v, want terminal failure with original cause", outcome)
}
if !strings.Contains(output, "Partial local report: first section complete.") {
t.Fatalf("output = %q, want completed first section", output)
}
if outcome.Explanation.Message != "The local report stopped after partial output." || !strings.Contains(diagnostics, outcome.Explanation.Message) {
t.Fatalf("explanation/diagnostics = (%q, %q)", outcome.Explanation.Message, diagnostics)
}
if strings.Contains(diagnostics, errPartialFixture.Error()) {
t.Errorf("diagnostics %q unexpectedly contain cause text", diagnostics)
}
})
}
func executeExample(t *testing.T, args ...string) (commandkit.Outcome, string, string) {
t.Helper()
app, err := newApp()
if err != nil {
t.Fatal(err)
}
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostics})
return outcome, output.String(), diagnostics.String()
}

View File

@@ -1,14 +0,0 @@
# Simple consumer
Run the maintained example with:
```sh
go run . config show
go run . greet
go run . greet --count=0 --tag=first --tag second
```
The `config` family comes from one typed ConfigKit scope. The custom `greet`
command uses CommandKit's typed bindings and shows the difference between an
omitted `--count` and an explicitly supplied zero, plus ordered repeated tags.
The configuration is synthetic and stays in memory.

View File

@@ -1,90 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"strconv"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type appConfiguration struct {
Greeting string `yaml:"greeting"`
}
type greetInput struct {
Count commandkit.Supplied[int]
Tags commandkit.Supplied[[]string]
}
func loadAppConfiguration(context.Context, commandkit.Selection, struct{}) (*configkit.Result[appConfiguration], error) {
return configkit.Load[appConfiguration](configkit.Bytes("simple example", []byte("greeting: hello\n")))
}
func newApp() (*commandkit.App, error) {
app, err := commandkit.New(commandkit.Identity{Name: "simple-example", Version: "1.0"})
if err != nil {
return nil, err
}
scope, err := commandkit.NewConfigKitScope[struct{}, appConfiguration]("app", "Application settings.", loadAppConfiguration)
if err != nil {
return nil, err
}
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
return nil, err
}
countDefault := 1
tagDefaults := []string{"example"}
greet, err := commandkit.NewCommand(commandkit.CommandSpec[greetInput]{
Name: "greet",
Summary: "Show flag presence and repeated values.",
Flags: []commandkit.Flag[greetInput]{
commandkit.ValueFlag[greetInput](commandkit.FlagMeta{Name: "count", Usage: "Greeting count."}, &countDefault, strconv.Atoi, func(input *greetInput, value commandkit.Supplied[int]) {
input.Count = value
}),
commandkit.RepeatFlag[greetInput](commandkit.FlagMeta{Name: "tag", Usage: "A repeatable label."}, tagDefaults, func(value string) (string, error) { return value, nil }, func(input *greetInput, value commandkit.Supplied[[]string]) {
input.Tags = value
}),
},
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
configuration, err := loadAppConfiguration(invocation.Context, commandkit.Selection{}, struct{}{})
if err != nil {
return commandkit.Failure(err)
}
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
if input.Count.Set {
count = fmt.Sprintf("explicit %d", input.Count.Value)
}
tags := fmt.Sprintf("default %v (not supplied)", input.Tags.Value)
if input.Tags.Set {
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
}
if _, err := fmt.Fprintf(invocation.Streams.Out, "%s; count=%s; tags=%s\n", configuration.Config.Greeting, count, tags); err != nil {
return commandkit.Failure(err)
}
return commandkit.Success()
},
})
if err != nil {
return nil, err
}
if err := app.Register(greet); err != nil {
return nil, err
}
return app, nil
}
func main() {
app, err := newApp()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
outcome := app.Execute(context.Background(), os.Args[1:], commandkit.Streams{Out: os.Stdout, Err: os.Stderr})
if outcome.Status != 0 {
os.Exit(outcome.Status)
}
}

View File

@@ -5,108 +5,212 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"strconv"
"strings"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
configkit "gitea.maximumdirect.net/eric/ConfigKit" commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
) )
// Execute parses and dispatches one invocation. args excludes the executable // Execute parses and dispatches one invocation. args excludes the executable
// name. CommandKit writes diagnostics to streams.Err and leaves process exit to // name. Supplied values are converted before command input construction; help
// the caller. A nil context returns an operational failure, and nil streams are // does not construct caller inputs or run acquisition callbacks. It applies the
// replaced with io.Discard. // parsed reporting selection, finalizes invocation presentation, and returns
// the resulting Outcome without exiting the process. Supplied streams remain
// caller-owned; nil destinations are replaced with io.Discard. A nil context
// returns an operational failure.
func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome { func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome {
if ctx == nil {
return a.present(Failure(errors.New("invocation context must not be nil")), nil, streams)
}
streams = normalizeStreams(streams)
current := newExecution(a, ctx, streams) current := newExecution(a, ctx, streams)
parsed := current.parse(args) if a == nil {
err := errors.New("cannot execute commands on a nil app")
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
}
if ctx == nil {
err := errors.New("invocation context must not be nil")
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
}
routedSnapshot := current.snapshot
current.route = resolveEnteredRoute(args, routedSnapshot)
current.recordRouteEvents()
parsed := current.parse(current.route.args)
current.reporter.applySelection(parsed.Reporting)
current.recordSelectionEvents(parsed)
current.submitDebugEvents()
var outcome Outcome
path := parsed.Path
handlerRan := false
if parsed.Failure != nil { if parsed.Failure != nil {
return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path) outcome = Usage(parsed.Failure.Message)
} } else if current.route.kind == routeDiscovery {
command, ok := a.commandAt(parsed.Path) if parsed.HelpRequested {
if !ok || command.payload == nil { outcome = current.renderHelp(parsed.Path)
return current.present(current.renderHelp(parsed.Path), parsed.Path) path = clonePath(current.route.enteredPath)
} } else {
if parsed.HelpRequested || !command.payload.hasRun() { entered := current.route.enteredPath.String()
if _, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil { outcome = Usage(fmt.Sprintf("incomplete command path %q; use %q --help", entered, entered))
return current.present(Usage(err), parsed.Path) path = clonePath(current.route.enteredPath)
} }
return current.present(current.renderHelp(parsed.Path), parsed.Path) } else {
} payload, ok := routedSnapshot.runners[parsed.Key]
buildInput, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, true) if !ok || payload == nil {
if err != nil { outcome = current.renderHelp(parsed.Path)
return current.present(Usage(err), parsed.Path) } else if parsed.HelpRequested || !payload.hasRun() {
} if _, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, false); err != nil {
input := buildInput() outcome = classifyInputError(err)
if err := command.payload.checkInput(input); err != nil { } else {
return current.present(Usage(err), parsed.Path) outcome = current.renderHelp(parsed.Path)
} }
if len(parsed.Path) == 1 && parsed.Path[0] == "help" { } else {
help, ok := input.(*helpInput) run, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, true)
if !ok { if err != nil {
return current.present(Failure(errors.New("built-in help input has an unexpected type")), parsed.Path) outcome = classifyInputError(err)
} else {
handlerRan = true
outcome = run(current)
}
} }
return current.present(current.renderHelpTargets(help.Targets.Value), parsed.Path)
} }
outcome := command.payload.run(Invocation{Context: ctx, Streams: streams}, input) return current.finalize(outcome, path, handlerRan)
return current.present(outcome, parsed.Path)
} }
type execution struct { type execution struct {
app *App app *App
ctx context.Context ctx context.Context
streams Streams streams Streams
options internalcobra.ParseOptions reporter *Reporter
metadata *internalcobra.Command debugEvents []string
session *internalcobra.Session options commandmeta.ParseOptions
snapshot *routingSnapshot
session *internalcobra.Session
route routeResult
}
type commandExecution struct {
invocation Invocation
renderHelpTargetsFunc func([]string) Outcome
}
func (current *execution) commandContext() commandExecution {
return commandExecution{
invocation: current.invocation(),
renderHelpTargetsFunc: current.renderHelpTargets,
}
}
func (current *execution) invocation() Invocation {
return Invocation{
Context: current.ctx,
reporter: current.reporter,
enteredPath: current.route.enteredPath.String(),
canonicalPath: current.route.canonicalPath.String(),
}
}
func (current commandExecution) renderHelpTargets(targets []string) Outcome {
return current.renderHelpTargetsFunc(targets)
} }
func newExecution(app *App, ctx context.Context, streams Streams) *execution { func newExecution(app *App, ctx context.Context, streams Streams) *execution {
streams = normalizeStreams(streams)
current := &execution{ current := &execution{
app: app, app: app,
ctx: ctx, ctx: ctx,
streams: streams, streams: streams,
options: internalcobra.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err}, reporter: newReporter(streams),
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
} }
if app != nil { if app != nil {
current.metadata = app.bridgeTree() current.snapshot = app.routeSnapshot()
current.session = internalcobra.Prepare(current.metadata, current.options)
} }
return current return current
} }
func (current *execution) parse(args []string) internalcobra.Result { func (current *execution) parse(args []string) commandmeta.Result {
if current.app == nil { if current.app == nil {
return internalcobra.Result{Failure: &internalcobra.Failure{Message: "cannot parse commands on a nil app"}} return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}}
}
if current.session == nil {
current.prepareSession()
}
return reduceReportingOccurrences(current.session.Parse(args))
}
func (current *execution) prepareSession() {
if current.app == nil {
return
}
if current.snapshot == nil {
current.snapshot = current.app.routeSnapshot()
}
options := current.options
if current.snapshot != nil {
switch current.route.kind {
case routeCanonical:
options.FlagAliases = current.snapshot.flagAliases[current.route.canonicalPath.Key()].neutralRecords()
case routeDiscovery:
options.FlagAliases = current.snapshot.discoveryFlags[current.route.discoveryPath.Key()].neutralRecords()
default:
snapshot := current.snapshot
options.FlagAliasesForPath = func(path []string) []commandmeta.FlagAlias {
return snapshot.flagAliases[commandpath.FromTokens(path).Key()].neutralRecords()
}
}
current.session = internalcobra.Prepare(current.snapshot.metadata, options)
} }
return current.session.Parse(args)
} }
func (current *execution) presentationSession() *internalcobra.Session { func (current *execution) presentationSession() *internalcobra.Session {
if current.app == nil { if current.app == nil {
return nil return nil
} }
metadata := current.app.bridgeTree() snapshot := current.app.routeSnapshot()
if current.session == nil || current.session.Metadata() != metadata { if current.session == nil || current.snapshot != snapshot {
current.metadata = metadata current.snapshot = snapshot
current.session = internalcobra.Prepare(metadata, current.options) current.session = nil
current.prepareSession()
} }
return current.session return current.session
} }
func (current *execution) renderHelp(path []string) Outcome { func (current *execution) renderHelp(path []string) Outcome {
if current.app == nil { if current.app == nil {
return Failure(errors.New("cannot render help on a nil app")) err := errors.New("cannot render help on a nil app")
return Failure(err.Error()).WithCause(err)
} }
return current.app.renderHelpOutcomeWithSession(path, current.streams, current.presentationSession()) session := current.presentationSession()
view := current.app.helpView(current.route, current.snapshot)
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
} }
func (current *execution) renderHelpTargets(targets []string) Outcome { func (current *execution) renderHelpTargets(targets []string) Outcome {
if current.app == nil { if current.app == nil {
return Failure(errors.New("cannot render help on a nil app")) err := errors.New("cannot render help on a nil app")
return Failure(err.Error()).WithCause(err)
} }
return current.app.renderHelpTargetsWithSession(targets, current.streams, current.presentationSession()) snapshot := current.app.routeSnapshot()
if snapshot == nil {
err := errors.New("cannot render help without command metadata")
return Failure(err.Error()).WithCause(err)
}
route := resolveEnteredRoute(targets, snapshot)
// Explicit help targets are paths, not command invocations with operands.
// Discovery routes have no canonical path and retain only trailing options.
if route.kind == routeUnknown || len(route.args) != len(route.canonicalPath) {
session := internalcobra.Prepare(snapshot.metadata, current.options)
view := commandmeta.HelpView{EnteredPath: targets, CanonicalPath: targets}
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
}
options := current.options
switch route.kind {
case routeCanonical:
options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
case routeDiscovery:
options.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords()
}
session := internalcobra.Prepare(snapshot.metadata, options)
view := current.app.helpView(route, snapshot)
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
} }
func (current *execution) usageLine(path []string) string { func (current *execution) usageLine(path []string) string {
@@ -122,104 +226,138 @@ func (current *execution) usageLine(path []string) string {
return current.app.usageFallback() return current.app.usageFallback()
} }
func (current *execution) present(outcome Outcome, path []string) Outcome { func (current *execution) routeUsageLine() string {
return current.app.presentWithUsageLine(outcome, path, current.streams, current.usageLine) line := current.usageLine(clonePath(current.route.canonicalPath))
canonical := strings.TrimSpace(current.app.identity.Name + " " + current.route.canonicalPath.String())
entered := strings.TrimSpace(current.app.identity.Name + " " + current.route.enteredPath.String())
line = strings.Replace(line, "Usage: "+canonical, "Usage: "+entered, 1)
return line + "\nAlias for: " + canonical
} }
func normalizeStreams(streams Streams) Streams { func (current *execution) recordRouteEvents() {
if streams.Out == nil { entered := current.route.enteredPath.String()
streams.Out = io.Discard canonical := current.route.canonicalPath.String()
switch current.route.kind {
case routeCanonical:
if entered == canonical {
if canonical == "" {
current.debugEvents = append(current.debugEvents, "selected the application root command")
} else {
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected command path %q", canonical))
}
} else {
current.debugEvents = append(current.debugEvents, fmt.Sprintf("resolved command alias %q to %q", entered, canonical))
}
case routeDiscovery:
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected discovery route for prefix %q", entered))
default:
if entered != "" {
current.debugEvents = append(current.debugEvents, fmt.Sprintf("no canonical route matched command path %q", entered))
}
} }
if streams.Err == nil {
streams.Err = io.Discard
}
return streams
} }
func (a *App) commandAt(path []string) (Command, bool) { func (current *execution) recordSelectionEvents(result commandmeta.Result) {
if a == nil { mode := "ordinary"
return Command{}, false flagName := ""
switch result.Reporting.Mode {
case commandmeta.ReportingModeQuiet:
mode, flagName = "quiet", "quiet"
case commandmeta.ReportingModeVerbose:
mode, flagName = "verbose", "verbose"
case commandmeta.ReportingModeDebug:
mode, flagName = "debug", "debug"
} }
if len(path) == 0 { event := "selected ordinary reporting mode"
return Command{}, true if flagName != "" {
event = fmt.Sprintf("selected %s reporting mode", mode)
for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
occurrence := result.FlagOccurrences[index]
if occurrence.Name != flagName {
continue
}
enabled, err := strconv.ParseBool(occurrence.RawValue)
if err != nil || !enabled {
continue
}
canonical := "--" + flagName
if occurrence.Spelling != canonical {
event += fmt.Sprintf(" using %s (alias for %s)", occurrence.Spelling, canonical)
} else {
event += fmt.Sprintf(" using %s", canonical)
}
break
}
} }
command, ok := a.commands[path[0]] current.debugEvents = append(current.debugEvents, event)
if !ok || (command.reserved && command.command.payload == nil) { if result.Reporting.ProgressSet {
return Command{}, false state := "disabled"
} if result.Reporting.Progress {
current := command.command state = "enabled"
for _, name := range path[1:] { }
found := false event := "explicitly " + state + " progress output"
for _, child := range current.children { for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
if child.name == name { occurrence := result.FlagOccurrences[index]
current = child if occurrence.Name == "progress" {
found = true if occurrence.Spelling != "--progress" {
event += fmt.Sprintf(" using %s (alias for --progress)", occurrence.Spelling)
}
break break
} }
} }
if !found { current.debugEvents = append(current.debugEvents, event)
return Command{}, false
}
} }
return current, true
} }
func (a *App) present(outcome Outcome, path []string, streams Streams) Outcome { func (current *execution) submitDebugEvents() {
return a.presentWithUsageLine(outcome, path, streams, a.usageLine) events := current.debugEvents
current.debugEvents = nil
for _, event := range events {
_ = current.reporter.Debug(event)
}
} }
func (a *App) presentWithUsageLine(outcome Outcome, path []string, streams Streams, usageLine func([]string) string) Outcome { func (current *execution) finalize(outcome Outcome, path []string, handlerRan bool) Outcome {
if outcome.Kind == "" && outcome.Status == 0 && outcome.Domain == "" &&
outcome.Cause == nil && outcome.DeliveryError == nil &&
!outcome.ReportIncomplete && len(outcome.Diagnostics) == 0 {
outcome.Kind = OutcomeSuccess
}
if outcome.DiagnosticDelivery == "" {
outcome.DiagnosticDelivery = DeliveryNone
}
streams = normalizeStreams(streams)
if outcome.DiagnosticDelivery == DeliveryComplete {
return outcome
}
diagnostics := append([]Diagnostic(nil), outcome.Diagnostics...)
if outcome.Cause != nil && !hasErrorDiagnostic(diagnostics) {
diagnostics = append(diagnostics, MessageDiagnostic(configkit.SeverityError, outcome.Cause.Error()))
}
if outcome.Kind == OutcomeUsage { if outcome.Kind == OutcomeUsage {
diagnostics = append(diagnostics, MessageDiagnostic(configkit.SeverityError, usageLine(path))) details := append([]Detail(nil), outcome.Explanation.Details...)
} details = append(details, func(w io.Writer) error {
outcome.Diagnostics = diagnostics line := current.usageText(path)
if len(diagnostics) == 0 { if !strings.HasSuffix(line, "\n") {
return outcome line += "\n"
}
delivered := false
for _, diagnostic := range diagnostics {
if diagnostic.Write == nil {
continue
}
delivered = true
writer := &checkedWriter{writer: streams.Err}
writeErr := diagnostic.Write(writer)
if writer.failure != nil {
if writeErr == nil {
writeErr = writer.failure
} else if !errors.Is(writeErr, writer.failure) {
writeErr = errors.Join(writeErr, writer.failure)
} }
} _, err := io.WriteString(w, line)
if writeErr != nil { return err
return deliveryFailure(outcome, writeErr) })
outcome.Explanation.Details = details
}
var completion func(*Reporter)
if handlerRan {
completion = func(reporter *Reporter) {
kind := outcome.Kind
if kind == "" && outcome.Status == 0 && outcome.Domain == "" {
kind = OutcomeSuccess
}
path := current.route.canonicalPath.String()
_ = reporter.Debug(fmt.Sprintf("handler completed for %q with %s outcome and status %d", path, kind, outcome.Status))
} }
} }
if delivered { return current.reporter.finalize(outcome, completion)
outcome.DiagnosticDelivery = DeliveryComplete }
func (current *execution) usageText(path []string) string {
switch {
case current.route.kind == routeDiscovery && current.app != nil:
return fmt.Sprintf("Usage: %s %s [command]", current.app.identity.Name, current.route.enteredPath.String())
case current.route.kind == routeCanonical && !current.route.enteredPath.Equal(current.route.canonicalPath):
return current.routeUsageLine()
default:
return current.usageLine(path)
} }
return outcome
} }
func (a *App) usageLine(path []string) string { func (a *App) usageLine(path []string) string {
if a != nil { if a != nil {
if usage, failure := internalcobra.UsageLine(a.bridgeTree(), path); failure == nil { if usage, failure := internalcobra.UsageLine(a.routeSnapshot().metadata, path); failure == nil {
return "Usage: " + usage return "Usage: " + usage
} }
} }
@@ -233,42 +371,3 @@ func (a *App) usageFallback() string {
} }
return fmt.Sprintf("Usage: %s [command]", name) return fmt.Sprintf("Usage: %s [command]", name)
} }
func hasErrorDiagnostic(diagnostics []Diagnostic) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Write != nil && diagnostic.Severity == configkit.SeverityError {
return true
}
}
return false
}
func deliveryFailure(outcome Outcome, err error) Outcome {
if outcome.DeliveryError == nil {
outcome.DeliveryError = err
} else {
outcome.DeliveryError = errors.Join(outcome.DeliveryError, err)
}
outcome.DiagnosticDelivery = DeliveryIncomplete
if outcome.Kind != OutcomeDomain {
outcome.Kind = OutcomeOperational
outcome.Status = 1
}
return outcome
}
type checkedWriter struct {
writer io.Writer
failure error
}
func (w *checkedWriter) Write(data []byte) (int, error) {
n, err := w.writer.Write(data)
if err == nil && n < len(data) {
err = io.ErrShortWrite
}
if err != nil && w.failure == nil {
w.failure = err
}
return n, err
}

View File

@@ -10,7 +10,6 @@ import (
"testing" "testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit" commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
) )
func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) { func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
@@ -20,11 +19,11 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
} }
var inputs []input var inputs []input
var contexts []context.Context var contexts []context.Context
var streams []commandkit.Streams var reporters []*commandkit.Reporter
firstContext := context.WithValue(context.Background(), struct{}{}, "first") firstContext := context.WithValue(context.Background(), struct{}{}, "first")
secondContext := context.WithValue(context.Background(), struct{}{}, "second") secondContext := context.WithValue(context.Background(), struct{}{}, "second")
var firstOut, secondOut, firstDiagnostic, secondDiagnostic bytes.Buffer var firstOut, secondOut, firstDiagnostic, secondDiagnostic bytes.Buffer
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{ command := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "run", Name: "run",
Flags: []commandkit.Flag[input]{ Flags: []commandkit.Flag[input]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.name = value }), commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.name = value }),
@@ -33,13 +32,15 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
Run: func(invocation commandkit.Invocation, value input) commandkit.Outcome { Run: func(invocation commandkit.Invocation, value input) commandkit.Outcome {
inputs = append(inputs, value) inputs = append(inputs, value)
contexts = append(contexts, invocation.Context) contexts = append(contexts, invocation.Context)
streams = append(streams, invocation.Streams) reporter := invocation.Reporter()
reporters = append(reporters, reporter)
_ = reporter.RenderOutput(func(writer io.Writer) error {
_, err := io.WriteString(writer, value.name.Value+"\n")
return err
})
return commandkit.Success() return commandkit.Success()
}, },
}) })
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -56,11 +57,30 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
if inputs[1].name.Set || inputs[1].name.Value != "" || inputs[1].ids.Set { if inputs[1].name.Set || inputs[1].name.Value != "" || inputs[1].ids.Set {
t.Fatalf("second invocation inherited state: %#v", inputs[1]) t.Fatalf("second invocation inherited state: %#v", inputs[1])
} }
if streams[2].Out != io.Discard || streams[2].Err != io.Discard { if reporters[0] == nil || reporters[1] == nil || reporters[2] == nil || reporters[0] == reporters[1] || reporters[0] == reporters[2] || reporters[1] == reporters[2] {
t.Fatalf("nil streams were not replaced with io.Discard: %#v", streams[2]) t.Fatalf("invocations did not receive fresh reporters: %#v", reporters)
} }
if contexts[0] != firstContext || contexts[1] != secondContext || streams[0].Out != &firstOut || streams[0].Err != &firstDiagnostic || streams[1].Out != &secondOut || streams[1].Err != &secondDiagnostic { if firstOut.String() != "first\n" || secondOut.String() != "\n" || contexts[0] != firstContext || contexts[1] != secondContext {
t.Fatalf("handler invocation contexts or streams were not forwarded independently") t.Fatalf("handler invocation contexts or reporter destinations were not independent: out=%q/%q contexts=%v/%v", firstOut.String(), secondOut.String(), contexts[0], contexts[1])
}
}
func TestOmittedInputFactoryUsesExactPointerZeroValue(t *testing.T) {
command := commandkit.NewCommand(commandkit.CommandSpec[*int]{
Name: "run",
Run: func(_ commandkit.Invocation, value *int) commandkit.Outcome {
if value != nil {
return commandkit.Failure("omitted input factory allocated a pointer")
}
return commandkit.Success()
},
})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
if outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{}); outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("pointer zero value outcome = %#v", outcome)
} }
} }
@@ -70,7 +90,7 @@ func TestExecuteUsesFreshSessionForHelpAfterFlagBearingInvocation(t *testing.T)
enabled commandkit.Supplied[bool] enabled commandkit.Supplied[bool]
} }
var received []input var received []input
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{ command := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "run", Name: "run",
Flags: []commandkit.Flag[input]{ Flags: []commandkit.Flag[input]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) { commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) {
@@ -83,9 +103,6 @@ func TestExecuteUsesFreshSessionForHelpAfterFlagBearingInvocation(t *testing.T)
return commandkit.Success() return commandkit.Success()
}, },
}) })
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -115,27 +132,21 @@ func TestExecutePresentsHandlerUsageAfterRegisteringCommand(t *testing.T) {
usageCause := errors.New("handler requests usage") usageCause := errors.New("handler requests usage")
var app *commandkit.App var app *commandkit.App
var calls int var calls int
late, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ late := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "late", Name: "late",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }, Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() },
}) })
if err != nil { command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
t.Fatal(err)
}
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run", Name: "run",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
calls++ calls++
if err := app.Register(late); err != nil { if err := app.Register(late); err != nil {
return commandkit.Failure(err) return commandkit.Failure(err.Error()).WithCause(err)
} }
return commandkit.Usage(usageCause) return commandkit.Usage("The handler requests usage.").WithCause(usageCause)
}, },
}) })
if err != nil { app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
t.Fatal(err)
}
app, err = commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -156,7 +167,7 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
type input struct{ count commandkit.Supplied[int] } type input struct{ count commandkit.Supplied[int] }
parseFailure := errors.New("bad count") parseFailure := errors.New("bad count")
checkFailure := errors.New("incompatible inputs") checkFailure := errors.New("incompatible inputs")
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{ command := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "run", Name: "run",
Flags: []commandkit.Flag[input]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Required: true}, nil, func(value string) (int, error) { Flags: []commandkit.Flag[input]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Required: true}, nil, func(value string) (int, error) {
if value == "bad" { if value == "bad" {
@@ -168,9 +179,6 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
CheckInput: func(input) error { return checkFailure }, CheckInput: func(input) error { return checkFailure },
Run: func(commandkit.Invocation, input) commandkit.Outcome { return commandkit.Success() }, Run: func(commandkit.Invocation, input) commandkit.Outcome { return commandkit.Success() },
}) })
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -192,120 +200,86 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) { func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
domainCause := errors.New("domain rejected the request") domainCause := errors.New("domain rejected the request")
warning := commandkit.MessageDiagnostic(configkit.SeverityWarning, "review needed") command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run", Name: "run",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
outcome := commandkit.Domain("sample", 2, domainCause) _ = invocation.Reporter().Warn("review needed")
outcome.Diagnostics = []commandkit.Diagnostic{warning} outcome := commandkit.Domain("sample", 2).WithExplanation("The request was rejected.").WithCause(domainCause)
return outcome return outcome
}, },
}) })
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var diagnostic bytes.Buffer var diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic}) outcome := app.Execute(context.Background(), []string{"run", "--verbose"}, commandkit.Streams{Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeDomain || outcome.Domain != "sample" || outcome.Status != 2 || !errors.Is(outcome.Cause, domainCause) { if outcome.Kind != commandkit.OutcomeDomain || outcome.Domain != "sample" || outcome.Status != 2 || !errors.Is(outcome.Cause, domainCause) {
t.Fatalf("domain outcome changed: %#v", outcome) t.Fatalf("domain outcome changed: %#v", outcome)
} }
if diagnostic.String() != "review needed\ndomain rejected the request\n" || outcome.DiagnosticDelivery != commandkit.DeliveryComplete { if diagnostic.String() != "WARN: review needed\nERROR: The request was rejected.\n" || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome) t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome)
} }
warningOnly, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ warningOnly := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "warn", Name: "warn",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
result := commandkit.Success() _ = invocation.Reporter().Warn("review needed")
result.Diagnostics = []commandkit.Diagnostic{warning} return commandkit.Success()
return result
}, },
}) })
if err != nil {
t.Fatal(err)
}
app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly) app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
diagnostic.Reset() diagnostic.Reset()
outcome = app.Execute(context.Background(), []string{"warn"}, commandkit.Streams{Err: &diagnostic}) outcome = app.Execute(context.Background(), []string{"warn", "--verbose"}, commandkit.Streams{Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "review needed\n" { if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "WARN: review needed\n" {
t.Fatalf("warning-only outcome = %#v, diagnostics %q", outcome, diagnostic.String()) t.Fatalf("warning-only outcome = %#v, diagnostics %q", outcome, diagnostic.String())
} }
} }
func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T) { func TestExecuteDoesNotPresentOperationCauseWithoutExplanation(t *testing.T) {
cause := errors.New("already shown") cause := errors.New("private operation detail")
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run", Name: "run",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
outcome := commandkit.Domain("sample", 2, cause) return commandkit.Domain("sample", 2).WithCause(cause)
outcome.Diagnostics = []commandkit.Diagnostic{{Severity: configkit.SeverityError, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "presented\n")
return err
}}}
return outcome
}, },
}) })
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var diagnostic bytes.Buffer var diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic}) outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeDomain || diagnostic.String() != "presented\n" { if outcome.Kind != commandkit.OutcomeDomain || !errors.Is(outcome.Cause, cause) {
t.Fatalf("presented cause was duplicated: %#v, %q", outcome, diagnostic.String()) t.Fatalf("domain outcome changed: %#v", outcome)
} }
if strings.Contains(diagnostic.String(), cause.Error()) || !strings.Contains(diagnostic.String(), "ERROR: Command failed.") {
diagnostic.Reset() t.Fatalf("cause was presented instead of the fallback explanation: %#v, %q", outcome, diagnostic.String())
completed := commandkit.Domain("sample", 2, cause)
completed.DiagnosticDelivery = commandkit.DeliveryComplete
command, err = commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "done", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return completed }})
if err != nil {
t.Fatal(err)
}
app, err = commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
outcome = app.Execute(context.Background(), []string{"done"}, commandkit.Streams{Err: &diagnostic})
if diagnostic.Len() != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
t.Fatalf("completed diagnostics were repeated: %#v, %q", outcome, diagnostic.String())
} }
} }
func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) { func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
operationFailure := context.Canceled operationFailure := context.Canceled
writeFailure := errors.New("writer failed") writeFailure := errors.New("writer failed")
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run", Name: "run",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
outcome := commandkit.Failure(operationFailure) return commandkit.Failure("The operation was canceled.").WithCause(operationFailure)
outcome.ReportIncomplete = true
return outcome
}, },
}) })
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{err: writeFailure}}) outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{err: writeFailure}})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, operationFailure) || !errors.Is(outcome.DeliveryError, writeFailure) { if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || !errors.Is(outcome.Cause, operationFailure) || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeFailure) {
t.Fatalf("combined operation and delivery failure = %#v", outcome) t.Fatalf("combined operation and delivery failure = %#v", outcome)
} }
short := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: shortWriter{}}) short := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: shortWriter{}})
if !errors.Is(short.DeliveryError, io.ErrShortWrite) || short.DiagnosticDelivery != commandkit.DeliveryIncomplete { if !errors.Is(short.Presentation.Diagnostics.WriteError, io.ErrShortWrite) || short.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
t.Fatalf("short write was not detected: %#v", short) t.Fatalf("short write was not detected: %#v", short)
} }
} }
@@ -313,7 +287,7 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
func TestExecuteHelpSkipsRequiredInputsAndRejectsNilContext(t *testing.T) { func TestExecuteHelpSkipsRequiredInputsAndRejectsNilContext(t *testing.T) {
type input struct{ id commandkit.Supplied[string] } type input struct{ id commandkit.Supplied[string] }
factoryCalls, checkCalls, runCalls := 0, 0, 0 factoryCalls, checkCalls, runCalls := 0, 0, 0
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{ command := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "run", Name: "run",
NewInput: func() input { NewInput: func() input {
factoryCalls++ factoryCalls++
@@ -323,9 +297,6 @@ func TestExecuteHelpSkipsRequiredInputsAndRejectsNilContext(t *testing.T) {
CheckInput: func(input) error { checkCalls++; return nil }, CheckInput: func(input) error { checkCalls++; return nil },
Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() }, Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() },
}) })
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -344,7 +315,7 @@ func TestExecuteRunsApplicationOwnedNestedHelp(t *testing.T) {
type input struct{ topic commandkit.Supplied[string] } type input struct{ topic commandkit.Supplied[string] }
var received []input var received []input
domainCause := errors.New("application help result") domainCause := errors.New("application help result")
help, err := commandkit.NewCommand(commandkit.CommandSpec[input]{ help := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "help", Name: "help",
Description: "Explain a session topic.", Description: "Explain a session topic.",
Arguments: []commandkit.Argument[input]{commandkit.ValueArgument( Arguments: []commandkit.Argument[input]{commandkit.ValueArgument(
@@ -354,16 +325,10 @@ func TestExecuteRunsApplicationOwnedNestedHelp(t *testing.T) {
)}, )},
Run: func(_ commandkit.Invocation, value input) commandkit.Outcome { Run: func(_ commandkit.Invocation, value input) commandkit.Outcome {
received = append(received, value) received = append(received, value)
return commandkit.Domain("session-help", 7, domainCause) return commandkit.Domain("session-help", 7).WithExplanation("Session help is unavailable.").WithCause(domainCause)
}, },
}) })
if err != nil { session := commandkit.Group("session", "Session commands.", help)
t.Fatal(err)
}
session, err := commandkit.Group("session", "Session commands.", help)
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, session) app, err := commandkit.New(commandkit.Identity{Name: "example"}, session)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -401,7 +366,7 @@ func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) {
tags commandkit.Supplied[[]string] tags commandkit.Supplied[[]string]
} }
factoryCalls, runCalls := 0, 0 factoryCalls, runCalls := 0, 0
show, err := commandkit.NewCommand(commandkit.CommandSpec[input]{ show := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "show", Name: "show",
Summary: "Show one item.", Summary: "Show one item.",
Description: "Display the selected item in detail.", Description: "Display the selected item in detail.",
@@ -418,25 +383,9 @@ func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) {
Arguments: []commandkit.Argument[input]{commandkit.RepeatArgument(commandkit.ArgumentMeta{Name: "ID", Usage: "item identifier"}, 1, 3, func(value string) (string, error) { return value, nil }, func(*input, commandkit.Supplied[[]string]) {})}, Arguments: []commandkit.Argument[input]{commandkit.RepeatArgument(commandkit.ArgumentMeta{Name: "ID", Usage: "item identifier"}, 1, 3, func(value string) (string, error) { return value, nil }, func(*input, commandkit.Supplied[[]string]) {})},
Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() }, Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() },
}) })
if err != nil { config := commandkit.Group("config", "Inspect configuration.", show)
t.Fatal(err) now := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "now", Summary: "Deploy immediately.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})
} deploy := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "deploy", Summary: "Deploy artifacts.", Children: []commandkit.Command{now}, Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { runCalls++; return commandkit.Success() }})
config, err := commandkit.Group("config", "Inspect configuration.", show)
if err != nil {
t.Fatal(err)
}
now, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "now", Summary: "Deploy immediately.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})
if err != nil {
t.Fatal(err)
}
deployParent, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "deploy", Summary: "Deploy artifacts.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { runCalls++; return commandkit.Success() }})
if err != nil {
t.Fatal(err)
}
deploy, err := commandkit.WithChildren(deployParent, now)
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, config, deploy) app, err := commandkit.New(commandkit.Identity{Name: "example"}, config, deploy)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -500,7 +449,7 @@ func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) {
t.Fatalf("version invoked an application command: %#v calls=%d/%d", versionOutcome, factoryCalls, runCalls) t.Fatalf("version invoked an application command: %#v calls=%d/%d", versionOutcome, factoryCalls, runCalls)
} }
failed := app.Execute(context.Background(), []string{"config", "show", "--help"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic}) failed := app.Execute(context.Background(), []string{"config", "show", "--help"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic})
if failed.Kind != commandkit.OutcomeOperational || !failed.ReportIncomplete || !errors.Is(failed.DeliveryError, io.ErrShortWrite) { if failed.Kind != commandkit.OutcomeOperational || failed.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(failed.Presentation.Output.WriteError, io.ErrShortWrite) {
t.Fatalf("failed help delivery = %#v", failed) t.Fatalf("failed help delivery = %#v", failed)
} }
out.Reset() out.Reset()
@@ -540,7 +489,7 @@ func TestVersionCommandFormatsApplicationAndCommandKitIdentity(t *testing.T) {
t.Fatalf("invalid version scope = %#v, output %q", invalid, out.String()) t.Fatalf("invalid version scope = %#v, output %q", invalid, out.String())
} }
failed := app.Execute(context.Background(), []string{"version"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic}) failed := app.Execute(context.Background(), []string{"version"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic})
if failed.Kind != commandkit.OutcomeOperational || !failed.ReportIncomplete || !errors.Is(failed.DeliveryError, io.ErrShortWrite) { if failed.Kind != commandkit.OutcomeOperational || failed.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(failed.Presentation.Output.WriteError, io.ErrShortWrite) {
t.Fatalf("failed version delivery = %#v", failed) t.Fatalf("failed version delivery = %#v", failed)
} }
} }
@@ -559,10 +508,7 @@ func (shortWriter) Write(data []byte) (int, error) {
} }
func TestHelpEntryFormsPresentReportAndDiagnosticFailures(t *testing.T) { func TestHelpEntryFormsPresentReportAndDiagnosticFailures(t *testing.T) {
group, err := commandkit.Group("notes", "Notes metadata.") group := commandkit.Group("notes", "Notes metadata.")
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, group) app, err := commandkit.New(commandkit.Identity{Name: "example"}, group)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -572,11 +518,11 @@ func TestHelpEntryFormsPresentReportAndDiagnosticFailures(t *testing.T) {
for _, args := range [][]string{nil, {"notes"}, {"--help"}, {"-h"}, {"notes", "--help"}, {"help"}, {"help", "notes"}} { for _, args := range [][]string{nil, {"notes"}, {"--help"}, {"-h"}, {"notes", "--help"}, {"help"}, {"help", "notes"}} {
var diagnostics bytes.Buffer var diagnostics bytes.Buffer
result := app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: &diagnostics}) result := app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: &diagnostics})
if result.Kind != commandkit.OutcomeOperational || !result.ReportIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || result.DiagnosticDelivery != commandkit.DeliveryComplete || strings.Count(diagnostics.String(), reportErr.Error()) != 1 { if result.Kind != commandkit.OutcomeOperational || result.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Output.WriteError, reportErr) || result.Presentation.Diagnostics.State != commandkit.DeliveryComplete || (!strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output")) {
t.Fatalf("%v: result=%#v diagnostics=%q", args, result, diagnostics.String()) t.Fatalf("%v: result=%#v diagnostics=%q", args, result, diagnostics.String())
} }
result = app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: failingWriter{diagnosticErr}}) result = app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: failingWriter{diagnosticErr}})
if result.DiagnosticDelivery != commandkit.DeliveryIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || !errors.Is(result.DeliveryError, diagnosticErr) { if result.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Output.WriteError, reportErr) || result.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Diagnostics.WriteError, diagnosticErr) {
t.Fatalf("%v: result=%#v", args, result) t.Fatalf("%v: result=%#v", args, result)
} }
} }
@@ -595,104 +541,39 @@ func TestExecuteNormalizesOutcomeDefaultsAndPreservesDomainValues(t *testing.T)
{commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 2, Domain: "needs action", Cause: cause}, commandkit.OutcomeDomain, 2, "needs action", cause}, {commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 2, Domain: "needs action", Cause: cause}, commandkit.OutcomeDomain, 2, "needs action", cause},
{commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 0, Domain: "complete"}, commandkit.OutcomeDomain, 0, "complete", nil}, {commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 0, Domain: "complete"}, commandkit.OutcomeDomain, 0, "complete", nil},
} { } {
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return tc.outcome }}) command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return tc.outcome }})
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{}) var diagnostic bytes.Buffer
wantDelivery := commandkit.DeliveryNone result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
if tc.cause != nil { if result.Kind != tc.kind || result.Status != tc.status || result.Domain != tc.domain || result.Cause != tc.cause {
wantDelivery = commandkit.DeliveryComplete
}
if result.Kind != tc.kind || result.Status != tc.status || result.Domain != tc.domain || result.Cause != tc.cause || result.DiagnosticDelivery != wantDelivery {
t.Fatalf("result=%#v expected=%#v", result, tc) t.Fatalf("result=%#v expected=%#v", result, tc)
} }
if tc.status != 0 && !strings.Contains(diagnostic.String(), "ERROR: Command failed.") {
t.Fatalf("failed outcome omitted its fallback explanation: %q", diagnostic.String())
}
if tc.cause != nil && strings.Contains(diagnostic.String(), tc.cause.Error()) {
t.Fatalf("operation cause was presented automatically: %q", diagnostic.String())
}
} }
} }
func TestNilDiagnosticWritersHaveNoOutputAndDoNotHideCauses(t *testing.T) { func TestNilDiagnosticWriterDoesNotHideOperationCause(t *testing.T) {
cause := errors.New("operation failed") cause := errors.New("operation failed")
for _, base := range []commandkit.Outcome{commandkit.Success(), commandkit.Domain("needs action", 2, nil), commandkit.Failure(cause)} { command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
base.Diagnostics = []commandkit.Diagnostic{{}, {Severity: configkit.SeverityWarning}, {Severity: configkit.SeverityError}} Name: "run",
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return base }}) Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
if err != nil { return commandkit.Failure("The operation failed.").WithCause(cause)
t.Fatal(err) },
} })
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var diagnostics bytes.Buffer result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostics}) if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, cause) || result.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
want := commandkit.DeliveryNone t.Fatalf("nil diagnostic destination changed the outcome: %#v", result)
if base.Cause != nil {
want = commandkit.DeliveryComplete
}
if result.Kind != base.Kind || result.Status != base.Status || result.Domain != base.Domain || result.Cause != base.Cause || result.DeliveryError != nil || result.DiagnosticDelivery != want {
t.Fatalf("result=%#v base=%#v", result, base)
}
if base.Cause == nil && diagnostics.Len() != 0 {
t.Fatalf("unexpected output=%q", diagnostics.String())
}
if base.Cause != nil && strings.Count(diagnostics.String(), cause.Error()) != 1 {
t.Fatalf("cause output=%q", diagnostics.String())
}
if base.Cause != nil {
result = app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{io.ErrClosedPipe}})
if !errors.Is(result.Cause, cause) || !errors.Is(result.DeliveryError, io.ErrClosedPipe) || result.DiagnosticDelivery != commandkit.DeliveryIncomplete {
t.Fatalf("failed delivery=%#v", result)
}
}
}
}
func TestParentHelpDiscoversMetadataOnlyGroups(t *testing.T) {
leaf, err := commandkit.Group("notes", "Read notes metadata.")
if err != nil {
t.Fatal(err)
}
parent, err := commandkit.Group("topics", "Browse topic metadata.", leaf)
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, parent)
if err != nil {
t.Fatal(err)
}
loads := 0
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
loads++
return commandkit.Acquired[commandkit.ConfigView]{}, nil
}}}}); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
args []string
content string
}{
{nil, "Browse topic metadata."},
{[]string{"topics"}, "Read notes metadata."},
{[]string{"topics", "--help"}, "Read notes metadata."},
{[]string{"help", "topics"}, "Read notes metadata."},
{[]string{"topics", "notes"}, "Read notes metadata."},
{[]string{"help", "topics", "notes"}, "Read notes metadata."},
} {
var out bytes.Buffer
result := app.Execute(context.Background(), tc.args, commandkit.Streams{Out: &out})
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), tc.content) || loads != 0 {
t.Fatalf("%v: result=%#v output=%q loads=%d", tc.args, result, out.String(), loads)
}
if len(tc.args) == 1 && (!strings.Contains(out.String(), "Available Commands:") || !strings.Contains(out.String(), "[command]")) {
t.Fatalf("missing child inventory/usage: %q", out.String())
}
}
var diagnostic bytes.Buffer
result := app.Execute(context.Background(), []string{"topics", "unknown"}, commandkit.Streams{Err: &diagnostic})
if result.Kind != commandkit.OutcomeUsage || !strings.Contains(diagnostic.String(), "[command]") {
t.Fatalf("result=%#v diagnostic=%q", result, diagnostic.String())
} }
} }

189
execute_reporting_test.go Normal file
View File

@@ -0,0 +1,189 @@
package commandkit
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
)
func TestExecuteGivesEachInvocationItsReporterAndAppliesSelection(t *testing.T) {
var reporters []*Reporter
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(inv Invocation, _ struct{}) Outcome {
reporter := inv.Reporter()
reporters = append(reporters, reporter)
if _, err := fmt.Fprint(reporter.Output(), "result\n"); err != nil {
t.Errorf("write result: %v", err)
}
if err := reporter.Warn("attention"); err != nil {
t.Errorf("submit warning: %v", err)
}
return Outcome{
Kind: OutcomeDomain, Domain: "accepted", Status: 0,
Presentation: PresentationResult{
Output: DeliveryResult{State: DeliveryIncomplete, WriteError: io.ErrClosedPipe},
},
}
},
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
var quietOut, quietErr bytes.Buffer
quiet := app.Execute(context.Background(), []string{"run", "--quiet"}, Streams{Out: &quietOut, Err: &quietErr})
if quiet.Kind != OutcomeDomain || quiet.Status != 0 || quiet.Domain != "accepted" {
t.Fatalf("quiet outcome = %#v", quiet)
}
if quietOut.Len() != 0 || quietErr.Len() != 0 {
t.Fatalf("quiet output = %q, diagnostics = %q", quietOut.String(), quietErr.String())
}
if quiet.Presentation.Output.State != DeliverySuppressed || quiet.Presentation.Diagnostics.State != DeliverySuppressed {
t.Fatalf("quiet presentation = %#v", quiet.Presentation)
}
var verboseOut, verboseErr bytes.Buffer
verbose := app.Execute(context.Background(), []string{"run", "--verbose"}, Streams{Out: &verboseOut, Err: &verboseErr})
if verbose.Kind != OutcomeDomain || verbose.Status != 0 {
t.Fatalf("verbose outcome = %#v", verbose)
}
if verboseOut.String() != "result\n" || verboseErr.String() != "WARN: attention\n" {
t.Fatalf("verbose output = %q, diagnostics = %q", verboseOut.String(), verboseErr.String())
}
if verbose.Presentation.Output.State != DeliveryComplete || verbose.Presentation.Diagnostics.State != DeliveryComplete {
t.Fatalf("verbose presentation = %#v", verbose.Presentation)
}
if len(reporters) != 2 || reporters[0] == nil || reporters[1] == nil || reporters[0] == reporters[1] {
t.Fatalf("invocation reporters = %#v", reporters)
}
if _, err := reporters[0].Output().Write([]byte("late")); !errors.Is(err, ErrReporterClosed) {
t.Fatalf("write through finalized reporter error = %v", err)
}
}
func TestExecuteQuietUsageKeepsAliasGuidanceVisible(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "Example"}, command, Alias("r", "run"))
if err != nil {
t.Fatal(err)
}
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"r", "--quiet", "--unknown"}, Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeUsage || outcome.Status != 2 {
t.Fatalf("outcome = %#v", outcome)
}
if output.Len() != 0 {
t.Fatalf("usage output = %q", output.String())
}
if got := diagnostics.String(); !strings.Contains(got, "ERROR: unknown flag") || !strings.Contains(got, "Usage: Example r") || !strings.Contains(got, "Alias for: Example run") {
t.Fatalf("usage diagnostics = %q", got)
}
if outcome.Presentation.Diagnostics.State != DeliveryComplete {
t.Fatalf("diagnostic presentation = %#v", outcome.Presentation.Diagnostics)
}
}
func TestUnresolvedRoutesUseReportingAliasesForTheirParseContext(t *testing.T) {
calls := 0
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
NewInput: func() struct{} { calls++; return struct{}{} },
Run: func(Invocation, struct{}) Outcome { calls++; return Success() },
})
app, err := New(Identity{Name: "Example"}, Group("group", "", Group("nested", "", command)),
FlagAlias("-d", "--debug"), FlagAlias("-q", "--quiet"),
FlagAlias("-x", "--quiet"), FlagAliasFor("group nested", "-x", "--debug"))
if err != nil {
t.Fatal(err)
}
for _, path := range [][]string{{"missing"}, {"group", "missing"}, {"group", "nested", "missing"}} {
t.Run(strings.Join(path, "/"), func(t *testing.T) {
var canonicalDiagnostics, aliasDiagnostics bytes.Buffer
canonical := app.Execute(context.Background(), append(append([]string(nil), path...), "--debug"), Streams{Err: &canonicalDiagnostics})
alias := app.Execute(context.Background(), append(append([]string(nil), path...), "-d"), Streams{Err: &aliasDiagnostics})
if canonical.Kind != OutcomeUsage || alias.Kind != OutcomeUsage || canonical.Status != alias.Status || canonical.Explanation.Message != alias.Explanation.Message {
t.Fatalf("canonical=%#v alias=%#v", canonical, alias)
}
for _, text := range []string{canonicalDiagnostics.String(), aliasDiagnostics.String()} {
if !strings.Contains(text, "DEBUG:") || !strings.Contains(text, "ERROR:") || strings.Contains(text, "unknown shorthand") || strings.Contains(text, "Alias for:") {
t.Fatalf("diagnostics=%q", text)
}
}
route := resolveEnteredRoute(path, app.routeSnapshot())
if route.kind != routeUnknown || len(route.canonicalPath) != 0 {
t.Fatalf("invalid route acquired canonical identity: %#v", route)
}
var quiet bytes.Buffer
got := app.Execute(context.Background(), append(append([]string(nil), path...), "-q", "--unknown"), Streams{Err: &quiet})
if got.Kind != OutcomeUsage || !strings.Contains(quiet.String(), "ERROR: unknown flag") || strings.Contains(quiet.String(), "DEBUG:") {
t.Fatalf("quiet alias outcome=%#v diagnostics=%q", got, quiet.String())
}
})
}
var scoped bytes.Buffer
got := app.Execute(context.Background(), []string{"group", "nested", "missing", "-x"}, Streams{Err: &scoped})
if got.Kind != OutcomeUsage || !strings.Contains(scoped.String(), "DEBUG:") {
t.Fatalf("scoped precedence outcome=%#v diagnostics=%q", got, scoped.String())
}
if calls != 0 {
t.Fatalf("invalid routes ran factories or handlers %d times", calls)
}
}
type executeReportingFailureWriter struct{ err error }
func (w executeReportingFailureWriter) Write([]byte) (int, error) { return 0, w.err }
func TestExecuteFinalizesEarlyAndIgnoredWriterFailures(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(inv Invocation, _ struct{}) Outcome {
_, _ = inv.Reporter().Output().Write([]byte("report"))
return Success()
},
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
writeErr := errors.New("result destination failed")
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"run"}, Streams{
Out: executeReportingFailureWriter{err: writeErr}, Err: &diagnostics,
})
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) {
t.Fatalf("ignored output failure outcome = %#v", outcome)
}
if !strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output") {
t.Fatalf("fallback diagnostic = %q", diagnostics.String())
}
diagnostics.Reset()
outcome = app.Execute(nil, []string{"run"}, Streams{Err: &diagnostics})
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil {
t.Fatalf("nil context outcome = %#v", outcome)
}
if !strings.Contains(diagnostics.String(), "ERROR: invocation context must not be nil\n") {
t.Fatalf("nil context diagnostic = %q", diagnostics.String())
}
diagnostics.Reset()
var nilApp *App
outcome = nilApp.Execute(context.Background(), []string{"run"}, Streams{Err: &diagnostics})
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil {
t.Fatalf("nil app outcome = %#v", outcome)
}
if !strings.Contains(diagnostics.String(), "ERROR: cannot execute commands on a nil app\n") {
t.Fatalf("nil app diagnostic = %q", diagnostics.String())
}
}

View File

@@ -1,230 +0,0 @@
package commandkit
import (
"bytes"
"errors"
"fmt"
"io"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func newConfigExplainCommand[C any](prepared *preparedConfigSuite[C]) (Command, error) {
flags := configInputFlags(prepared.suite, "text")
flags = append(flags, ValueFlag[configInput[C]](FlagMeta{
Name: "path",
Usage: "Configuration path as a JSON Pointer; an empty value selects the root.",
Required: true,
}, nil, parseString, func(input *configInput[C], value Supplied[string]) {
input.Path = value
}))
spec := CommandSpec[configInput[C]]{
Name: string(ConfigExplain),
Summary: "Explain the effective and historical value at a configuration path.",
Flags: flags,
CheckInput: func(input configInput[C]) error {
if _, err := preflightConfig(prepared, input, ConfigExplain); err != nil {
return err
}
if !input.Path.Set {
return fmt.Errorf("--path is required")
}
if _, err := configkit.ParsePath(input.Path.Value); err != nil {
return fmt.Errorf("invalid configuration path %q: %w", input.Path.Value, err)
}
return nil
},
Run: func(invocation Invocation, input configInput[C]) Outcome {
return runConfigExplanation(invocation, prepared, input)
},
}
return NewCommand(spec)
}
type explanationReport struct {
Scope string `yaml:"scope"`
Path string `yaml:"path"`
Effective snapshotDTO `yaml:"effective"`
Origin explanationOriginDTO `yaml:"origin"`
History []explanationChangeDTO `yaml:"history"`
SecretReference *secretReferenceDTO `yaml:"secret_reference"`
}
type snapshotDTO struct {
Present bool `yaml:"present"`
Redacted bool `yaml:"redacted"`
YAML string `yaml:"yaml"`
}
type explanationOriginDTO struct {
Layer string `yaml:"layer"`
Locations []locationDTO `yaml:"locations"`
Operation string `yaml:"operation"`
Inheritance []inheritanceStepDTO `yaml:"inheritance"`
}
type inheritanceStepDTO struct {
From string `yaml:"from"`
To string `yaml:"to"`
Reference string `yaml:"reference"`
Locations []locationDTO `yaml:"locations"`
}
type explanationChangeDTO struct {
Before snapshotDTO `yaml:"before"`
After snapshotDTO `yaml:"after"`
Origin explanationOriginDTO `yaml:"origin"`
Reasons []string `yaml:"reasons"`
Dependencies []string `yaml:"dependencies"`
}
type secretReferenceDTO struct {
Name string `yaml:"name"`
}
func runConfigExplanation[C any](invocation Invocation, prepared *preparedConfigSuite[C], input configInput[C]) Outcome {
if err := invocation.Context.Err(); err != nil {
return Failure(err)
}
scopeNames, err := selectConfigScopes(prepared, input, ConfigExplain)
if err != nil {
return Failure(err)
}
if !input.Path.Set {
return Failure(fmt.Errorf("--path is required"))
}
path, err := configkit.ParsePath(input.Path.Value)
if err != nil {
return Usage(fmt.Errorf("invalid configuration path %q: %w", input.Path.Value, err))
}
name := scopeNames[0]
contexts := contextsForScopes(prepared, input, scopeNames)
scope, ok := findConfigScope(prepared, name)
if !ok || scope.Acquire == nil {
return Failure(fmt.Errorf("configuration scope %q has no acquisition callback", name))
}
if err := invocation.Context.Err(); err != nil {
return explanationFailure(err, nil, false)
}
acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigExplain, Selection: input.Selection}, contexts[name])
diagnostics := append([]Diagnostic(nil), acquired.Diagnostics...)
if acquireErr != nil {
return explanationFailure(acquireErr, diagnostics, false)
}
if err := invocation.Context.Err(); err != nil {
return explanationFailure(err, diagnostics, false)
}
if acquired.Value.Explanation == nil {
return explanationFailure(fmt.Errorf("configuration scope %q did not provide an explanation reader", name), diagnostics, false)
}
explanation, found := acquired.Value.Explanation.Explain(path)
if !found {
return explanationFailure(fmt.Errorf("configuration path %q is not known in scope %q", path.String(), name), diagnostics, false)
}
if err := invocation.Context.Err(); err != nil {
return explanationFailure(err, diagnostics, false)
}
if input.Format != "yaml" {
return writeNativeExplanation(invocation.Streams.Out, acquired.Value.Explanation, path, diagnostics)
}
var report bytes.Buffer
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
return explanationFailure(err, diagnostics, false)
}
if err := invocation.Context.Err(); err != nil {
return explanationFailure(err, diagnostics, false)
}
if err := writeReport(invocation.Streams.Out, report.Bytes()); err != nil {
outcome := explanationFailure(err, diagnostics, true)
outcome.DeliveryError = err
return outcome
}
outcome := Success()
outcome.Diagnostics = diagnostics
return outcome
}
func writeNativeExplanation(writer io.Writer, reader ExplanationReader, path configkit.Path, diagnostics []Diagnostic) Outcome {
checked := &checkedWriter{writer: writer}
renderErr := reader.WriteExplanation(checked, path)
if checked.failure != nil {
if renderErr == nil {
renderErr = checked.failure
} else {
renderErr = errors.Join(renderErr, checked.failure)
}
}
if renderErr != nil {
outcome := explanationFailure(renderErr, diagnostics, true)
outcome.DeliveryError = checked.failure
return outcome
}
outcome := Success()
outcome.Diagnostics = diagnostics
return outcome
}
func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {
report := explanationReport{
Scope: scope,
Path: explanation.Path.String(),
Effective: snapshotReport(explanation.Effective),
Origin: originReport(explanation.Origin),
History: make([]explanationChangeDTO, 0, len(explanation.History)),
}
for _, change := range explanation.History {
entry := explanationChangeDTO{
Before: snapshotReport(change.Before),
After: snapshotReport(change.After),
Origin: originReport(change.Origin),
Reasons: append([]string{}, change.Reasons...),
Dependencies: make([]string, 0, len(change.Dependencies)),
}
for _, dependency := range change.Dependencies {
entry.Dependencies = append(entry.Dependencies, dependency.String())
}
report.History = append(report.History, entry)
}
if explanation.SecretReference != nil {
report.SecretReference = &secretReferenceDTO{Name: explanation.SecretReference.Name}
}
return report
}
func snapshotReport(snapshot configkit.ValueSnapshot) snapshotDTO {
return snapshotDTO{Present: snapshot.Present, Redacted: snapshot.Redacted, YAML: snapshot.YAML}
}
func originReport(origin configkit.Origin) explanationOriginDTO {
report := explanationOriginDTO{
Layer: string(origin.Layer),
Locations: locationReports(origin.Locations),
Operation: string(origin.Operation),
Inheritance: make([]inheritanceStepDTO, 0, len(origin.Inheritance)),
}
for _, step := range origin.Inheritance {
report.Inheritance = append(report.Inheritance, inheritanceStepDTO{
From: step.From.String(),
To: step.To.String(),
Reference: step.Reference.String(),
Locations: locationReports(step.Locations),
})
}
return report
}
func locationReports(locations []configkit.Location) []locationDTO {
report := make([]locationDTO, 0, len(locations))
for _, location := range locations {
report = append(report, locationDTO{SourceID: location.SourceID, Line: location.Line, Column: location.Column})
}
return report
}
func explanationFailure(cause error, diagnostics []Diagnostic, reportStarted bool) Outcome {
outcome := Failure(cause)
outcome.Diagnostics = diagnostics
outcome.ReportIncomplete = reportStarted
return outcome
}

282
flag_aliases.go Normal file
View File

@@ -0,0 +1,282 @@
package commandkit
import (
"fmt"
"sort"
"strings"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
type resolvedFlagAlias struct {
source capturedFlagSpelling
target capturedFlagSpelling
}
type resolvedFlagAliases struct {
bySpelling map[string]resolvedFlagAlias
}
type flagAliasMapping struct {
target capturedFlagSpelling
scoped bool
}
type flagAliasContext struct {
path commandpath.Path
canonical map[string]capturedFlagSpelling
discovery bool
}
func (aliases resolvedFlagAliases) neutralRecords() []commandmeta.FlagAlias {
keys := make([]string, 0, len(aliases.bySpelling))
for key := range aliases.bySpelling {
keys = append(keys, key)
}
sort.Strings(keys)
records := make([]commandmeta.FlagAlias, 0, len(keys))
for _, key := range keys {
alias := aliases.bySpelling[key]
records = append(records, commandmeta.FlagAlias{Source: alias.source.token(), Target: alias.target.token()})
}
return records
}
func (spelling capturedFlagSpelling) token() string {
if spelling.short {
return "-" + spelling.name
}
return "--" + spelling.name
}
func compileFlagAliases(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
Resolve(commandpath.Path) commandpath.Path
}) (map[string]resolvedFlagAliases, map[string]resolvedFlagAliases, error) {
canonicalNodes := indexCanonicalNodes(roots)
global := make(map[string]flagAliasMapping)
scoped := make(map[string]map[string]flagAliasMapping)
for _, rule := range rules {
record := rule.record
switch record.kind {
case globalFlagAliasRule:
source := flagSpellingKey(record.sourceFlag)
global[source] = flagAliasMapping{target: record.targetFlag}
case scopedFlagAliasRule:
scope := record.scopePath.Key()
if canonicalNodes[scope] == nil {
return nil, nil, fmt.Errorf("flag alias scope %q is not a canonical command path", record.scopePath.String())
}
if scoped[scope] == nil {
scoped[scope] = make(map[string]flagAliasMapping)
}
source := flagSpellingKey(record.sourceFlag)
scoped[scope][source] = flagAliasMapping{target: record.targetFlag, scoped: true}
}
}
canonicalContexts := make(map[string]flagAliasContext, len(canonicalNodes)+1)
rootPath := commandpath.Path(nil)
canonicalContexts[rootPath.Key()] = flagAliasContext{path: rootPath, canonical: helpFlagSpellings()}
for key, node := range canonicalNodes {
canonicalContexts[key] = flagAliasContext{path: node.path.Clone(), canonical: canonicalFlagSpellings(node.command)}
}
discoveryContexts := discoverFlagContexts(rules, roots, aliases, canonicalNodes)
canonicalKeys := sortedContextKeys(canonicalContexts)
resolvedCanonical := make(map[string]resolvedFlagAliases, len(canonicalContexts))
for _, key := range canonicalKeys {
context := canonicalContexts[key]
mappings := cloneFlagMappings(global)
for source, mapping := range scoped[key] {
mappings[source] = mapping
}
resolved, err := resolveFlagAliases(context, mappings)
if err != nil {
return nil, nil, err
}
resolvedCanonical[key] = resolved
}
discoveryKeys := sortedContextKeys(discoveryContexts)
resolvedDiscovery := make(map[string]resolvedFlagAliases, len(discoveryContexts))
for _, key := range discoveryKeys {
context := discoveryContexts[key]
resolved, err := resolveFlagAliases(context, cloneFlagMappings(global))
if err != nil {
return nil, nil, err
}
resolvedDiscovery[key] = resolved
}
return resolvedCanonical, resolvedDiscovery, nil
}
func indexCanonicalNodes(roots map[string]*canonicalNode) map[string]*canonicalNode {
indexed := make(map[string]*canonicalNode)
var add func(*canonicalNode)
add = func(node *canonicalNode) {
indexed[node.path.Key()] = node
for _, child := range node.children {
add(child)
}
}
for _, root := range roots {
add(root)
}
return indexed
}
func helpFlagSpellings() map[string]capturedFlagSpelling {
flags := make(map[string]capturedFlagSpelling, 2+len(commandmeta.ReportingFlags()))
for _, spelling := range []capturedFlagSpelling{{name: "help"}, {name: "h", short: true}} {
flags[flagSpellingKey(spelling)] = spelling
}
for _, metadata := range commandmeta.ReportingFlags() {
spelling := capturedFlagSpelling{name: metadata.Name}
flags[flagSpellingKey(spelling)] = spelling
}
return flags
}
func canonicalFlagSpellings(command Command) map[string]capturedFlagSpelling {
flags := helpFlagSpellings()
if command.record.payload == nil {
return flags
}
for _, metadata := range command.record.payload.bridgeFlags() {
long := capturedFlagSpelling{name: metadata.Name}
flags[flagSpellingKey(long)] = long
if metadata.Short != "" {
short := capturedFlagSpelling{name: metadata.Short, short: true}
flags[flagSpellingKey(short)] = short
}
}
return flags
}
func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
Resolve(commandpath.Path) commandpath.Path
}, canonicalNodes map[string]*canonicalNode) map[string]flagAliasContext {
commandSources := make(map[string]struct{})
prefixes := make(map[string]commandpath.Path)
for _, rule := range rules {
if rule.record.kind == commandAliasRule {
commandSources[rule.record.sourcePath.Key()] = struct{}{}
}
}
for _, rule := range rules {
if rule.record.kind != commandAliasRule {
continue
}
for length := 1; length < len(rule.record.sourcePath); length++ {
prefix := rule.record.sourcePath[:length].Clone()
key := prefix.Key()
if canonicalNodes[key] != nil {
continue
}
if _, exactAlias := commandSources[key]; exactAlias {
continue
}
resolved := aliases.Resolve(prefix)
trace := traceCanonicalPath(roots, resolved)
if trace.exact != nil || (trace.operandOwner != nil && acceptsOperandPrefix(trace.operandOwner.command, len(resolved)-len(trace.operandOwner.path))) {
continue
}
prefixes[key] = prefix
}
}
contexts := make(map[string]flagAliasContext, len(prefixes))
for key, path := range prefixes {
contexts[key] = flagAliasContext{path: path, canonical: helpFlagSpellings(), discovery: true}
}
return contexts
}
func cloneFlagMappings(mappings map[string]flagAliasMapping) map[string]flagAliasMapping {
cloned := make(map[string]flagAliasMapping, len(mappings))
for source, mapping := range mappings {
cloned[source] = mapping
}
return cloned
}
func sortedContextKeys(contexts map[string]flagAliasContext) []string {
keys := make([]string, 0, len(contexts))
for key := range contexts {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func resolveFlagAliases(context flagAliasContext, mappings map[string]flagAliasMapping) (resolvedFlagAliases, error) {
result := resolvedFlagAliases{bySpelling: make(map[string]resolvedFlagAlias)}
sources := make([]string, 0, len(mappings))
for source := range mappings {
sources = append(sources, source)
}
sort.Strings(sources)
for _, sourceKey := range sources {
mapping := mappings[sourceKey]
source := spellingFromKey(sourceKey)
target, active, err := resolveFlagTarget(source, mapping.target, mappings, context.canonical)
if err != nil {
return resolvedFlagAliases{}, fmt.Errorf("%s: %w", flagContextDescription(context), err)
}
if !active {
if mapping.scoped {
return resolvedFlagAliases{}, fmt.Errorf("scoped flag alias %s has no canonical destination in %s", source.token(), flagContextDescription(context))
}
continue
}
if _, collision := context.canonical[sourceKey]; collision {
return resolvedFlagAliases{}, fmt.Errorf("flag alias %s conflicts with a canonical spelling in %s", source.token(), flagContextDescription(context))
}
result.bySpelling[sourceKey] = resolvedFlagAlias{source: source, target: target}
}
return result, nil
}
func resolveFlagTarget(source, target capturedFlagSpelling, mappings map[string]flagAliasMapping, canonical map[string]capturedFlagSpelling) (capturedFlagSpelling, bool, error) {
visited := map[string]int{flagSpellingKey(source): 0}
chain := []capturedFlagSpelling{source}
current := target
for {
key := flagSpellingKey(current)
if endpoint, exists := canonical[key]; exists {
return endpoint, true, nil
}
if start, exists := visited[key]; exists {
cycle := append(append([]capturedFlagSpelling(nil), chain[start:]...), current)
spellings := make([]string, 0, len(cycle))
for _, spelling := range cycle {
spellings = append(spellings, spelling.token())
}
return capturedFlagSpelling{}, false, fmt.Errorf("flag alias cycle: %s", strings.Join(spellings, " → "))
}
visited[key] = len(chain)
chain = append(chain, current)
next, exists := mappings[key]
if !exists {
return capturedFlagSpelling{}, false, nil
}
current = next.target
}
}
func spellingFromKey(key string) capturedFlagSpelling {
if strings.HasPrefix(key, "short:") {
return capturedFlagSpelling{name: strings.TrimPrefix(key, "short:"), short: true}
}
return capturedFlagSpelling{name: strings.TrimPrefix(key, "long:")}
}
func flagContextDescription(context flagAliasContext) string {
if context.discovery {
return fmt.Sprintf("alias prefix %q", context.path.String())
}
if len(context.path) == 0 {
return "application root"
}
return fmt.Sprintf("command %q", context.path.String())
}

297
flag_aliases_test.go Normal file
View File

@@ -0,0 +1,297 @@
package commandkit
import (
"strings"
"testing"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
func flagAliasCommand(name string, flags ...Flag[struct{}]) Command {
return NewCommand(CommandSpec[struct{}]{
Name: name, Summary: name, Flags: flags,
Run: func(Invocation, struct{}) Outcome { return Success() },
})
}
func stringFlag(name, short string) Flag[struct{}] {
return ValueFlag(FlagMeta{Name: name, Short: short}, (*string)(nil),
func(value string) (string, error) { return value, nil },
func(*struct{}, Supplied[string]) {},
)
}
func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) {
inspect := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("format", "f"))
show := flagAliasCommand("show", stringFlag("color", "c"), stringFlag("format", "f"))
group := Group("Tools", "tools", show)
app, err := New(Identity{Name: "Example"},
inspect,
group,
Alias("look", "inspect"),
FlagAlias("--colour", "--hue"),
FlagAlias("--hue", "--color"),
FlagAlias("-x", "--color"),
FlagAlias("--tint", "-c"),
FlagAliasFor("Tools", "--group-help", "--help"),
FlagAliasFor("inspect", "--hue", "--format"),
FlagAliasFor("inspect", "--local", "--format"),
)
if err != nil {
t.Fatal(err)
}
inspectAliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling
for source, target := range map[string]string{
"--colour": "--format",
"--hue": "--format",
"-x": "--color",
"--tint": "-c",
"--local": "--format",
} {
alias, ok := inspectAliases[flagSpellingKey(capturedSpelling(source))]
if !ok || alias.target.token() != target {
t.Errorf("inspect alias %s = (%s, %v), want %s", source, alias.target.token(), ok, target)
}
}
groupAliases := app.flagAliases[commandpath.Path{"Tools"}.Key()].bySpelling
if _, active := groupAliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})]; active {
t.Error("global alias was active on a group without its destination flag")
}
if _, inherited := groupAliases[flagSpellingKey(capturedFlagSpelling{name: "local"})]; inherited {
t.Fatal("command-scoped alias was inherited by its parent group")
}
if _, inherited := app.flagAliases[commandpath.Path{"Tools", "show"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "local"})]; inherited {
t.Fatal("command-scoped alias was inherited by a different command")
}
groupHelp := flagSpellingKey(capturedFlagSpelling{name: "group-help"})
if got := groupAliases[groupHelp].target.token(); got != "--help" {
t.Fatalf("group-scoped help alias target = %s, want --help", got)
}
if _, inherited := app.flagAliases[commandpath.Path{"Tools", "show"}.Key()].bySpelling[groupHelp]; inherited {
t.Fatal("group-scoped alias was inherited by its child command")
}
}
func TestFlagAliasDiscoveryContextsUseGlobalReportingAliases(t *testing.T) {
show := flagAliasCommand("show")
app, err := New(Identity{Name: "Example"},
Group("Tools", "tools", show),
Alias("shortcut show", "Tools show"),
FlagAlias("--hlep", "--help"),
FlagAlias("--silent", "--quiet"),
FlagAlias("--chatty", "--verbose"),
FlagAlias("--trace", "--debug"),
FlagAlias("--show-progress", "--progress"),
FlagAliasFor("Tools show", "--local-help", "--help"),
)
if err != nil {
t.Fatal(err)
}
globalHelp := flagSpellingKey(capturedFlagSpelling{name: "hlep"})
if got := app.flagAliases[commandpath.Path(nil).Key()].bySpelling[globalHelp].target.token(); got != "--help" {
t.Fatalf("root --hlep target = %s, want --help", got)
}
if got := app.flagAliases[commandpath.Path{"Tools"}.Key()].bySpelling[globalHelp].target.token(); got != "--help" {
t.Fatalf("group --hlep target = %s, want --help", got)
}
discovery := app.discoveryFlags[commandpath.Path{"shortcut"}.Key()].bySpelling
if got := discovery[globalHelp].target.token(); got != "--help" {
t.Fatalf("discovery --hlep target = %s, want --help", got)
}
for label, aliases := range map[string]resolvedFlagAliases{
"root": app.flagAliases[commandpath.Path(nil).Key()],
"group": app.flagAliases[commandpath.Path{"Tools"}.Key()],
"command": app.flagAliases[commandpath.Path{"Tools", "show"}.Key()],
"discovery": app.discoveryFlags[commandpath.Path{"shortcut"}.Key()],
} {
for source, target := range map[string]string{
"silent": "quiet",
"chatty": "verbose",
"trace": "debug",
"show-progress": "progress",
} {
key := flagSpellingKey(capturedFlagSpelling{name: source})
if got := aliases.bySpelling[key].target.token(); got != "--"+target {
t.Errorf("%s --%s target = %s, want --%s", label, source, got, target)
}
}
}
if _, inherited := discovery[flagSpellingKey(capturedFlagSpelling{name: "local-help"})]; inherited {
t.Fatal("discovery context inherited a command-scoped alias")
}
}
func TestReportingFlagsAreReservedAndAcceptApplicationShortAliases(t *testing.T) {
for _, name := range []string{"quiet", "verbose", "debug", "progress"} {
command := flagAliasCommand("reserved", stringFlag(name, ""))
if len(command.record.issues) == 0 {
t.Errorf("capturing --%s did not retain a declaration error", name)
}
app, err := New(Identity{Name: "Example"}, flagAliasCommand("safe"))
if err != nil {
t.Fatal(err)
}
snapshot := app.routeSnapshot()
if err := app.Register(command, Alias("reserved-shortcut", "reserved")); err == nil || !strings.Contains(err.Error(), "reserved for reporting") {
t.Errorf("registering --%s returned %v, want a retained reporting reservation error", name, err)
}
if app.routeSnapshot() != snapshot || app.commands["reserved"] != nil || app.commands["safe"] == nil || len(app.routingRules) != 0 {
t.Fatalf("failed registration of --%s changed the installed commands", name)
}
}
declarations := []Declaration{flagAliasCommand("run")}
for short, target := range map[string]string{"q": "quiet", "v": "verbose", "d": "debug"} {
declarations = append(declarations, FlagAlias("-"+short, "--"+target))
}
if _, err := New(Identity{Name: "Example"}, declarations...); err != nil {
t.Fatalf("application short aliases for reporting flags were rejected: %v", err)
}
}
func TestReportingFlagSpellingsConflictWithAliasSources(t *testing.T) {
for _, name := range []string{"quiet", "verbose", "debug", "progress"} {
if _, err := New(Identity{Name: "Example"}, FlagAlias("--"+name, "--help")); err == nil || !strings.Contains(err.Error(), "conflicts with a canonical spelling") {
t.Errorf("alias source --%s returned %v, want a canonical spelling collision", name, err)
}
}
if _, err := New(Identity{Name: "Example"}, Group("tools", "", flagAliasCommand("show")), FlagAliasFor("tools", "--quiet", "--help")); err == nil || !strings.Contains(err.Error(), "conflicts with a canonical spelling") {
t.Errorf("group-scoped canonical reporting spelling returned %v, want a collision", err)
}
}
func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testing.T) {
app, err := New(Identity{Name: "Example"},
flagAliasCommand("run", stringFlag("format", "f")),
Group("tools", "", flagAliasCommand("show")),
)
if err != nil {
t.Fatal(err)
}
var check func(*commandmeta.Command)
check = func(node *commandmeta.Command) {
if len(node.CoreFlags) != 4 {
t.Errorf("%s core reporting flags = %#v", node.Name, node.CoreFlags)
}
for _, flag := range node.Flags {
if commandmeta.IsReportingFlag(flag.Name) {
t.Errorf("%s reporting flag %q was added to consumer bindings", node.Name, flag.Name)
}
}
for _, child := range node.Children {
check(child)
}
}
check(app.routeSnapshot().metadata)
}
func TestFlagAliasDuplicatesAndScopesAreDeterministic(t *testing.T) {
command := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("format", "f"))
duplicate := FlagAlias("--colour", "--color")
app, err := New(Identity{Name: "Example"}, command, flagAliasCommand("plain", stringFlag("color", "c")), duplicate, FlagAlias("--colour", "--color"),
FlagAliasFor("inspect", "--colour", "--format"))
if err != nil {
t.Fatalf("identical duplicates and scoped override were rejected: %v", err)
}
if got := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--format" {
t.Fatalf("scoped alias target = %s, want --format", got)
}
if got := app.flagAliases[commandpath.Path{"plain"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--color" {
t.Fatalf("effective global alias target = %s, want --color", got)
}
for name, rules := range map[string][]RoutingRule{
"competing global targets": {
FlagAlias("--colour", "--color"),
FlagAlias("--colour", "--format"),
},
"competing scoped targets": {
FlagAliasFor("inspect", "--colour", "--color"),
FlagAliasFor("inspect", "--colour", "--format"),
},
"unknown scope": {FlagAliasFor("missing", "--colour", "--color")},
"missing scoped destination": {FlagAliasFor("inspect", "--colour", "--missing")},
} {
t.Run(name, func(t *testing.T) {
declarations := []Declaration{command}
for _, rule := range rules {
declarations = append(declarations, rule)
}
if _, err := New(Identity{Name: "Example"}, declarations...); err == nil {
t.Fatal("New() accepted invalid flag alias rules")
}
})
}
}
func TestFlagAliasCyclesIdentifyParticipatingSpellings(t *testing.T) {
for name, declarations := range map[string][]Declaration{
"global": {
FlagAlias("--first", "--second"),
FlagAlias("--second", "--first"),
},
"scoped": {
flagAliasCommand("inspect"),
FlagAliasFor("inspect", "-a", "--second"),
FlagAliasFor("inspect", "--second", "-a"),
},
} {
t.Run(name, func(t *testing.T) {
_, err := New(Identity{Name: "Example"}, declarations...)
if err == nil {
t.Fatal("New() accepted a flag alias cycle")
}
message := err.Error()
for _, spelling := range []string{"--first", "--second"} {
if name == "scoped" {
continue
}
if !strings.Contains(message, spelling) {
t.Errorf("cycle error %q does not identify %s", message, spelling)
}
}
if name == "scoped" && (!strings.Contains(message, "-a") || !strings.Contains(message, "--second")) {
t.Errorf("scoped cycle error %q does not identify both spellings", message)
}
})
}
}
func TestInactiveGlobalAliasesActivateAtomicallyWhenDestinationAppears(t *testing.T) {
command := flagAliasCommand("inspect", stringFlag("color", "c"))
app, err := New(Identity{Name: "Example"}, command,
FlagAlias("--color", "--missing"),
FlagAlias("--colour", "--color"),
)
if err != nil {
t.Fatal(err)
}
aliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling
if _, active := aliases[flagSpellingKey(capturedFlagSpelling{name: "color"})]; active {
t.Fatal("global alias with an unavailable destination became active")
}
if got := aliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--color" {
t.Fatalf("--colour endpoint = %s, want canonical --color", got)
}
snapshot := app.routeSnapshot()
commands := len(app.commands)
rules := len(app.routingRules)
originalAlias := aliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})]
if err := app.Register(flagAliasCommand("later", stringFlag("color", "c"), stringFlag("missing", ""))); err == nil {
t.Fatal("Register() accepted a newly active global source collision")
}
currentAlias := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})]
if app.routeSnapshot() != snapshot || len(app.commands) != commands || len(app.routingRules) != rules || currentAlias != originalAlias {
t.Fatal("failed registration changed the existing command or routing state")
}
}
func capturedSpelling(token string) capturedFlagSpelling {
issues := []error(nil)
return captureFlagSpelling("test", token, &issues)
}

96
flag_values.go Normal file
View File

@@ -0,0 +1,96 @@
package commandkit
import (
"errors"
"fmt"
)
// ErrFlagInput identifies a missing required captured flag selected for an input.
var ErrFlagInput = errors.New("required flag input is missing")
// ErrFlagWiring identifies an invalid captured binding or destination.
var ErrFlagWiring = errors.New("flag wiring is invalid")
// FlagValues stores invocation-local converted values and presence for captured
// flags. It does not retain an input instance; values can be applied to
// independently initialized destinations.
type FlagValues[I any] struct {
values map[*bindingIdentity]preparedValue[I]
}
// CaptureFlags adapts flags declared for I into flags on Outer. During parsing,
// each adapted flag stores its prepared value in target, associated with the
// original inner binding identity. Copies of an original handle remain
// selectable; an independently constructed lookalike does not.
func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues[I]) []Flag[Outer] {
captured := make([]Flag[Outer], len(flags))
for index, original := range flags {
inner := original
identity := newBindingIdentity()
binding := flagBinding[Outer]{
identity: identity,
meta: inner.binding.meta,
deferredRequired: inner.binding.meta.Required,
defaultText: inner.binding.defaultText,
repeated: inner.binding.repeated,
boolean: inner.binding.boolean,
}
if target != nil && inner.binding.identity != nil && inner.binding.valid && inner.binding.prepare != nil {
binding.valid = true
binding.prepare = func(raw []string) (preparedValue[Outer], error) {
prepared, err := inner.binding.prepare(raw)
if err != nil {
return preparedValue[Outer]{}, err
}
return preparedValue[Outer]{
identity: identity,
supplied: prepared.supplied,
apply: func(outer *Outer) {
store := target(outer)
if store == nil {
return
}
if store.values == nil {
store.values = make(map[*bindingIdentity]preparedValue[I])
}
store.values[inner.binding.identity] = prepared
},
}, nil
}
}
captured[index] = Flag[Outer]{binding: binding}
}
return captured
}
// ApplyTo applies selected original captured flags to an initialized
// destination. A missing captured binding or nil destination matches
// ErrFlagWiring. A required selected value that was not supplied matches
// ErrFlagInput. Repeated core-owned values are copied for each application.
func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error {
if target == nil {
return fmt.Errorf("%w: destination is nil", ErrFlagWiring)
}
for _, flag := range selected {
binding := flag.binding
if binding.identity == nil {
return fmt.Errorf("%w: selected flag has no identity", ErrFlagWiring)
}
prepared, exists := values.values[binding.identity]
if !exists {
return fmt.Errorf("%w: flag --%s was not captured", ErrFlagWiring, binding.meta.Name)
}
if binding.meta.Required && !prepared.supplied {
return fmt.Errorf("flag --%s: %w", binding.meta.Name, ErrFlagInput)
}
prepared.apply(target)
}
return nil
}
func classifyInputError(err error) Outcome {
if errors.Is(err, ErrFlagWiring) {
return Failure(err.Error()).WithCause(err)
}
return Usage(err.Error()).WithCause(err)
}

144
flag_values_test.go Normal file
View File

@@ -0,0 +1,144 @@
package commandkit
import (
"errors"
"fmt"
"testing"
)
type capturedInput struct {
name Supplied[string]
tags Supplied[[]string]
}
type capturedOuter struct {
flags FlagValues[capturedInput]
}
func TestCaptureFlagsAppliesOnlyOriginalBindingWithoutReconversion(t *testing.T) {
parseCalls := 0
original := ValueFlag(FlagMeta{Name: "name"}, nil, func(value string) (string, error) {
parseCalls++
return value, nil
}, func(input *capturedInput, value Supplied[string]) { input.name = value })
copied := original
captured := CaptureFlags([]Flag[capturedInput]{original}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags })
prepared, err := captured[0].binding.prepare([]string{"chosen"})
if err != nil {
t.Fatal(err)
}
outer := capturedOuter{}
prepared.apply(&outer)
var target capturedInput
if err := outer.flags.ApplyTo(&target, copied); err != nil {
t.Fatal(err)
}
if target.name.Value != "chosen" || !target.name.Set || parseCalls != 1 {
t.Fatalf("target=%#v parse calls=%d", target, parseCalls)
}
lookalike := ValueFlag(FlagMeta{Name: "name"}, nil, func(value string) (string, error) { return value, nil }, func(*capturedInput, Supplied[string]) {})
if err := outer.flags.ApplyTo(&target, lookalike); !errors.Is(err, ErrFlagWiring) {
t.Fatalf("lookalike binding error = %v", err)
}
if err := outer.flags.ApplyTo(nil, copied); !errors.Is(err, ErrFlagWiring) {
t.Fatalf("nil target error = %v", err)
}
if err := (FlagValues[capturedInput]{}).ApplyTo(&target); err != nil {
t.Fatalf("empty selection from zero store: %v", err)
}
}
func TestCaptureFlagsDefersRequiredCheckToSelectedBindings(t *testing.T) {
required := ValueFlag(FlagMeta{Name: "name", Required: true}, strptr("default"), func(value string) (string, error) { return value, nil }, func(input *capturedInput, value Supplied[string]) { input.name = value })
captured := CaptureFlags([]Flag[capturedInput]{required}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags })
prepared, err := captured[0].binding.prepare(nil)
if err != nil {
t.Fatal(err)
}
outer := capturedOuter{}
prepared.apply(&outer)
var target capturedInput
if err := outer.flags.ApplyTo(&target); err != nil {
t.Fatalf("unselected required flag failed: %v", err)
}
if err := outer.flags.ApplyTo(&target, required); !errors.Is(err, ErrFlagInput) {
t.Fatalf("selected missing required flag error = %v", err)
}
optional := ValueFlag(FlagMeta{Name: "name"}, strptr("default"), func(value string) (string, error) { return value, nil }, func(input *capturedInput, value Supplied[string]) { input.name = value })
optionalCapture := CaptureFlags([]Flag[capturedInput]{optional}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags })
optionalPrepared, err := optionalCapture[0].binding.prepare(nil)
if err != nil {
t.Fatal(err)
}
outer = capturedOuter{}
optionalPrepared.apply(&outer)
target = capturedInput{}
if err := outer.flags.ApplyTo(&target, optional); err != nil {
t.Fatal(err)
}
if target.name.Value != "default" || target.name.Set {
t.Fatalf("captured default value = %#v", target.name)
}
badCapture := CaptureFlags[capturedOuter]([]Flag[capturedInput]{required}, nil)
command := NewCommand(CommandSpec[capturedOuter]{Name: "broken", Flags: badCapture})
if _, err := New(Identity{Name: "example"}, command); err == nil {
t.Fatal("installation accepted a nil captured-flag accessor")
}
}
func TestCaptureFlagsCopiesRepeatedValuesOnEachApplication(t *testing.T) {
original := RepeatFlag(FlagMeta{Name: "tag"}, nil, func(value string) (string, error) { return value, nil }, func(input *capturedInput, value Supplied[[]string]) { input.tags = value })
captured := CaptureFlags([]Flag[capturedInput]{original}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags })
prepared, err := captured[0].binding.prepare([]string{"a,b", "c"})
if err != nil {
t.Fatal(err)
}
outer := capturedOuter{}
prepared.apply(&outer)
var first, second capturedInput
if err := outer.flags.ApplyTo(&first, original); err != nil {
t.Fatal(err)
}
if err := outer.flags.ApplyTo(&second, original); err != nil {
t.Fatal(err)
}
first.tags.Value[0] = "changed"
if second.tags.Value[0] != "a,b" || second.tags.Value[1] != "c" || !second.tags.Set {
t.Fatalf("second application shares repeat storage: %#v", second.tags)
}
}
func TestCapturedFlagErrorClassificationPreservesWrappedCategories(t *testing.T) {
if got := classifyInputError(fmt.Errorf("scope input: %w", ErrFlagWiring)); got.Kind != OutcomeOperational {
t.Fatalf("wiring outcome = %#v", got)
}
if got := classifyInputError(fmt.Errorf("scope input: %w", ErrFlagInput)); got.Kind != OutcomeUsage {
t.Fatalf("input outcome = %#v", got)
}
}
func TestCheckInputClassifiesWrappedFlagWiringAsOperational(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
CheckInput: func(struct{}) error { return fmt.Errorf("scope selector: %w", ErrFlagWiring) },
Run: func(Invocation, struct{}) Outcome { t.Fatal("run called after wiring error"); return Success() },
})
app, err := New(Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
if outcome := app.Execute(t.Context(), []string{"run"}, Streams{}); outcome.Kind != OutcomeOperational || !errors.Is(outcome.Cause, ErrFlagWiring) {
t.Fatalf("wrapped wiring outcome = %#v", outcome)
}
usage := NewCommand(CommandSpec[struct{}]{Name: "usage", CheckInput: func(struct{}) error { return fmt.Errorf("selected value: %w", ErrFlagInput) }, Run: func(Invocation, struct{}) Outcome { return Success() }})
app, err = New(Identity{Name: "example"}, usage)
if err != nil {
t.Fatal(err)
}
if outcome := app.Execute(t.Context(), []string{"usage"}, Streams{}); outcome.Kind != OutcomeUsage || !errors.Is(outcome.Cause, ErrFlagInput) {
t.Fatalf("wrapped input outcome = %#v", outcome)
}
}
func strptr(value string) *string { return &value }

8
go.mod
View File

@@ -3,22 +3,16 @@ module gitea.maximumdirect.net/eric/CommandKit
go 1.26.0 go 1.26.0
require ( require (
gitea.maximumdirect.net/eric/ConfigKit v0.5.0
gitea.maximumdirect.net/eric/promptkit v0.12.0
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.9 github.com/spf13/pflag v1.0.9
go.yaml.in/yaml/v4 v4.0.0-rc.6 go.yaml.in/yaml/v4 v4.0.0-rc.6
) )
require ( require (
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0 // indirect gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 // indirect
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0 // indirect
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/yuin/goldmark/v2 v2.1.6 // indirect github.com/yuin/goldmark/v2 v2.1.6 // indirect
golang.org/x/mod v0.40.0 // indirect golang.org/x/mod v0.40.0 // indirect
golang.org/x/text v0.14.0 // indirect
) )
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit

19
go.sum
View File

@@ -1,21 +1,9 @@
gitea.maximumdirect.net/eric/ConfigKit v0.5.0 h1:2KwG10+kApWM3avOoYA+p768xcbHUp7tQ2rqizBNpyg= gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 h1:P2eDQ29BgyYqHnaz5MV+vzOk7r3s8py2y9k8JISFtJ0=
gitea.maximumdirect.net/eric/ConfigKit v0.5.0/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk= gitea.maximumdirect.net/eric/ReleaseKit v0.4.2/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0 h1:aTMn+50MEpHpHI2RoCCmJYsy7Ohrpa+RSva63Kq0/tU=
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
gitea.maximumdirect.net/eric/promptkit v0.12.0 h1:oIFWEg6GPxtmX3Plqv0euP+FnK1LCk/t4xDy6oKCpP4=
gitea.maximumdirect.net/eric/promptkit v0.12.0/go.mod h1:pFOOy5v1zH17stEi9rmHYmfbuLjnfVMcUhfmk+4VtJY=
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0 h1:lzfCDbzJQRGzIlRPRprnAV9hVVL5zUQy80VMQ0kbYa0=
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0/go.mod h1:HjS0f+3q0IdHCTEz+VVAEgIwIWJFAZUjuKdAtvtqKdg=
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0 h1:h12ZTRldAmpLAomj0oQRGteFxHLN1LPE6XDl9zJNbm8=
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0/go.mod h1:OEFBeKdjTsLzqJwLmXI2O5gG3r64FktcVJVb3mBrRfg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
@@ -26,8 +14,5 @@ go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

63
help_view.go Normal file
View File

@@ -0,0 +1,63 @@
package commandkit
import (
"sort"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
func (a *App) helpView(route routeResult, snapshot *routingSnapshot) commandmeta.HelpView {
view := commandmeta.HelpView{
EnteredPath: clonePath(route.enteredPath),
CanonicalPath: clonePath(route.canonicalPath),
Discovery: route.kind == routeDiscovery,
CoreFlags: commandmeta.ReportingFlags(),
}
if snapshot == nil {
return view
}
view.CommandAliases = routeHelpAliases(snapshot, route)
if view.Discovery {
view.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords()
return view
}
view.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
return view
}
func clonePath(path commandpath.Path) []string {
return append([]string(nil), path...)
}
func routeHelpAliases(snapshot *routingSnapshot, route routeResult) []commandmeta.CommandAlias {
if route.kind != routeDiscovery && len(route.canonicalPath) == 0 {
return append([]commandmeta.CommandAlias(nil), snapshot.commandAliases...)
}
bySource := make(map[string]commandmeta.CommandAlias)
for _, prefix := range snapshot.aliases.ResolutionPaths(route.enteredPath) {
for _, alias := range snapshot.commandAliases {
source := commandpath.FromTokens(alias.Source)
if len(source) <= len(prefix) || !source.HasPrefix(prefix) {
continue
}
suffix := source[len(prefix):]
entered := append(route.enteredPath.Clone(), suffix...)
target := snapshot.aliases.Resolve(entered)
if traceCanonicalPath(snapshot.canonical, target).exact == nil {
continue
}
bySource[suffix.Key()] = commandmeta.CommandAlias{Source: clonePath(suffix), Target: clonePath(target)}
}
}
keys := make([]string, 0, len(bySource))
for key := range bySource {
keys = append(keys, key)
}
sort.Strings(keys)
result := make([]commandmeta.CommandAlias, 0, len(keys))
for _, key := range keys {
result = append(result, bySource[key])
}
return result
}

View File

@@ -18,8 +18,11 @@ type Identity struct {
Revision string Revision string
} }
// Streams contains the output destinations associated with a command // Streams contains the caller-owned output destinations associated with an
// invocation. // invocation. Out receives substantive results; Err receives messages,
// terminal explanations, usage guidance, and CommandKit diagnostics. Nil
// destinations are replaced with io.Discard. CommandKit does not close or flush
// supplied writers.
type Streams struct { type Streams struct {
// Out receives help and command reports. // Out receives help and command reports.
Out io.Writer Out io.Writer
@@ -27,11 +30,41 @@ type Streams struct {
Err io.Writer Err io.Writer
} }
// Invocation provides execution context and output streams to a command func normalizeStreams(streams Streams) Streams {
// handler. if streams.Out == nil {
streams.Out = io.Discard
}
if streams.Err == nil {
streams.Err = io.Discard
}
return streams
}
// Invocation provides execution context and invocation-scoped reporting to a
// command handler. Execute finalizes its reporter before returning. For command
// aliases, EnteredPath and CanonicalPath expose the route the caller used and
// the selected canonical command path. Both omit the executable name, flags,
// and operands.
type Invocation struct { type Invocation struct {
// Context carries caller cancellation and request-scoped values. // Context carries caller cancellation and request-scoped values.
Context context.Context Context context.Context
// Streams are the caller-selected output destinations.
Streams Streams reporter *Reporter
enteredPath string
canonicalPath string
} }
// Reporter returns the reporting facility supplied for this invocation. A
// manually constructed Invocation has no reporter.
func (inv Invocation) Reporter() *Reporter { return inv.reporter }
// EnteredPath returns the command path used to invoke the selected command,
// excluding the executable name, flags, and operands. It is empty for an
// Invocation that was not created by command execution.
func (inv Invocation) EnteredPath() string { return inv.enteredPath }
// CanonicalPath returns the selected command's canonical path after resolving
// aliases, excluding the executable name, flags, and operands. It equals
// EnteredPath for a direct canonical invocation and is empty for an Invocation
// that was not created by command execution.
func (inv Invocation) CanonicalPath() string { return inv.canonicalPath }

View File

@@ -0,0 +1,20 @@
CommandKit ckconfig
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
This library is free software: you can redistribute it and/or modify it
under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
The GNU Lesser General Public License version 3 and its incorporated
GNU General Public License version 3 text are provided in LICENSE.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
Third-party code and assets are subject to their respective license terms.

View File

@@ -0,0 +1,304 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License.
"The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version".
The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version:
a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following:
a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license document.
c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.
1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version.
e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library.
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
CommandKit
Copyright (C) 2026 eric
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
CommandKit Copyright (C) 2026 eric
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <http://www.gnu.org/philosophy/why-not-lgpl.html>.

View File

@@ -0,0 +1,45 @@
# CommandKit ckconfig
`ckconfig` is the optional ConfigKit integration for CommandKit. It adapts
ConfigKit loaders and scopes into reusable `commandkit.Command` declarations;
installing the integration is explicit and does not modify an application by
itself.
## Module
The module path is
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig`. Its first
release is prepared as `v0.1.0`. It uses the core `commandkit` package and
ConfigKit; it does not import PromptKit. See the
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
for exact callback and lifetime contracts.
## Example
For one application configuration scope, install the convenience declaration:
```go
app, err := commandkit.New(identity, ckconfig.AppCommand(loadAppConfig))
```
Use `ckconfig.Command(ckconfig.Suite[Input, State]{...})` to define named
scopes, selectors, input checks, and state shared by the selected acquisitions
or comparison in a config invocation. `ckconfig.FromConfigKit` adapts a typed
ConfigKit loader. The `config` command family exposes scope discovery and the
validate, show, sources, explain, and diff operations supported by the declared
scopes. Source reports include native destination, placement, unresolved
destination, and source version-check evidence. Comparison returns ConfigKit's
native records to the YAML report path; the
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#compare-configuration-selections)
owns its operator details.
The maintained [simple example](examples/simple) shows a complete consumer.
The [CommandKit CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#configuration-scopes)
describes its operator-facing behavior.
## License and attribution
This module is licensed under the [GNU Lesser General Public License v3.0 or
later](COPYRIGHT). The accompanying [LICENSE](LICENSE) contains the full LGPL
v3 and GPL v3 texts. See [third-party attribution](docs/oss/attribution.md)
for incorporated and dependency notices.

View File

@@ -0,0 +1,334 @@
package ckconfig
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type adapterSettings struct {
Greeting string `yaml:"greeting"`
Secret string `yaml:"secret"`
}
func adapterDefinition() configkit.Definition[adapterSettings] {
return configkit.Definition[adapterSettings]{
Defaults: func() adapterSettings { return adapterSettings{Greeting: "Hello"} },
Validate: func(value *adapterSettings, validation *configkit.Validation) {
if value.Greeting == "Hello" {
validation.Warning(configkit.Keys("greeting"), "using the default greeting")
}
},
Secret: func(path configkit.Path) bool { return path.String() == "/secret" },
}
}
func adapterSettingsLoad(source configkit.Source) (*configkit.Result[adapterSettings], error) {
return configkit.Load(source, configkit.Options[adapterSettings]{Definition: adapterDefinition()})
}
func comparisonSide(selection Selection, greeting string) ComparisonSide[struct{ Label string }] {
return ComparisonSide[struct{ Label string }]{
Selection: selection,
Input: struct{ Label string }{Label: greeting},
}
}
func TestFromConfigKitCompareReturnsNativeChangesAndLoadsEqualSelections(t *testing.T) {
selection := Selection{Config: commandkit.Supplied[string]{Value: "same.yml", Set: true}}
var calls []struct {
selection Selection
input struct{ Label string }
state *int
}
scope := FromConfigKit[struct{ Label string }, *int, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, input struct{ Label string }, state *int) (*configkit.Result[adapterSettings], error) {
calls = append(calls, struct {
selection Selection
input struct{ Label string }
state *int
}{selected, input, state})
*state++
return adapterSettingsLoad(configkit.Mapping(input.Label, map[string]any{"greeting": input.Label}))
})
state := new(int)
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{ Label string }]{
Left: comparisonSide(selection, "before"),
Right: comparisonSide(selection, "after"),
}, state)
if err != nil {
t.Fatal(err)
}
if len(calls) != 2 || calls[0].selection != selection || calls[1].selection != selection {
t.Fatalf("loader calls = %#v", calls)
}
if calls[0].input.Label != "before" || calls[1].input.Label != "after" || calls[0].state != state || calls[1].state != state || *state != 2 {
t.Fatalf("loader inputs/state = %#v, %#v, state=%d", calls[0], calls[1], *state)
}
if len(acquired.Value) != 1 || acquired.Value[0].Path.String() != "/greeting" || acquired.Value[0].Left.YAML != "before\n" || acquired.Value[0].Right.YAML != "after\n" {
t.Fatalf("native differences = %#v", acquired.Value)
}
equal, err := scope.Compare(context.Background(), ComparisonRequest[struct{ Label string }]{
Left: comparisonSide(selection, "same"),
Right: comparisonSide(selection, "same"),
}, state)
if err != nil {
t.Fatal(err)
}
if len(equal.Value) != 0 || len(calls) != 4 {
t.Fatalf("equal comparison records/calls = %#v / %d", equal.Value, len(calls))
}
}
func TestFromConfigKitCompareIgnoresDefaultAndResolutionHistoryForEqualValues(t *testing.T) {
type resolvedSettings struct {
Greeting string `yaml:"greeting"`
}
definition := configkit.Definition[resolvedSettings]{
Defaults: func() resolvedSettings { return resolvedSettings{Greeting: "resolved"} },
Resolve: func(value *resolvedSettings, resolution *configkit.Resolution) error {
if value.Greeting != "resolved" {
value.Greeting = "resolved"
resolution.Record(configkit.Keys("greeting"), configkit.ChangeNormalization, "canonical greeting")
}
return nil
},
}
left, err := configkit.Load(configkit.NoSource(), configkit.Options[resolvedSettings]{Definition: definition})
if err != nil {
t.Fatal(err)
}
right, err := configkit.Load(configkit.Mapping("right-source", map[string]any{"greeting": "source value"}), configkit.Options[resolvedSettings]{Definition: definition})
if err != nil {
t.Fatal(err)
}
scope := FromConfigKit[struct{}, struct{}, resolvedSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[resolvedSettings], error) {
if selected.Config.Value == "left" {
return left, nil
}
return right, nil
})
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if err != nil {
t.Fatal(err)
}
if left.Config != right.Config || left.Config.Greeting != "resolved" || len(acquired.Value) != 0 {
t.Fatalf("equal resolved values yielded differences: left=%#v right=%#v records=%#v", left.Config, right.Config, acquired.Value)
}
}
func TestFromConfigKitCompareUsesBaseAndOverlaySelections(t *testing.T) {
dir := t.TempDir()
basePath := filepath.Join(dir, "base.yml")
overlayPath := filepath.Join(dir, "overlays", "testing.yml")
if err := os.WriteFile(basePath, []byte("greeting: Hello\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Dir(overlayPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(overlayPath, []byte("greeting: Goodbye\n"), 0o600); err != nil {
t.Fatal(err)
}
loader := func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
options := configkit.LayeredOptions[adapterSettings]{Definition: adapterDefinition()}
if selection.Overlay.Set {
options.Overlay = selection.Overlay.Value
options.OverlayFilesystem = &configkit.FilesystemContext{Root: dir, BaseDir: dir}
}
return configkit.LoadLayered(configkit.Input{Source: configkit.File(selection.Config.Value)}, options)
}
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", loader)
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: basePath, Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{
Config: commandkit.Supplied[string]{Value: basePath, Set: true},
Overlay: commandkit.Supplied[string]{Value: "testing", Set: true},
}},
}, struct{}{})
if err != nil {
t.Fatal(err)
}
if len(acquired.Value) != 1 || acquired.Value[0].Path.String() != "/greeting" || acquired.Value[0].Left.YAML != "Hello\n" || acquired.Value[0].Right.YAML != "Goodbye\n" {
t.Fatalf("base/overlay differences = %#v", acquired.Value)
}
}
func TestFromConfigKitCompareRetainsSideDiagnosticsAndLoadError(t *testing.T) {
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
_, loadErr := configkit.Load(configkit.Mapping("right-source", map[string]any{"unknown": true}), configkit.Options[adapterSettings]{Definition: adapterDefinition()})
if loadErr == nil {
t.Fatal("invalid right source unexpectedly loaded")
}
wrapper := fmt.Errorf("application loader context: %w", loadErr)
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selected.Config.Value == "left" {
return left, nil
}
return nil, wrapper
})
acquired, gotErr := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if !errors.Is(gotErr, loadErr) {
t.Fatalf("load error identity not preserved: %v", gotErr)
}
var typed *configkit.LoadError
if !errors.As(gotErr, &typed) || typed != loadErr {
t.Fatalf("load error type not preserved: %#v", typed)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || len(acquired.Explanation.Details) != 1 {
t.Fatalf("retained diagnostics/explanation = %#v / %#v", acquired.Diagnostics, acquired.Explanation)
}
var leftText, rightText bytes.Buffer
if err := acquired.Diagnostics[0].Write(&leftText); err != nil {
t.Fatal(err)
}
if err := acquired.Explanation.Details[0](&rightText); err != nil {
t.Fatal(err)
}
if !strings.Contains(leftText.String(), `Configuration scope "app" left:`) || !strings.Contains(leftText.String(), `"left-source"`) {
t.Fatalf("left diagnostic lost side/source identity: %q", leftText.String())
}
if !strings.Contains(rightText.String(), `Configuration scope "app" right:`) || !strings.Contains(rightText.String(), `"right-source"`) {
t.Fatalf("right diagnostic lost side/source identity: %q", rightText.String())
}
}
func TestFromConfigKitCompareStopsAfterLeftLoadFailure(t *testing.T) {
_, loadErr := configkit.Load(configkit.Mapping("left-source", map[string]any{"unknown": true}), configkit.Options[adapterSettings]{Definition: adapterDefinition()})
if loadErr == nil {
t.Fatal("invalid left source unexpectedly loaded")
}
rightCalled := false
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selected.Config.Value == "left" {
return nil, loadErr
}
rightCalled = true
return adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"greeting": "Goodbye"}))
})
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if !errors.Is(err, loadErr) || rightCalled {
t.Fatalf("left failure/right load = %v / %v", err, rightCalled)
}
var typed *configkit.LoadError
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Explanation.Details) != 1 {
t.Fatalf("left failure identity/explanation = %v / %#v", err, acquired.Explanation)
}
var terminal bytes.Buffer
if err := acquired.Explanation.Details[0](&terminal); err != nil {
t.Fatal(err)
}
if !strings.Contains(terminal.String(), `Configuration scope "app" left:`) || !strings.Contains(terminal.String(), `"left-source"`) {
t.Fatalf("left terminal context lost side/source identity: %q", terminal.String())
}
}
func TestFromConfigKitCompareCancellationStopsBeforeRightLoad(t *testing.T) {
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
rightCalled := false
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selection.Config.Value == "left" {
cancel()
return left, nil
}
rightCalled = true
return adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"greeting": "Goodbye"}))
})
acquired, err := scope.Compare(ctx, ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if !errors.Is(err, context.Canceled) || rightCalled {
t.Fatalf("cancellation/right load = %v / %v", err, rightCalled)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("left diagnostics lost on cancellation: %#v", acquired.Diagnostics)
}
}
func TestFromConfigKitCompareRejectsNilResultAndRetainsNativeDiffFailureDiagnostics(t *testing.T) {
t.Run("nil result", func(t *testing.T) {
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
return nil, nil
})
_, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{}, struct{}{})
if err == nil || !strings.Contains(err.Error(), `"app" left`) || !strings.Contains(err.Error(), "nil result") {
t.Fatalf("nil left result error = %v", err)
}
})
t.Run("native diff error", func(t *testing.T) {
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selection.Config.Value == "left" {
return left, nil
}
return &configkit.Result[adapterSettings]{}, nil
})
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if err == nil || !strings.Contains(err.Error(), "native diff") {
t.Fatalf("native diff error = %v", err)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("native diff failure lost diagnostics: %#v", acquired.Diagnostics)
}
})
}
func TestFromConfigKitComparePreservesConcealedNativeDifference(t *testing.T) {
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"secret": "old"}))
if err != nil {
t.Fatal(err)
}
right, err := adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"secret": "new"}))
if err != nil {
t.Fatal(err)
}
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selected.Config.Value == "left" {
return left, nil
}
return right, nil
})
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if err != nil {
t.Fatal(err)
}
if len(acquired.Value) != 1 || !acquired.Value[0].HiddenChanged || !acquired.Value[0].Left.Redacted || !acquired.Value[0].Right.Redacted || acquired.Value[0].Left.YAML != acquired.Value[0].Right.YAML {
t.Fatalf("concealed native difference was altered: %#v", acquired.Value)
}
}

View File

@@ -0,0 +1,105 @@
package ckconfig
import (
"context"
"errors"
"io"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestComparisonCapabilitiesAreIndependentAndCaptured(t *testing.T) {
type input struct{ Value string }
var inputCalls, stateCalls, acquireCalls, compareCalls int
compare := func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
compareCalls++
return commandkit.Acquired[[]configkit.Difference]{}, nil
}
operations := []Operation{Diff}
suite := Suite[input, struct{}]{
NewInput: func() input { inputCalls++; return input{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Scopes: []Scope[input, struct{}]{
{Name: "app", Operations: operations, Compare: compare},
{Name: "promptkit", Operations: []Operation{Diff, Show, Validate}, Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, nil
}, Compare: compare},
{Name: "alpha"},
{Name: "plain", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, nil
}},
},
}
captured := captureSuite(suite)
declaration := Command(suite)
// Capability metadata and callback references are copied during capture.
suite.Scopes[0].Operations[0] = Show
suite.Scopes[0].Compare = nil
app := newConfigTestApp(t, declaration)
var output strings.Builder
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("scope discovery outcome = %#v", outcome)
}
if got, want := output.String(), "app: diff\npromptkit: validate, show, diff\nalpha: \nplain: validate\n"; got != want {
t.Fatalf("scope discovery = %q, want %q", got, want)
}
// A family-wide diff command is available, but unsupported scopes fail before
// suite factories or either provider runs.
outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "plain"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage {
t.Fatalf("unsupported scope outcome = %#v", outcome)
}
if inputCalls != 0 || stateCalls != 0 || acquireCalls != 0 || compareCalls != 0 {
t.Fatalf("discovery or preflight invoked suite work: input=%d state=%d acquire=%d compare=%d", inputCalls, stateCalls, acquireCalls, compareCalls)
}
if captured.byName["app"].scope.Compare == nil {
t.Fatal("captured comparison callback was cleared by a source edit")
}
if _, err := captured.byName["app"].scope.Compare(context.Background(), ComparisonRequest[input]{}, struct{}{}); err != nil || compareCalls != 1 {
t.Fatalf("captured comparison callback result=%v calls=%d", err, compareCalls)
}
withoutComparison := newConfigTestApp(t, Command(Suite[input, struct{}]{Scopes: []Scope[input, struct{}]{{
Name: "plain", Operations: []Operation{Validate},
Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{}, nil
},
}}}))
outcome = withoutComparison.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage {
t.Fatalf("family without comparison exposed diff: %#v", outcome)
}
}
func TestComparisonCallbackWiringAndRetainedAdapterIssues(t *testing.T) {
newApp := func(scope Scope[struct{}, struct{}]) error {
_, err := commandkit.New(commandkit.Identity{Name: "example"}, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
return err
}
if err := newApp(Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Diff}}); err == nil {
t.Fatalf("missing comparison callback error = %v", err)
}
if err := newApp(Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Validate}}); err == nil {
t.Fatalf("missing ordinary acquisition callback error = %v", err)
}
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "Application configuration", nil)
scope.Operations = []Operation{Diff}
scope.Compare = func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
}
retained := scope.issues[0]
if err := newApp(scope); err == nil || !errors.Is(err, retained) {
t.Fatalf("narrowed adapter lost its retained loader issue: %v", err)
}
}

View File

@@ -0,0 +1,183 @@
package ckconfig
import (
"context"
"io"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestComparisonSelectionInheritsFieldsIndependently(t *testing.T) {
leftConfig := commandkit.Supplied[string]{Value: "before.yml", Set: true}
leftOverlay := commandkit.Supplied[string]{Value: "base", Set: true}
tests := []struct {
name string
left Selection
right Selection
want Selection
}{
{
name: "omitted fields stay omitted",
},
{
name: "both fields inherit supplied state",
left: Selection{Config: leftConfig, Overlay: leftOverlay},
want: Selection{Config: leftConfig, Overlay: leftOverlay},
},
{
name: "explicit empty config overrides while overlay inherits",
left: Selection{Config: leftConfig, Overlay: leftOverlay},
right: Selection{Config: commandkit.Supplied[string]{Set: true}},
want: Selection{Config: commandkit.Supplied[string]{Set: true}, Overlay: leftOverlay},
},
{
name: "explicit overlay overrides while config inherits",
left: Selection{Config: leftConfig, Overlay: leftOverlay},
right: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}},
want: Selection{Config: leftConfig, Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}},
},
{
name: "explicit empty overlay remains supplied",
left: Selection{Config: leftConfig, Overlay: leftOverlay},
right: Selection{Overlay: commandkit.Supplied[string]{Set: true}},
want: Selection{Config: leftConfig, Overlay: commandkit.Supplied[string]{Set: true}},
},
{
name: "both explicit right fields replace left",
left: Selection{Config: leftConfig, Overlay: leftOverlay},
right: Selection{Config: commandkit.Supplied[string]{Value: "after.yml", Set: true}, Overlay: commandkit.Supplied[string]{Value: "new", Set: true}},
want: Selection{Config: commandkit.Supplied[string]{Value: "after.yml", Set: true}, Overlay: commandkit.Supplied[string]{Value: "new", Set: true}},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := inheritComparisonSelection(test.left, test.right); got != test.want {
t.Fatalf("inherited selection = %#v, want %#v", got, test.want)
}
})
}
}
func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) {
scope := Scope[struct{}, struct{}]{
Name: "app", Operations: []Operation{Diff, Show, Explain, Validate, Sources},
Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{}, nil
},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
help := func(args ...string) string {
t.Helper()
var output strings.Builder
app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard})
return output.String()
}
diffHelp := help("config", "diff", "--help")
if !strings.Contains(diffHelp, "--against-config") || !strings.Contains(diffHelp, "--against-overlay") {
t.Fatalf("diff help omits right-side selection flags:\n%s", diffHelp)
}
if !strings.Contains(diffHelp, "default: yaml") {
t.Fatalf("diff help does not show the YAML default:\n%s", diffHelp)
}
for _, operation := range []string{"validate", "show", "sources", "explain"} {
operationHelp := help("config", operation, "--help")
if strings.Contains(operationHelp, "--against-config") || strings.Contains(operationHelp, "--against-overlay") {
t.Errorf("%s exposes comparison-only flags:\n%s", operation, operationHelp)
}
if operation == "show" && !strings.Contains(operationHelp, "default: yaml") {
t.Errorf("show format default changed:\n%s", operationHelp)
}
if operation != "show" && !strings.Contains(operationHelp, "default: text") {
t.Errorf("%s format default changed:\n%s", operation, operationHelp)
}
if operation != "explain" && strings.Contains(operationHelp, "--path") {
t.Errorf("%s exposes the explain-only path flag:\n%s", operation, operationHelp)
}
}
explainHelp := help("config", "explain", "--help")
if !strings.Contains(explainHelp, "--path") {
t.Fatalf("explain flags changed:\n%s", explainHelp)
}
}
func TestComparisonPreparationAcceptsOnlyYAML(t *testing.T) {
inputCalls, stateCalls := 0, 0
prepared := captureSuite(Suite[struct{}, struct{}]{
NewInput: func() struct{} { inputCalls++; return struct{}{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
})
input := &configCommandInput[struct{}, struct{}]{
selection: Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}},
against: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}},
format: commandkit.Supplied[string]{Value: "yaml", Set: true},
}
if err := prepareConfigOperation(prepared, Diff, input); err != nil {
t.Fatalf("explicit YAML format rejected: %v", err)
}
if input.plan == nil || input.plan.format != "yaml" {
t.Fatalf("comparison plan = %#v", input.plan)
}
if input.plan.scopeName != "app" {
t.Fatalf("comparison default scope = %q, want app", input.plan.scopeName)
}
if want := (Selection{
Config: commandkit.Supplied[string]{Value: "before.yml", Set: true},
Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true},
}); input.against != want {
t.Fatalf("resolved right selection = %#v, want %#v", input.against, want)
}
for _, format := range []string{"text", "json"} {
input := &configCommandInput[struct{}, struct{}]{format: commandkit.Supplied[string]{Value: format}}
if err := prepareConfigOperation(prepared, Diff, input); err == nil {
t.Errorf("comparison format %q was accepted", format)
}
}
if inputCalls != 2 || stateCalls != 0 {
t.Fatalf("comparison preparation calls input/state: %d/%d", inputCalls, stateCalls)
}
}
func TestComparisonAvailabilityPreflightPrecedesFactories(t *testing.T) {
inputCalls, stateCalls, acquireCalls, compareCalls := 0, 0, 0, 0
prepared := captureSuite(Suite[struct{}, struct{}]{
NewInput: func() struct{} { inputCalls++; return struct{}{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Scopes: []Scope[struct{}, struct{}]{
{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
compareCalls++
return commandkit.Acquired[[]configkit.Difference]{}, nil
}},
{Name: "plain", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, nil
}},
},
})
for _, scope := range []string{"missing", "all", "plain"} {
input := &configCommandInput[struct{}, struct{}]{
scope: commandkit.Supplied[string]{Value: scope, Set: true},
format: commandkit.Supplied[string]{Value: "yaml"},
}
if err := prepareConfigOperation(prepared, Diff, input); err == nil {
t.Errorf("comparison scope %q was accepted", scope)
}
}
if inputCalls != 0 || stateCalls != 0 || acquireCalls != 0 || compareCalls != 0 {
t.Fatalf("failed preflight invoked suite work: input=%d state=%d acquire=%d compare=%d", inputCalls, stateCalls, acquireCalls, compareCalls)
}
}

View File

@@ -0,0 +1,357 @@
package ckconfig
import (
"context"
"errors"
"io"
"strconv"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) {
type input struct {
Enabled commandkit.Supplied[bool]
Limit commandkit.Supplied[int]
Tags commandkit.Supplied[[]string]
}
var inputCalls, checkCalls, stateCalls int
var parseCalls int
var preparedInputs []*input
var selections []Selection
enabled := commandkit.BoolFlag[*input](commandkit.FlagMeta{Name: "enabled"}, true, func(value **input, supplied commandkit.Supplied[bool]) {
(*value).Enabled = supplied
})
limit := commandkit.ValueFlag[*input](commandkit.FlagMeta{Name: "limit", Required: true}, (*int)(nil), func(raw string) (int, error) {
parseCalls++
return strconv.Atoi(raw)
}, func(value **input, supplied commandkit.Supplied[int]) {
(*value).Limit = supplied
})
tags := commandkit.RepeatFlag[*input](commandkit.FlagMeta{Name: "tag"}, nil, func(raw string) (string, error) {
parseCalls++
return raw, nil
}, func(value **input, supplied commandkit.Supplied[[]string]) {
(*value).Tags = supplied
})
suite := Suite[*input, struct{}]{
NewInput: func() *input {
inputCalls++
return &input{}
},
NewState: func() struct{} {
stateCalls++
if checkCalls != 2 {
t.Errorf("shared state created after %d input checks", checkCalls)
}
if len(preparedInputs) != 2 || preparedInputs[0] == preparedInputs[1] {
t.Errorf("inputs were not initialized separately: %#v", preparedInputs)
} else {
left, right := preparedInputs[0].Tags.Value, preparedInputs[1].Tags.Value
if len(left) != 2 || len(right) != 2 || &left[0] == &right[0] {
t.Errorf("repeated values share storage: left=%#v right=%#v", left, right)
} else {
left[0] = "changed"
if right[0] != "alpha" {
t.Errorf("mutating one side changed the other: %#v", right)
}
}
}
return struct{}{}
},
Selectors: []Selector[*input]{{Binding: enabled}, {Binding: limit}, {Binding: tags}},
CheckInput: func(scope string, selection Selection, value *input) error {
checkCalls++
preparedInputs = append(preparedInputs, value)
selections = append(selections, selection)
if scope != "app" || value == nil {
return errors.New("unexpected scope input")
}
if !value.Enabled.Set || value.Enabled.Value {
return errors.New("explicit false was not preserved")
}
if !value.Limit.Set || value.Limit.Value != 0 {
return errors.New("explicit zero was not preserved")
}
if !value.Tags.Set || len(value.Tags.Value) != 2 || value.Tags.Value[0] != "alpha" || value.Tags.Value[1] != "beta" {
return errors.New("repeated input was not preserved")
}
return nil
},
Scopes: []Scope[*input, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[*input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
}
app := newConfigTestApp(t, Command(suite))
outcome := app.Execute(context.Background(), []string{
"config", "diff", "--config", "same.yml", "--against-config", "same.yml", "--overlay=",
"--enabled=false", "--limit=0", "--tag", "alpha", "--tag", "beta",
}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("valid comparison outcome = %#v", outcome)
}
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 {
t.Fatalf("preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
}
if parseCalls != 3 {
t.Fatalf("typed values were converted %d times, want once per supplied occurrence", parseCalls)
}
wantSelection := Selection{
Config: commandkit.Supplied[string]{Value: "same.yml", Set: true},
Overlay: commandkit.Supplied[string]{Set: true},
}
if len(selections) != 2 || selections[0] != wantSelection || selections[1] != wantSelection {
t.Fatalf("side selections = %#v, want equal %#v values", selections, wantSelection)
}
}
func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
type input struct{ ID int }
type state struct{ Label string }
inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls := 0, 0, 0, 0, 0
checked := make(map[Selection]input)
shared := &state{Label: "shared"}
var request ComparisonRequest[input]
app := newConfigTestApp(t, Command(Suite[input, *state]{
NewInput: func() input {
inputCalls++
return input{ID: inputCalls}
},
NewState: func() *state {
stateCalls++
if checkCalls != 2 {
t.Errorf("shared state created after %d input checks", checkCalls)
}
return shared
},
CheckInput: func(scope string, selection Selection, value input) error {
checkCalls++
if scope != "app" {
return errors.New("unexpected scope")
}
checked[selection] = value
return nil
},
Scopes: []Scope[input, *state]{{
Name: "app", Operations: []Operation{Show, Diff},
Acquire: func(context.Context, Request, input, *state) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, errors.New("ordinary acquisition reached during comparison")
},
Compare: func(_ context.Context, pair ComparisonRequest[input], value *state) (commandkit.Acquired[[]configkit.Difference], error) {
compareCalls++
if checkCalls != 2 || stateCalls != 1 || value != shared {
t.Errorf("provider reached with checks=%d states=%d shared=%t", checkCalls, stateCalls, value == shared)
}
request = pair
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
}))
outcome := app.Execute(context.Background(), []string{
"config", "diff", "--config", "before.yml", "--against-overlay=",
}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("comparison outcome = %#v", outcome)
}
left := Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}}
right := Selection{Config: left.Config, Overlay: commandkit.Supplied[string]{Set: true}}
if request.Left.Selection != left || request.Right.Selection != right {
t.Fatalf("provider selections = %#v", request)
}
if len(checked) != 2 || request.Left.Input != checked[left] || request.Right.Input != checked[right] {
t.Fatalf("provider inputs = %#v, checked = %#v", request, checked)
}
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || compareCalls != 1 || acquireCalls != 0 {
t.Fatalf("invocation calls input=%d check=%d state=%d compare=%d acquire=%d", inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls)
}
}
func TestComparisonDoesNotRetainPartialPlanAfterSideFailure(t *testing.T) {
checkCalls := 0
failure := errors.New("right input rejected")
prepared := captureSuite(Suite[struct{}, struct{}]{
CheckInput: func(string, Selection, struct{}) error {
checkCalls++
if checkCalls == 2 {
return failure
}
return nil
},
Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
})
commandInput := &configCommandInput[struct{}, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}}
if err := prepareConfigOperation(prepared, Diff, commandInput); !errors.Is(err, failure) {
t.Fatalf("right-side input failure = %v", err)
}
if commandInput.plan != nil || checkCalls != 2 {
t.Fatalf("failed pair retained partial state: plan=%#v checks=%d", commandInput.plan, checkCalls)
}
}
func TestComparisonNilInputFactoryUsesZeroValue(t *testing.T) {
checkCalls := 0
prepared := captureSuite(Suite[*int, struct{}]{
CheckInput: func(_ string, _ Selection, value *int) error {
checkCalls++
if value != nil {
return errors.New("nil factory did not supply the zero value")
}
return nil
},
Scopes: []Scope[*int, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[*int], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
})
commandInput := &configCommandInput[*int, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}}
if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
t.Fatal(err)
}
request := commandInput.plan.comparison.request
if checkCalls != 2 || request.Left.Input != nil || request.Right.Input != nil {
t.Fatalf("nil input value changed: checks=%d request=%#v", checkCalls, request)
}
}
func TestComparisonPreservesCallerOwnedInputAliases(t *testing.T) {
shared := new(int)
inputCalls, checkCalls := 0, 0
prepared := captureSuite(Suite[*int, struct{}]{
NewInput: func() *int { inputCalls++; return shared },
CheckInput: func(_ string, _ Selection, value *int) error {
checkCalls++
if value != shared {
return errors.New("caller-owned alias was replaced")
}
return nil
},
Scopes: []Scope[*int, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[*int], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
})
commandInput := &configCommandInput[*int, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}}
if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
t.Fatal(err)
}
request := commandInput.plan.comparison.request
if inputCalls != 2 || checkCalls != 2 || request.Left.Input != shared || request.Right.Input != shared {
t.Fatalf("caller-owned alias changed: calls=%d/%d request=%#v", inputCalls, checkCalls, request)
}
}
func TestComparisonInputFailureStopsBeforeStateAndPreservesWiringClassification(t *testing.T) {
var inputCalls, checkCalls, stateCalls int
suite := Suite[struct{}, struct{}]{
NewInput: func() struct{} { inputCalls++; return struct{}{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
CheckInput: func(string, Selection, struct{}) error {
checkCalls++
if checkCalls == 2 {
return commandkit.ErrFlagWiring
}
return nil
},
Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
}
app := newConfigTestApp(t, Command(suite))
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, commandkit.ErrFlagWiring) {
t.Fatalf("right-side wiring failure = %#v", outcome)
}
if !strings.Contains(outcome.Cause.Error(), `scope "app" right input`) {
t.Fatalf("right-side error lacks scope context: %v", outcome.Cause)
}
if inputCalls != 2 || checkCalls != 2 || stateCalls != 0 {
t.Fatalf("failed preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
}
}
func TestComparisonMissingRequiredSelectorIsUsageBeforeState(t *testing.T) {
type input struct{ Token commandkit.Supplied[string] }
var inputCalls, checkCalls, stateCalls int
token := commandkit.ValueFlag(commandkit.FlagMeta{Name: "token", Required: true}, (*string)(nil), parseConfigString, func(value *input, supplied commandkit.Supplied[string]) {
value.Token = supplied
})
suite := Suite[input, struct{}]{
NewInput: func() input { inputCalls++; return input{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Selectors: []Selector[input]{{Binding: token, Scopes: []string{"app"}}},
CheckInput: func(string, Selection, input) error { checkCalls++; return nil },
Scopes: []Scope[input, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
}
app := newConfigTestApp(t, Command(suite))
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || !errors.Is(outcome.Cause, commandkit.ErrFlagInput) {
t.Fatalf("missing required selector = %#v", outcome)
}
if inputCalls != 1 || checkCalls != 0 || stateCalls != 0 {
t.Fatalf("missing selector calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
}
}
func TestComparisonHelpConvertsInapplicableSelectorsWithoutBuildingInputs(t *testing.T) {
type input struct{ Tenant int }
parseErr := errors.New("invalid tenant")
var parseCalls, inputCalls, checkCalls, stateCalls int
tenant := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant", Required: true}, (*int)(nil), func(raw string) (int, error) {
parseCalls++
if raw == "bad" {
return 0, parseErr
}
return strconv.Atoi(raw)
}, func(value *input, supplied commandkit.Supplied[int]) {
value.Tenant = supplied.Value
})
makeScope := func(name string) Scope[input, struct{}] {
return Scope[input, struct{}]{Name: name, Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
}}
}
suite := Suite[input, struct{}]{
NewInput: func() input { inputCalls++; return input{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Selectors: []Selector[input]{{Binding: tenant, Scopes: []string{"promptkit"}}},
CheckInput: func(string, Selection, input) error { checkCalls++; return nil },
Scopes: []Scope[input, struct{}]{makeScope("app"), makeScope("promptkit")},
}
app := newConfigTestApp(t, Command(suite))
var output strings.Builder
app.Execute(context.Background(), []string{"config", "diff", "--scope", "app", "--tenant=7", "--help"}, commandkit.Streams{Out: &output, Err: io.Discard})
if !strings.Contains(output.String(), "--tenant") || parseCalls != 1 {
t.Fatalf("help did not convert the inapplicable supplied selector: calls=%d help=%q", parseCalls, output.String())
}
outcome := app.Execute(context.Background(), []string{"config", "diff", "--scope", "app", "--tenant=bad", "--help"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || !errors.Is(outcome.Cause, parseErr) || parseCalls != 2 {
t.Fatalf("malformed help selector outcome=%#v conversions=%d", outcome, parseCalls)
}
if inputCalls != 0 || checkCalls != 0 || stateCalls != 0 {
t.Fatalf("help built suite work: input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
}
}

View File

@@ -0,0 +1,213 @@
package ckconfig
import (
"context"
"errors"
"fmt"
"io"
"strconv"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// FromConfigKit adapts a typed ConfigKit loader to all five native
// configuration operations. The loader receives the operation's explicit
// Selection, initialized input, and shared invocation state, and runs only when
// an installed operation selects this scope. Comparison invokes it separately
// for each reached side. Construction and help do not call it. A nil original
// loader is retained as a construction issue, even if callers later narrow the
// scope's operation list.
func FromConfigKit[I, S, T any](
name, summary string,
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
) Scope[I, S] {
scope := Scope[I, S]{
Name: name,
Summary: summary,
Operations: []Operation{Validate, Show, Sources, Explain, Diff},
}
if load == nil {
return WithScopeError(scope, fmt.Errorf("ConfigKit scope %q loader must not be nil", name))
}
scope.Acquire = func(ctx context.Context, request Request, input I, state S) (commandkit.Acquired[View], error) {
result, err := load(ctx, request.Selection, input, state)
acquired := commandkit.Acquired[View]{}
if result != nil {
acquired.Value = View{Effective: result, Sources: result, Explanation: result}
acquired.Diagnostics, acquired.Explanation = configKitDiagnostics(name, result.Sources(), result.Diagnostics())
}
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) && loadError != nil {
warnings, explanation := configKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())
acquired.Diagnostics = append(acquired.Diagnostics, warnings...)
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, explanation)
}
return acquired, err
}
if result == nil {
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
}
return acquired, nil
}
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
if err := ctx.Err(); err != nil {
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("ConfigKit scope %q comparison before left load: %w", name, err)
}
acquired := commandkit.Acquired[[]configkit.Difference]{}
left, leftDiagnostics, leftExplanation, err := loadComparisonSide(ctx, name, "left", load, request.Left, state)
acquired.Diagnostics = append(acquired.Diagnostics, leftDiagnostics...)
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, leftExplanation)
if err != nil {
return acquired, err
}
if err := ctx.Err(); err != nil {
return acquired, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err)
}
right, rightDiagnostics, rightExplanation, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
acquired.Diagnostics = append(acquired.Diagnostics, rightDiagnostics...)
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, rightExplanation)
if err != nil {
return acquired, err
}
if err := ctx.Err(); err != nil {
return acquired, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err)
}
acquired.Value, err = configkit.Diff(left, right)
if err != nil {
return acquired, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err)
}
return acquired, nil
}
return scope
}
func loadComparisonSide[I, S, T any](
ctx context.Context,
scope, side string,
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
request ComparisonSide[I],
state S,
) (*configkit.Result[T], []commandkit.Diagnostic, commandkit.Explanation, error) {
result, err := load(ctx, request.Selection, request.Input, state)
var diagnostics []commandkit.Diagnostic
var explanation commandkit.Explanation
if result != nil {
warnings, terminal := configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics())
diagnostics = append(diagnostics, warnings...)
explanation = appendConfigKitExplanation(explanation, terminal)
}
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) && loadError != nil {
warnings, terminal := configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics())
diagnostics = append(diagnostics, warnings...)
explanation = appendConfigKitExplanation(explanation, terminal)
}
return result, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err)
}
if result == nil {
return nil, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
}
return result, diagnostics, explanation, nil
}
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
}
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
}
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
if len(records) == 0 {
return nil, commandkit.Explanation{}
}
sources = append([]configkit.SourceInfo(nil), sources...)
records = cloneConfigKitDiagnostics(records)
warnings := make([]configkit.Diagnostic, 0, len(records))
terminal := make([]configkit.Diagnostic, 0, len(records))
for _, record := range records {
if record.Severity == configkit.SeverityError {
terminal = append(terminal, record)
} else {
warnings = append(warnings, record)
}
}
if len(warnings) == 0 {
return nil, explanationWithConfigKitRecords(heading, sources, terminal)
}
warning := commandkit.Diagnostic{
Severity: commandkit.SeverityWarning,
Write: configKitDiagnosticWriter(heading, sources, warnings),
}
return []commandkit.Diagnostic{warning}, explanationWithConfigKitRecords(heading, sources, terminal)
}
func explanationWithConfigKitRecords(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Explanation {
if len(records) == 0 {
return commandkit.Explanation{}
}
return commandkit.Explanation{Details: []commandkit.Detail{configKitDiagnosticWriter(heading, sources, records)}}
}
func configKitDiagnosticWriter(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Detail {
return func(writer io.Writer) error {
if err := writeDiagnosticText(writer, heading); err != nil {
return err
}
if len(sources) > 0 {
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
return err
}
for _, source := range sources {
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
if err := writeDiagnosticText(writer, line); err != nil {
return err
}
}
}
return configkit.WriteDiagnostics(writer, records)
}
}
func cloneConfigKitDiagnostics(records []configkit.Diagnostic) []configkit.Diagnostic {
cloned := append([]configkit.Diagnostic(nil), records...)
for index := range cloned {
if records[index].Primary != nil {
primary := *records[index].Primary
cloned[index].Primary = &primary
}
cloned[index].Related = append([]configkit.Location(nil), records[index].Related...)
}
return cloned
}
func appendConfigKitExplanation(left, right commandkit.Explanation) commandkit.Explanation {
if left.Message == "" {
left.Message = right.Message
}
left.Details = append(left.Details, right.Details...)
return left
}
func writeDiagnosticText(writer io.Writer, value string) error {
written, err := io.WriteString(writer, value)
if err != nil {
return err
}
if written != len(value) {
return io.ErrShortWrite
}
return nil
}

View File

@@ -0,0 +1,163 @@
package ckconfig
import (
"context"
"io"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// Selection carries the configuration and overlay choices supplied for a
// configuration scope.
type Selection struct {
// Config is the caller-supplied configuration path, if present.
Config commandkit.Supplied[string]
// Overlay is the caller-supplied overlay path, if present.
Overlay commandkit.Supplied[string]
}
// Operation identifies a supported standard configuration operation.
type Operation string
const (
// Validate checks the selected configuration and reports diagnostics.
Validate Operation = "validate"
// Show writes the selected configuration's effective form.
Show Operation = "show"
// Sources reports the selected configuration's input sources.
Sources Operation = "sources"
// Explain reports the history and provenance of a selected path.
Explain Operation = "explain"
// Diff compares two selections of the same configuration scope.
Diff Operation = "diff"
)
// Request describes one configuration operation and its selected inputs.
type Request struct {
// Operation identifies the requested inspection operation.
Operation Operation
// Selection carries the explicit config and overlay inputs.
Selection Selection
}
// ComparisonSide groups one configuration selection with its independently
// initialized and checked typed input. The supplied-state fields of Selection
// are preserved for the scope's loader.
type ComparisonSide[I any] struct {
// Selection carries the explicit config and overlay inputs for this side.
Selection Selection
// Input is initialized and checked for this side before comparison.
Input I
}
// ComparisonRequest carries the earlier and later selections supplied to a
// scope's comparison callback. Each side has a separately prepared input; the
// callback receives shared invocation state separately.
type ComparisonRequest[I any] struct {
// Left is the earlier configuration selection.
Left ComparisonSide[I]
// Right is the later configuration selection.
Right ComparisonSide[I]
}
// EffectiveWriter writes a configuration's native effective representation.
type EffectiveWriter interface {
WriteEffective(io.Writer) error
}
// SourceReader reports the native source inventory for a configuration.
type SourceReader interface {
Sources() []configkit.SourceInfo
}
// ExplanationReader reads and writes a configuration's native path explanation.
type ExplanationReader interface {
Explain(configkit.Path) (configkit.Explanation, bool)
WriteExplanation(io.Writer, configkit.Path) error
}
// View exposes native configuration facets without reconstructing their
// reports.
type View struct {
Effective EffectiveWriter
Sources SourceReader
Explanation ExplanationReader
}
// Scope declares the operations and providers for one named configuration.
// Ordinary inspection operations use Acquire; Diff uses Compare and does not
// require Acquire unless an ordinary operation is also declared. Construction
// issues are retained privately until family assembly attaches them to its
// command declaration.
type Scope[I, S any] struct {
// Name identifies the scope in CLI selection and reports.
Name string
// Summary describes the scope. Scope discovery reports name and operations only.
Summary string
// Operations selects the standard handlers exposed for this scope.
Operations []Operation
// Acquire loads the selected configuration for one ordinary inspection
// invocation. It returns independent diagnostics and terminal context
// separately from the native value and optional operation error.
Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error)
// Compare compares two selections for this scope using one shared state.
// Its Acquired result carries native difference records, independent
// diagnostics, and terminal context; returned errors retain their supported
// cause identity. It is required when Diff is declared in Operations.
Compare func(context.Context, ComparisonRequest[I], S) (commandkit.Acquired[[]configkit.Difference], error)
issues []error
}
// Selector binds a typed flag to the configuration scopes that use it.
type Selector[I any] struct {
// Binding is the original typed flag handle captured by the suite.
Binding commandkit.Flag[I]
// Scopes lists the scope names that receive this flag's value.
Scopes []string
}
// Suite declares the typed input, shared state, selectors, and configuration
// scopes used to assemble configuration commands.
//
// Input initialization is separate for each selected ordinary scope and each
// reached comparison side; state is shared by the invocation's acquisitions or
// comparison callback. Factory results and callback effects remain
// caller-owned: Command does not deep-copy arbitrary objects or repair aliases
// returned by factories. Core-owned repeated flag collections are copied when
// applied to separate inputs. Factories are not invoked during construction,
// installation, help, version, or scope discovery, and need not run after failure.
type Suite[I, S any] struct {
// NewInput is called once for each selected ordinary scope, or once for each
// reached side of a comparison, after operation preflight and before applying
// selectors and calling CheckInput.
// If nil, input is the zero value of I, including nil for pointer types.
NewInput func() I
// NewState is called once when provider execution is reached, after all
// selected inputs pass their checks. The result is shared by selected
// acquisitions or the comparison callback.
// If nil, state is the zero value of S, including nil for pointer types.
NewState func() S
// Selectors declares typed flags and their applicable scopes.
Selectors []Selector[I]
// CheckInput validates one initialized scope input after applicable values
// are applied. Comparison sides are checked separately with their selection.
CheckInput func(scope string, selection Selection, input I) error
// Scopes declares the configuration scopes assembled by Command.
Scopes []Scope[I, S]
}
// WithScopeError returns a copy of scope that retains err as a construction
// issue for ckconfig.Command to report during installation. Existing issues
// remain available to family assembly, and scope is not mutated. A nil error
// returns scope unchanged.
func WithScopeError[I, S any](scope Scope[I, S], err error) Scope[I, S] {
if err == nil {
return scope
}
issues := make([]error, len(scope.issues), len(scope.issues)+1)
copy(issues, scope.issues)
scope.issues = append(issues, err)
return scope
}

View File

@@ -0,0 +1,276 @@
package ckconfig
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestWithScopeErrorCopiesAndRetainsCauses(t *testing.T) {
first := &scopeCause{message: "first construction problem"}
second := errors.New("second construction problem")
original := Scope[struct{}, struct{}]{issues: []error{fmt.Errorf("wrapped: %w", first)}}
copyWithError := WithScopeError(original, second)
if len(original.issues) != 1 || !errors.Is(original.issues[0], first) {
t.Fatalf("original issues changed: %#v", original.issues)
}
if len(copyWithError.issues) != 2 || !errors.Is(copyWithError.issues[0], first) || !errors.Is(copyWithError.issues[1], second) {
t.Fatalf("attached issues = %#v", copyWithError.issues)
}
if copyWithError.issues[0] != original.issues[0] {
t.Fatal("existing cause identity was not preserved")
}
var retained *scopeCause
if !errors.As(copyWithError.issues[0], &retained) || retained != first {
t.Fatalf("retained concrete cause = %#v, want %#v", retained, first)
}
narrowed := copyWithError
narrowed.Operations = nil
third := errors.New("third construction problem")
withThird := WithScopeError(narrowed, third)
unchanged := WithScopeError(withThird, nil)
if len(unchanged.issues) != len(copyWithError.issues)+1 || !errors.Is(unchanged.issues[2], third) {
t.Fatalf("repeated attachment lost issues: %#v", unchanged.issues)
}
}
type scopeCause struct{ message string }
func (e *scopeCause) Error() string { return e.message }
func TestFromConfigKitRetainsNilLoaderIssue(t *testing.T) {
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "Application configuration", nil)
scope.Operations = nil
if scope.Acquire != nil {
t.Fatal("nil loader produced an acquisition callback")
}
if len(scope.issues) != 1 || !strings.Contains(scope.issues[0].Error(), "loader") {
t.Fatalf("retained nil-loader issues = %#v", scope.issues)
}
}
func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
type input struct{ Label string }
type state struct{ Calls int }
type settings struct {
Name string `yaml:"name"`
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
selection := Selection{
Config: commandkit.Supplied[string]{Value: "chosen.yaml", Set: true},
Overlay: commandkit.Supplied[string]{Value: "", Set: true},
}
gotContext, gotSelection, gotInput, gotState := context.Context(nil), Selection{}, input{}, (*state)(nil)
result, err := configkit.Load(configkit.Mapping("source.yaml", map[string]any{"name": "native"}), configkit.Options[settings]{Definition: configkit.Definition[settings]{}})
if err != nil {
t.Fatal(err)
}
scope := FromConfigKit[input, *state, settings]("app", "Application configuration", func(got context.Context, selected Selection, in input, st *state) (*configkit.Result[settings], error) {
gotContext, gotSelection, gotInput, gotState = got, selected, in, st
st.Calls++
return result, nil
})
if len(scope.Operations) != 5 || scope.Operations[0] != Validate || scope.Operations[1] != Show || scope.Operations[2] != Sources || scope.Operations[3] != Explain || scope.Operations[4] != Diff {
t.Fatalf("operations = %#v", scope.Operations)
}
if gotContext != nil {
t.Fatal("loader ran during adapter construction")
}
stateValue := &state{}
acquired, err := scope.Acquire(ctx, Request{Operation: Show, Selection: selection}, input{Label: "typed"}, stateValue)
if err != nil {
t.Fatal(err)
}
if gotContext != ctx || gotSelection != selection || gotInput.Label != "typed" || gotState != stateValue || stateValue.Calls != 1 {
t.Fatalf("loader arguments/context/state = %v, %#v, %#v, %p, %d", gotContext, gotSelection, gotInput, gotState, stateValue.Calls)
}
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result {
t.Fatal("adapter did not expose the native result facets directly")
}
var effective bytes.Buffer
if err := acquired.Value.Effective.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if !strings.Contains(effective.String(), "name: native") {
t.Fatalf("native effective output = %q", effective.String())
}
sources := acquired.Value.Sources.Sources()
if len(sources) != 1 || sources[0].Label != "source.yaml" {
t.Fatalf("native sources = %#v", sources)
}
path, err := configkit.ParsePath("/name")
if err != nil {
t.Fatal(err)
}
if _, ok := acquired.Value.Explanation.Explain(path); !ok {
t.Fatal("native explanation facet did not resolve /name")
}
var explanation bytes.Buffer
if err := acquired.Value.Explanation.WriteExplanation(&explanation, path); err != nil {
t.Fatal(err)
}
if explanation.Len() == 0 {
t.Fatal("native explanation writer produced no output")
}
}
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
path := configkit.Keys("schema_version")
_, loadErr := configkit.Load(configkit.Bytes("bad source", []byte("{}\n")), configkit.Options[struct{}]{
Definition: configkit.Definition[struct{}]{Version: &configkit.VersionRule{
Path: path,
Check: func(_ configkit.Version, validation *configkit.Validation) error {
validation.Warning(path, "review schema compatibility")
return errors.New("private checker error")
},
}},
})
if loadErr == nil {
t.Fatal("fixture did not produce a ConfigKit load error")
}
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[struct{}], error) {
return nil, loadErr
})
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
if gotErr != loadErr {
t.Fatalf("load error identity = %v, want original %v", gotErr, loadErr)
}
var typed *configkit.LoadError
if !errors.As(gotErr, &typed) {
t.Fatalf("load error type was not retained: %T", gotErr)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("failure diagnostics = %#v", acquired.Diagnostics)
}
if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 {
t.Fatalf("failure explanation = %#v", acquired.Explanation)
}
var warning, terminal bytes.Buffer
if err := acquired.Diagnostics[0].Write(&warning); err != nil {
t.Fatal(err)
}
if err := acquired.Explanation.Details[0](&terminal); err != nil {
t.Fatal(err)
}
if !strings.Contains(warning.String(), `Configuration scope "app":`) || !strings.Contains(warning.String(), `review schema compatibility`) {
t.Fatalf("warning output lost its scope or record: %q", warning.String())
}
if !strings.Contains(terminal.String(), `Configuration scope "app":`) || !strings.Contains(terminal.String(), `"bad source"`) || strings.Contains(terminal.String(), "review schema compatibility") {
t.Fatalf("terminal explanation lost native context or included an independent warning: %q", terminal.String())
}
if strings.Contains(terminal.String(), "private checker error") {
t.Fatalf("terminal explanation exposed the upstream cause: %q", terminal.String())
}
}
func TestFromConfigKitPreservesValueAndDiagnosticsAlongsideError(t *testing.T) {
result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
loadErr := errors.New("loader returned a value with an error")
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
return result, loadErr
})
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
if gotErr != loadErr {
t.Fatalf("load error identity = %v, want %v", gotErr, loadErr)
}
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result {
t.Fatalf("acquired value facets were lost: %#v", acquired.Value)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("independent diagnostics = %#v", acquired.Diagnostics)
}
if len(acquired.Explanation.Details) != 0 {
t.Fatalf("warning-only result acquired terminal context: %#v", acquired.Explanation)
}
}
func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) {
warning, noExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning {
t.Fatalf("warning diagnostics = %#v", warning)
}
if noExplanation.Message != "" || len(noExplanation.Details) != 0 {
t.Fatalf("warning-only explanation = %#v", noExplanation)
}
withWarning, withExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{
{Severity: configkit.SeverityWarning, Message: "safe warning"},
{Severity: configkit.SeverityError, Message: "safe error"},
})
if len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 {
t.Fatalf("mixed diagnostics = %#v, explanation = %#v", withWarning, withExplanation)
}
if err := withWarning[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite)
}
if err := withExplanation.Details[0](shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("terminal short writer error = %v, want %v", err, io.ErrShortWrite)
}
wantErr := errors.New("writer failed")
if err := withWarning[0].Write(failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
t.Fatalf("writer error = %v, want %v", err, wantErr)
}
if err := withExplanation.Details[0](failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
t.Fatalf("terminal writer error = %v, want %v", err, wantErr)
}
}
func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.T) {
primary := &configkit.Location{SourceID: "primary-source", Line: 4, Column: 2}
related := []configkit.Location{{SourceID: "related-source", Line: 2, Column: 1}}
sources := []configkit.SourceInfo{
{ID: "primary-source", Label: "primary", Path: "primary.yml"},
{ID: "related-source", Label: "related", Path: "related.yml"},
}
records := []configkit.Diagnostic{{
Severity: configkit.SeverityError,
Path: configkit.Keys("name"),
Message: "native terminal context",
Primary: primary,
Related: related,
}}
_, explanation := configKitDiagnostics("app", sources, records)
primary.SourceID = "mutated-primary"
related[0].SourceID = "mutated-related"
sources[0].Path = "mutated.yml"
if len(explanation.Details) != 1 {
t.Fatalf("explanation = %#v", explanation)
}
var output bytes.Buffer
if err := explanation.Details[0](&output); err != nil {
t.Fatal(err)
}
for _, fragment := range []string{"primary.yml", "primary-source", "related-source", "native terminal context"} {
if !strings.Contains(output.String(), fragment) {
t.Errorf("terminal context %q omits %q", output.String(), fragment)
}
}
if strings.Contains(output.String(), "mutated-") || strings.Contains(output.String(), "mutated.yml") {
t.Fatalf("terminal renderer retained caller mutations: %q", output.String())
}
}
type shortWriter struct{}
func (shortWriter) Write(value []byte) (int, error) { return len(value) - 1, nil }
type failingWriter struct{ err error }
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }

View File

@@ -0,0 +1,36 @@
package ckconfig
import (
"errors"
"fmt"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
)
func runConfigDiff[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
if plan.comparison == nil || plan.comparison.scope == nil {
cause := errors.New("configuration comparison was not prepared")
return configFailure("Configuration comparison was not prepared.", commandkit.Explanation{}, cause)
}
selected := plan.comparison
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration comparison was canceled.", commandkit.Explanation{}, err)
}
compare := selected.scope.scope.Compare
if compare == nil {
cause := fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name)
return configFailure("Configuration scope does not support comparison.", commandkit.Explanation{}, cause)
}
acquired, err := compare(invocation.Context, selected.request, state)
reporter := invocation.Reporter()
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if err != nil {
return configFailure(fmt.Sprintf("Unable to compare configuration scope %q.", selected.scope.scope.Name), acquired.Explanation, err)
}
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration comparison was canceled.", acquired.Explanation, err)
}
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
return renderDiffReport(acquired.Value)
})
}

View File

@@ -0,0 +1,288 @@
package ckconfig
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"go.yaml.in/yaml/v4"
)
func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndInheritedSelections(t *testing.T) {
type settings struct {
Greeting string `yaml:"greeting"`
}
var selections []Selection
app := newConfigTestApp(t, AppCommand(func(_ context.Context, selection Selection) (*configkit.Result[settings], error) {
selections = append(selections, selection)
value := "Hello"
if selection.Config.Set && selection.Config.Value == "after.yml" {
value = "Goodbye"
}
return configkit.Load(configkit.Mapping("selected configuration", map[string]any{"greeting": value}), configkit.Options[settings]{})
}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before.yml", "--overlay", "base", "--against-config", "after.yml"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("default YAML comparison outcome = %#v", outcome)
}
if len(selections) != 2 || !selections[0].Config.Set || selections[0].Config.Value != "before.yml" || !selections[1].Config.Set || selections[1].Config.Value != "after.yml" || selections[1].Overlay != selections[0].Overlay {
t.Fatalf("comparison selections = %#v", selections)
}
var document yaml.Node
if err := yaml.Unmarshal(output.Bytes(), &document); err != nil {
t.Fatalf("default report is not YAML: %q: %v", output.String(), err)
}
changes := reportField(document.Content[0], "changes")
if value := reportField(changes, "greeting"); value == nil || value.Value != "Goodbye" {
t.Fatalf("public comparison output = %#v", document.Content[0])
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "yaml", "--config", "before.yml", "--against-config", "before.yml"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("explicit YAML comparison outcome = %#v", outcome)
}
var equal yaml.Node
if err := yaml.Unmarshal(output.Bytes(), &equal); err != nil {
t.Fatalf("explicit YAML report is not YAML: %q: %v", output.String(), err)
}
if len(reportField(equal.Content[0], "additions").Content) != 0 || len(reportField(equal.Content[0], "changes").Content) != 0 || len(reportField(equal.Content[0], "deletions").Content) != 0 {
t.Fatalf("equal comparison did not return empty categories: %q", output.String())
}
loadsBeforeText := len(selections)
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "text"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || len(selections) != loadsBeforeText || output.Len() != 0 {
t.Fatalf("text format outcome/work/output = %#v / %d / %q", outcome, len(selections)-loadsBeforeText, output.String())
}
}
func TestConfigDiffRunsComparisonOnlyScopeAndRejectsUnsupportedScopeBeforeWork(t *testing.T) {
var inputCalls, stateCalls, acquireCalls, compareCalls int
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{
NewInput: func() struct{} { inputCalls++; return struct{}{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Scopes: []Scope[struct{}, struct{}]{
{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
compareCalls++
return commandkit.Acquired[[]configkit.Difference]{}, nil
}},
{Name: "plain", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, nil
}},
},
}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "app: diff") {
t.Fatalf("comparison-only discovery = %#v, %q", outcome, output.String())
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "app"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || compareCalls != 1 {
t.Fatalf("comparison-only execution = %#v calls=%d", outcome, compareCalls)
}
inputsBefore, statesBefore := inputCalls, stateCalls
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "plain"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || inputCalls != inputsBefore || stateCalls != statesBefore || acquireCalls != 0 || compareCalls != 1 || output.Len() != 0 {
t.Fatalf("unsupported scope outcome/work/output = %#v / %d,%d,%d,%d / %q", outcome, inputCalls, stateCalls, acquireCalls, compareCalls, output.String())
}
}
func TestConfigDiffProviderFailureRetainsDiagnosticsAndCause(t *testing.T) {
wantErr := errors.New("provider unavailable")
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "provider warning\n")
return err
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{
Diagnostics: []commandkit.Diagnostic{diagnostic},
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
_, err := io.WriteString(writer, "native comparison context\n")
return err
}}},
}, fmt.Errorf("comparison setup: %w", wantErr)
},
}}}))
var output, diagnosticOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || output.Len() != 0 {
t.Fatalf("provider failure outcome/output = %#v / %q", outcome, output.String())
}
if got := diagnosticOutput.String(); !strings.Contains(got, "provider warning") || !strings.Contains(got, "native comparison context") || strings.Index(got, "provider warning") > strings.Index(got, "native comparison context") || strings.Contains(got, wantErr.Error()) {
t.Fatalf("provider messages and terminal context = %q", got)
}
}
func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{Value: []configkit.Difference{{
Path: configkit.Keys("invalid"), Right: configkit.ValueSnapshot{Present: true, YAML: "bad: ["},
}}}, nil
},
}}}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--quiet"}, commandkit.Streams{Out: failingOutput{err: errors.New("quiet destination should not be called")}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || outcome.Presentation.Output.RenderError == nil || output.Len() != 0 {
t.Fatalf("report preparation failure outcome/output = %#v / %q", outcome, output.String())
}
}
func TestConfigDiffCancellationAfterAcquisitionRetainsDiagnosticsWithoutWriting(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(io.Writer) error { return nil }}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
cancel()
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
},
}}}))
defer cancel()
var output bytes.Buffer
outcome := app.Execute(ctx, []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || output.Len() != 0 {
t.Fatalf("cancelled comparison outcome/output = %#v / %q", outcome, output.String())
}
}
func TestConfigDiffOutputErrorsCarryIncompleteDeliveryOutcome(t *testing.T) {
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}}}))
wantErr := errors.New("output failed")
for _, test := range []struct {
name string
writer io.Writer
want error
}{
{name: "writer error", writer: failingOutput{err: wantErr}, want: wantErr},
{name: "short write", writer: shortWriter{}, want: io.ErrShortWrite},
} {
t.Run(test.name, func(t *testing.T) {
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: test.writer, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
t.Fatalf("delivery failure outcome = %#v", outcome)
}
})
}
}
func TestConfigDiffDiagnosticDestinationFailureIsPresentationEvidence(t *testing.T) {
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{
Diagnostics: []commandkit.Diagnostic{{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "provider warning\n")
return err
}}},
}, nil
},
}}}))
writeErr := errors.New("diagnostic destination failed")
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: failingOutput{err: writeErr}})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
t.Fatalf("diagnostic delivery failure outcome = %#v", outcome)
}
if output.Len() == 0 {
t.Fatal("healthy result destination did not receive the diff report")
}
}
func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) {
type input struct {
ID int
Label string
}
type state struct{ ID int }
label := commandkit.ValueFlag(commandkit.FlagMeta{Name: "label"}, (*string)(nil), parseConfigString,
func(target *input, value commandkit.Supplied[string]) { target.Label = value.Value })
var inputID, stateID int
var observed []struct {
left, right input
state *state
}
app := newConfigTestApp(t, Command(Suite[input, *state]{
NewInput: func() input { inputID++; return input{ID: inputID} },
NewState: func() *state { stateID++; return &state{ID: stateID} },
Selectors: []Selector[input]{{Binding: label, Scopes: []string{"app"}}},
Scopes: []Scope[input, *state]{{Name: "app", Operations: []Operation{Diff}, Compare: func(_ context.Context, request ComparisonRequest[input], shared *state) (commandkit.Acquired[[]configkit.Difference], error) {
observed = append(observed, struct {
left, right input
state *state
}{request.Left.Input, request.Right.Input, shared})
left, err := configkit.Load[struct {
Label string `yaml:"label"`
}](configkit.Mapping("left", map[string]any{"label": request.Left.Input.Label}))
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{}, err
}
right, err := configkit.Load[struct {
Label string `yaml:"label"`
}](configkit.Mapping("right", map[string]any{"label": request.Right.Input.Label + "-right"}))
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{}, err
}
differences, err := configkit.Diff(left, right)
return commandkit.Acquired[[]configkit.Difference]{Value: differences}, err
}}},
}))
for range 2 {
outcome := app.Execute(context.Background(), []string{"config", "diff", "--label", "current"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("comparison invocation outcome = %#v", outcome)
}
}
if len(observed) != 2 || observed[0].left.ID == observed[0].right.ID || observed[1].left.ID == observed[1].right.ID || observed[0].left.ID == observed[1].left.ID || observed[0].right.ID == observed[1].right.ID {
t.Fatalf("input instances were reused: %#v", observed)
}
if observed[0].left.Label != "current" || observed[0].right.Label != "current" || observed[1].left.Label != "current" || observed[1].right.Label != "current" {
t.Fatalf("comparison inputs missed captured flag values: %#v", observed)
}
if observed[0].state == nil || observed[1].state == nil || observed[0].state == observed[1].state || observed[0].state.ID != 1 || observed[1].state.ID != 2 {
t.Fatalf("state was not fresh per invocation: %#v", observed)
}
}
func TestConfigDiffExecutesConcealedChangesAndEmptyResults(t *testing.T) {
type settings struct {
Secret string `yaml:"secret"`
}
app := newConfigTestApp(t, AppCommand(func(_ context.Context, selection Selection) (*configkit.Result[settings], error) {
value := "same"
if selection.Config.Set && selection.Config.Value == "after" {
value = "changed-secret"
}
return configkit.Load(configkit.Mapping("selected", map[string]any{"secret": value}), configkit.Options[settings]{Definition: configkit.Definition[settings]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}})
}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "after"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "[REDACTED]") || strings.Contains(output.String(), "changed-secret") {
t.Fatalf("concealed comparison outcome/report = %#v / %q", outcome, output.String())
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "before"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "additions:") || !strings.Contains(output.String(), "changes:") || !strings.Contains(output.String(), "deletions:") {
t.Fatalf("empty comparison outcome/report = %#v / %q", outcome, output.String())
}
}

View File

@@ -0,0 +1,194 @@
package ckconfig
import (
"bytes"
"fmt"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"go.yaml.in/yaml/v4"
)
func decodeSafeSnapshot(snapshot string) (*yaml.Node, error) {
var document yaml.Node
decoder := yaml.NewDecoder(strings.NewReader(snapshot))
if err := decoder.Decode(&document); err != nil {
return nil, fmt.Errorf("decode safe YAML snapshot: %w", err)
}
if document.Kind != yaml.DocumentNode || len(document.Content) == 0 {
return nil, fmt.Errorf("decode safe YAML snapshot: document has no value")
}
return document.Content[0], nil
}
func decodeJSONPointer(pointer string) ([]string, error) {
if pointer == "" {
return nil, nil
}
if pointer[0] != '/' {
return nil, fmt.Errorf("decode JSON pointer %q: path must start with /", pointer)
}
parts := strings.Split(pointer[1:], "/")
for index, part := range parts {
var decoded strings.Builder
decoded.Grow(len(part))
for offset := 0; offset < len(part); offset++ {
if part[offset] != '~' {
decoded.WriteByte(part[offset])
continue
}
if offset+1 >= len(part) {
return nil, fmt.Errorf("decode JSON pointer %q: incomplete escape in token %d", pointer, index)
}
offset++
switch part[offset] {
case '0':
decoded.WriteByte('~')
case '1':
decoded.WriteByte('/')
default:
return nil, fmt.Errorf("decode JSON pointer %q: invalid escape in token %d", pointer, index)
}
}
parts[index] = decoded.String()
}
return parts, nil
}
// yamlValuePlacer indexes each mapping once while assembling one report.
// Content remains the ordered representation used by the YAML encoder.
type yamlValuePlacer struct {
keys map[*yaml.Node]map[string]int
}
func (placer *yamlValuePlacer) placeValue(category *yaml.Node, path []string, value *yaml.Node) (*yaml.Node, error) {
if value == nil {
return nil, fmt.Errorf("place YAML value: value node is nil")
}
if len(path) == 0 {
return value, nil
}
if category == nil || category.Kind != yaml.MappingNode {
return nil, fmt.Errorf("place YAML value at %q: category ancestor is not a mapping", pointerFromTokens(path[:1]))
}
current := category
for index, token := range path {
keys := placer.mappingKeys(current)
keyIndex, exists := keys[token]
if index == len(path)-1 {
if exists {
current.Content[keyIndex+1] = value
} else {
keys[token] = len(current.Content)
current.Content = append(current.Content, stringKey(token), value)
}
return category, nil
}
if !exists {
child := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
keys[token] = len(current.Content)
current.Content = append(current.Content, stringKey(token), child)
current = child
continue
}
child := current.Content[keyIndex+1]
if child.Kind != yaml.MappingNode {
return nil, fmt.Errorf("place YAML value at %q: ancestor %q is not a mapping", pointerFromTokens(path), pointerFromTokens(path[:index+1]))
}
current = child
}
return category, nil
}
func (placer *yamlValuePlacer) mappingKeys(mapping *yaml.Node) map[string]int {
if keys, exists := placer.keys[mapping]; exists {
return keys
}
if placer.keys == nil {
placer.keys = make(map[*yaml.Node]map[string]int)
}
keys := make(map[string]int, len(mapping.Content)/2)
for index := 0; index+1 < len(mapping.Content); index += 2 {
candidate := mapping.Content[index]
if candidate.Kind == yaml.ScalarNode && candidate.Tag == "!!str" {
if _, exists := keys[candidate.Value]; !exists {
keys[candidate.Value] = index
}
}
}
placer.keys[mapping] = keys
return keys
}
func stringKey(value string) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}
}
func pointerFromTokens(tokens []string) string {
if len(tokens) == 0 {
return ""
}
var pointer strings.Builder
for _, token := range tokens {
pointer.WriteByte('/')
pointer.WriteString(strings.ReplaceAll(strings.ReplaceAll(token, "~", "~0"), "/", "~1"))
}
return pointer.String()
}
func renderDiffReport(differences []configkit.Difference) ([]byte, error) {
var placer yamlValuePlacer
additions := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
changes := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
deletions := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
for _, difference := range differences {
path := difference.Path.String()
switch {
case !difference.Left.Present && difference.Right.Present:
tokens, err := decodeJSONPointer(path)
if err != nil {
return nil, fmt.Errorf("render addition at %q: %w", path, err)
}
value, err := decodeSafeSnapshot(difference.Right.YAML)
if err != nil {
return nil, fmt.Errorf("render addition at %q: %w", path, err)
}
additions, err = placer.placeValue(additions, tokens, value)
if err != nil {
return nil, fmt.Errorf("render addition at %q: %w", path, err)
}
case difference.Left.Present && difference.Right.Present:
tokens, err := decodeJSONPointer(path)
if err != nil {
return nil, fmt.Errorf("render change at %q: %w", path, err)
}
value, err := decodeSafeSnapshot(difference.Right.YAML)
if err != nil {
return nil, fmt.Errorf("render change at %q: %w", path, err)
}
changes, err = placer.placeValue(changes, tokens, value)
if err != nil {
return nil, fmt.Errorf("render change at %q: %w", path, err)
}
case difference.Left.Present && !difference.Right.Present:
deletions.Content = append(deletions.Content, stringKey(path))
}
}
root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{
stringKey("additions"), additions,
stringKey("changes"), changes,
stringKey("deletions"), deletions,
}}
document := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}
var output bytes.Buffer
if err := encodeYAML(&output, document); err != nil {
return nil, fmt.Errorf("encode configuration diff report: %w", err)
}
return output.Bytes(), nil
}

View File

@@ -0,0 +1,173 @@
package ckconfig
import (
"bytes"
"reflect"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"go.yaml.in/yaml/v4"
)
func renderAndDecode(t *testing.T, differences []configkit.Difference) (*yaml.Node, []byte) {
t.Helper()
report, err := renderDiffReport(differences)
if err != nil {
t.Fatal(err)
}
var document yaml.Node
if err := yaml.Unmarshal(report, &document); err != nil {
t.Fatalf("decode rendered report %q: %v", report, err)
}
return document.Content[0], report
}
func reportField(mapping *yaml.Node, key string) *yaml.Node {
for index := 0; index+1 < len(mapping.Content); index += 2 {
if mapping.Content[index].Value == key {
return mapping.Content[index+1]
}
}
return nil
}
func TestRenderDiffReportClassifiesAndOrdersNativeRecords(t *testing.T) {
differences := []configkit.Difference{
{Path: configkit.Keys("z-added"), Right: configkit.ValueSnapshot{Present: true, YAML: "false\n"}},
{Path: configkit.Keys("count"), Left: configkit.ValueSnapshot{Present: true, YAML: "1\n"}, Right: configkit.ValueSnapshot{Present: true, YAML: "9007199254740993\n"}},
{Path: configkit.Keys("removed"), Left: configkit.ValueSnapshot{Present: true, YAML: "unused"}},
{Path: configkit.Keys("a-added"), Right: configkit.ValueSnapshot{Present: true, YAML: "'123'\n"}},
}
root, _ := renderAndDecode(t, differences)
if root.Kind != yaml.MappingNode || len(root.Content) != 6 {
t.Fatalf("report root = %#v", root)
}
if got := []string{root.Content[0].Value, root.Content[2].Value, root.Content[4].Value}; !reflect.DeepEqual(got, []string{"additions", "changes", "deletions"}) {
t.Fatalf("report category order = %#v", got)
}
additions := reportField(root, "additions")
if additions.Kind != yaml.MappingNode || additions.Content[0].Value != "z-added" || additions.Content[2].Value != "a-added" {
t.Fatalf("addition order/value = %#v", additions)
}
if additions.Content[1].Kind != yaml.ScalarNode || additions.Content[1].Tag != "!!bool" || additions.Content[1].Value != "false" {
t.Fatalf("boolean addition lost its type: %#v", additions.Content[1])
}
if additions.Content[3].Tag != "!!str" || additions.Content[3].Value != "123" {
t.Fatalf("numeric-looking string lost its type: %#v", additions.Content[3])
}
changes := reportField(root, "changes")
if changes.Kind != yaml.MappingNode || changes.Content[1].Tag != "!!int" || changes.Content[1].Value != "9007199254740993" {
t.Fatalf("change value/type = %#v", changes)
}
deletions := reportField(root, "deletions")
if deletions.Kind != yaml.SequenceNode || len(deletions.Content) != 1 || deletions.Content[0].Tag != "!!str" || deletions.Content[0].Value != "/removed" {
t.Fatalf("deletion records = %#v", deletions)
}
}
func TestRenderDiffReportEmitsEmptyCategoriesAndRootValue(t *testing.T) {
root, _ := renderAndDecode(t, nil)
if got := []string{root.Content[0].Value, root.Content[2].Value, root.Content[4].Value}; !reflect.DeepEqual(got, []string{"additions", "changes", "deletions"}) {
t.Fatalf("empty report keys = %#v", got)
}
if reportField(root, "additions").Kind != yaml.MappingNode || len(reportField(root, "additions").Content) != 0 || reportField(root, "changes").Kind != yaml.MappingNode || len(reportField(root, "changes").Content) != 0 || reportField(root, "deletions").Kind != yaml.SequenceNode || len(reportField(root, "deletions").Content) != 0 {
t.Fatalf("empty report categories = %#v", root)
}
root, _ = renderAndDecode(t, []configkit.Difference{{Right: configkit.ValueSnapshot{Present: true, YAML: "null\n"}}})
additions := reportField(root, "additions")
if additions.Tag != "!!null" || additions.Value != "null" {
t.Fatalf("whole-root addition was wrapped: %#v", additions)
}
}
func TestRenderDiffReportDistinguishesDeletionFromNullAndEmptyMappings(t *testing.T) {
differences := []configkit.Difference{
{Path: configkit.Keys("nullable"), Right: configkit.ValueSnapshot{Present: true, YAML: "null\n"}},
{Path: configkit.Keys("settings", "first"), Left: configkit.ValueSnapshot{Present: true, YAML: "intentionally not parsed"}},
{Path: configkit.Keys("settings", "second"), Left: configkit.ValueSnapshot{Present: true, YAML: "also not parsed"}},
}
root, _ := renderAndDecode(t, differences)
if got := reportField(root, "additions").Content; len(got) != 2 || got[0].Value != "nullable" || got[1].Tag != "!!null" {
t.Fatalf("explicit null addition = %#v", got)
}
deletions := reportField(root, "deletions")
if len(deletions.Content) != 2 || deletions.Content[0].Value != "/settings/first" || deletions.Content[1].Value != "/settings/second" {
t.Fatalf("mapping-to-empty deletion records = %#v", deletions)
}
}
func TestRenderDiffReportKeepsAtomicSequencesAndStructuralValues(t *testing.T) {
differences := []configkit.Difference{
{Path: configkit.Keys("items"), Left: configkit.ValueSnapshot{Present: true}, Right: configkit.ValueSnapshot{Present: true, YAML: "- one\n- two\n"}},
{Path: configkit.Keys("settings"), Left: configkit.ValueSnapshot{Present: true}, Right: configkit.ValueSnapshot{Present: true, YAML: "nested:\n enabled: true\n"}},
}
root, _ := renderAndDecode(t, differences)
changes := reportField(root, "changes")
if changes.Content[1].Kind != yaml.SequenceNode || len(changes.Content[1].Content) != 2 {
t.Fatalf("sequence change was expanded or altered: %#v", changes.Content[1])
}
settings := reportField(changes, "settings")
if settings.Kind != yaml.MappingNode || reportField(settings, "nested").Kind != yaml.MappingNode || reportField(reportField(settings, "nested"), "enabled").Tag != "!!bool" {
t.Fatalf("structural replacement was altered: %#v", settings)
}
}
func TestRenderDiffReportPreservesNativeMasksAndMarkerLookingStrings(t *testing.T) {
type settings struct {
Public string `yaml:"public"`
Secret string `yaml:"secret"`
}
definition := configkit.Definition[settings]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}
load := func(public, secret string) *configkit.Result[settings] {
result, err := configkit.Load(configkit.Mapping("configuration", map[string]any{"public": public, "secret": secret}), configkit.Options[settings]{Definition: definition})
if err != nil {
t.Fatal(err)
}
return result
}
left := load("visible", "old-secret")
right := load("[REDACTED]", "new-secret")
differences, err := configkit.Diff(left, right)
if err != nil {
t.Fatal(err)
}
if len(differences) != 2 || differences[0].Path.String() != "/public" || differences[1].Path.String() != "/secret" || !differences[1].HiddenChanged || !differences[1].Right.Redacted {
t.Fatalf("native changes/mask metadata = %#v", differences)
}
root, report := renderAndDecode(t, differences)
changes := reportField(root, "changes")
for _, key := range []string{"public", "secret"} {
value := reportField(changes, key)
if value == nil || value.Tag != "!!str" || value.Value != "[REDACTED]" {
t.Fatalf("safe value for %q = %#v", key, value)
}
}
if bytes.Contains(report, []byte("HiddenChanged")) || bytes.Contains(report, []byte("Redacted")) || strings.Contains(string(report), "old-secret") || strings.Contains(string(report), "new-secret") {
t.Fatalf("report exposed native metadata or secret contents: %q", report)
}
concealedOnly := load("[REDACTED]", "later-secret")
concealedDifferences, err := configkit.Diff(right, concealedOnly)
if err != nil {
t.Fatal(err)
}
if len(concealedDifferences) != 1 || concealedDifferences[0].Path.String() != "/secret" || !concealedDifferences[0].HiddenChanged {
t.Fatalf("concealed-only native records = %#v", concealedDifferences)
}
concealedRoot, _ := renderAndDecode(t, concealedDifferences)
if value := reportField(reportField(concealedRoot, "changes"), "secret"); value == nil || value.Tag != "!!str" || value.Value != "[REDACTED]" {
t.Fatalf("concealed-only safe value = %#v", value)
}
}
func TestRenderDiffReportReportsMalformedPresentSnapshot(t *testing.T) {
report, err := renderDiffReport([]configkit.Difference{{
Path: configkit.Keys("setting"),
Right: configkit.ValueSnapshot{Present: true, YAML: "setting: [unterminated\n"},
}})
if err == nil || report != nil || !strings.Contains(err.Error(), "/setting") {
t.Fatalf("malformed snapshot report/error = %q / %v", report, err)
}
}

View File

@@ -0,0 +1,146 @@
package ckconfig
import (
"reflect"
"strings"
"testing"
"go.yaml.in/yaml/v4"
)
func TestDecodeSafeSnapshotPreservesYAMLTypes(t *testing.T) {
tests := []struct {
name string
text string
kind yaml.Kind
tag string
value string
}{
{name: "null", text: "null\n", kind: yaml.ScalarNode, tag: "!!null", value: "null"},
{name: "boolean", text: "true\n", kind: yaml.ScalarNode, tag: "!!bool", value: "true"},
{name: "large integer", text: "9007199254740993\n", kind: yaml.ScalarNode, tag: "!!int", value: "9007199254740993"},
{name: "numeric string", text: "'123'\n", kind: yaml.ScalarNode, tag: "!!str", value: "123"},
{name: "boolean-like string", text: "'false'\n", kind: yaml.ScalarNode, tag: "!!str", value: "false"},
{name: "empty mapping", text: "{}\n", kind: yaml.MappingNode, tag: "!!map"},
{name: "empty sequence", text: "[]\n", kind: yaml.SequenceNode, tag: "!!seq"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
node, err := decodeSafeSnapshot(test.text)
if err != nil {
t.Fatal(err)
}
if node.Kind != test.kind || node.Tag != test.tag || node.Value != test.value {
t.Fatalf("node kind/tag/value = %v/%q/%q, want %v/%q/%q", node.Kind, node.Tag, node.Value, test.kind, test.tag, test.value)
}
})
}
nested, err := decodeSafeSnapshot("items:\n - name: example\n enabled: false\n")
if err != nil {
t.Fatal(err)
}
if nested.Kind != yaml.MappingNode || nested.Content[1].Kind != yaml.SequenceNode || nested.Content[1].Content[0].Content[3].Tag != "!!bool" {
t.Fatalf("nested collection/scalar tags were not preserved: %#v", nested)
}
}
func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
tests := []struct {
pointer string
want []string
}{
{pointer: "", want: nil},
{pointer: "/", want: []string{""}},
{pointer: "/parent/child", want: []string{"parent", "child"}},
{pointer: "/0/../a.b/50%", want: []string{"0", "..", "a.b", "50%"}},
{pointer: "/a~1b/c~0d", want: []string{"a/b", "c~d"}},
{pointer: "/~01", want: []string{"~1"}},
}
for _, test := range tests {
got, err := decodeJSONPointer(test.pointer)
if err != nil {
t.Fatalf("decode %q: %v", test.pointer, err)
}
if !reflect.DeepEqual(got, test.want) {
t.Errorf("decode %q = %#v, want %#v", test.pointer, got, test.want)
}
}
for _, pointer := range []string{"missing-leading-slash", "/bad~", "/bad~2escape"} {
if _, err := decodeJSONPointer(pointer); err == nil {
t.Errorf("decode %q unexpectedly succeeded", pointer)
}
}
}
func TestPlaceYAMLValueBuildsOrderedSemanticMappings(t *testing.T) {
var placer yamlValuePlacer
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
paths := [][]string{
{"parent", "0", "dot.name", "50%"},
{"parent", "second"},
{"a/b", "~1"},
{""},
}
for index, path := range paths {
value := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: string(rune('a' + index))}
updated, err := placer.placeValue(category, path, value)
if err != nil {
t.Fatalf("place %q: %v", pointerFromTokens(path), err)
}
category = updated
}
if len(category.Content) != 6 || category.Content[0].Value != "parent" || category.Content[2].Value != "a/b" || category.Content[4].Value != "" {
t.Fatalf("mapping insertion order or empty key = %#v", category.Content)
}
parent := category.Content[1]
if parent.Kind != yaml.MappingNode || len(parent.Content) != 4 || parent.Content[0].Value != "0" || parent.Content[2].Value != "second" {
t.Fatalf("nested shared ancestor/order = %#v", parent)
}
numeric := parent.Content[1]
dotted := numeric.Content[1]
if numeric.Kind != yaml.MappingNode || numeric.Content[0].Value != "dot.name" || dotted.Kind != yaml.MappingNode || dotted.Content[0].Value != "50%" {
t.Fatalf("semantic tokens were changed: numeric=%#v dotted=%#v", numeric, dotted)
}
if category.Content[1].Content[0].Tag != "!!str" || category.Content[2].Tag != "!!str" || category.Content[4].Tag != "!!str" {
t.Fatal("mapping keys are not explicitly tagged as YAML strings")
}
}
func TestPlaceYAMLValueHandlesRootAndEmptyKey(t *testing.T) {
var placer yamlValuePlacer
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
root := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}
updated, err := placer.placeValue(category, nil, root)
if err != nil || updated != root {
t.Fatalf("root placement = %#v, %v", updated, err)
}
category = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
value := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "empty-key"}
updated, err = placer.placeValue(category, []string{""}, value)
if err != nil {
t.Fatal(err)
}
if updated != category || len(category.Content) != 2 || category.Content[0].Value != "" || category.Content[0].Tag != "!!str" || category.Content[1] != value {
t.Fatalf("slash path placement = %#v", category)
}
}
func TestPlaceYAMLValueRejectsIncompatibleAncestors(t *testing.T) {
var placer yamlValuePlacer
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{
stringKey("parent"), {Kind: yaml.ScalarNode, Tag: "!!str", Value: "leaf"},
}}
_, err := placer.placeValue(category, []string{"parent", "child"}, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"})
if err == nil || !strings.Contains(err.Error(), "/parent/child") || !strings.Contains(err.Error(), "/parent") {
t.Fatalf("incompatible ancestor error = %v", err)
}
}
func TestDecodeSafeSnapshotRejectsMalformedYAML(t *testing.T) {
if _, err := decodeSafeSnapshot("value: [unterminated\n"); err == nil {
t.Fatal("malformed snapshot unexpectedly decoded")
}
}

View File

@@ -0,0 +1,9 @@
// Package ckconfig provides CommandKit's optional ConfigKit integration. Its
// canonical Command declaration installs the standard config command family
// through the core commandkit package. Applications can install CommandKit
// RoutingRule declarations alongside it through commandkit.New or
// App.Register. Source reports preserve ConfigKit's destination, placement,
// and version-check evidence. Invocation reporting applies CommandKit's shared
// visibility and delivery policy; provider warnings remain independent of
// failure explanations. Importing ckconfig alone installs no commands.
package ckconfig

View File

@@ -0,0 +1,191 @@
# Open Source Attribution Notices
## ConfigKit
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.8.0`.
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [LGPL v3.0](../../LICENSE) |
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5).
## go-yaml v4
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.
Copyright 2006-2010 Kirill Simonov
Copyright 2011-2019 Canonical Ltd
Copyright 2025 The go-yaml Project Contributors
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
[MIT](licenses/MIT.txt) |
[LICENSE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/LICENSE) |
[NOTICE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/NOTICE).
[Source](https://github.com/yaml/go-yaml/tree/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125).
## Cobra
Selected module: `github.com/spf13/cobra` at `v1.10.1`.
SPDX-License-Identifier: Apache-2.0
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
[LICENSE.txt](https://github.com/spf13/cobra/blob/7da941c3547e93b8c9f70bbd3befca79c6335388/LICENSE.txt).
[Source](https://github.com/spf13/cobra/tree/7da941c3547e93b8c9f70bbd3befca79c6335388).
## pflag
Selected module: `github.com/spf13/pflag` at `v1.0.9`.
Copyright (c) 2012 Alex Ogier. All rights reserved.
Copyright (c) 2012 The Go Authors. All rights reserved.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
[LICENSE](https://github.com/spf13/pflag/blob/10438578954bba2527fe5cae3684d4532b064bbe/LICENSE).
[Source](https://github.com/spf13/pflag/tree/10438578954bba2527fe5cae3684d4532b064bbe).
## go-md2man
Selected module: `github.com/cpuguy83/go-md2man/v2` at `v2.0.6` (transitive dependency).
Copyright (c) 2014 Brian Goff
SPDX-License-Identifier: MIT
License and notices: [MIT](licenses/MIT.txt) |
[LICENSE.md](https://github.com/cpuguy83/go-md2man/blob/441631534462b74c098953dbd8d5f6210994b0bf/LICENSE.md).
[Source](https://github.com/cpuguy83/go-md2man/tree/441631534462b74c098953dbd8d5f6210994b0bf).
## mousetrap
Selected module: `github.com/inconshreveable/mousetrap` at `v1.1.0` (transitive dependency).
SPDX-License-Identifier: Apache-2.0
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
[LICENSE](https://github.com/inconshreveable/mousetrap/blob/4e8053ee7ef85a6bd26368364a6d27f1641c1d21/LICENSE).
[Source](https://github.com/inconshreveable/mousetrap/tree/4e8053ee7ef85a6bd26368364a6d27f1641c1d21).
## Blackfriday
Selected module: `github.com/russross/blackfriday/v2` at `v2.1.0` (transitive dependency).
Copyright © 2011 Russ Ross
SPDX-License-Identifier: BSD-2-Clause
License and notices: [BSD 2-Clause](licenses/BSD-2-Clause.txt) |
[LICENSE.txt](https://github.com/russross/blackfriday/blob/4c9bf9512682b995722660a4196c0013228e2049/LICENSE.txt).
[Source](https://github.com/russross/blackfriday/tree/4c9bf9512682b995722660a4196c0013228e2049).
## gocheck
Selected module: `gopkg.in/check.v1` at `v0.0.0-20161208181325-20d25e280405` (transitive test dependency).
Gocheck - A rich testing framework for Go
Copyright (c) 2010-2013 Gustavo Niemeyer <gustavo@niemeyer.net>
SPDX-License-Identifier: BSD-2-Clause
License and notices: [BSD 2-Clause](licenses/BSD-2-Clause.txt) |
[LICENSE](https://github.com/go-check/check/blob/20d25e280405/LICENSE).
[Source](https://github.com/go-check/check/commit/20d25e280405).
## Standard Repository Policies
Material in the CommandKit core dependency (paths relative to the repository
root): `docs/policy/meta.md`, `docs/policy/agents.md`,
`docs/policy/architecture.md`, `docs/policy/testing.md`,
`docs/policy/documentation.md`, `docs/policy/release.md`, and
`docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph
instructions; and the Policy Reading Guide in `docs/development.md`, adapted for
the core module. The release policy uses baseline
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses baseline
`d84a38ebab26f048cafbe59a2246700c35d29843`.
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: CC-BY-SA-4.0
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
## go-yaml v3
Selected module: `gopkg.in/yaml.v3` at `v3.0.1` (transitive dependency).
Copyright (c) 2006-2010 Kirill Simonov
Copyright (c) 2006-2011 Kirill Simonov
Copyright 2011-2016 Canonical Ltd.
License and notices: [MIT](licenses/MIT.txt) |
[Apache 2.0](licenses/Apache-2.0.txt) |
[LICENSE](https://github.com/go-yaml/yaml/blob/f6f7691b1fdeb513f56608cd2c32c51f8194bf51/LICENSE) |
[NOTICE](https://github.com/go-yaml/yaml/blob/f6f7691b1fdeb513f56608cd2c32c51f8194bf51/NOTICE).
[Source](https://github.com/go-yaml/yaml/tree/f6f7691b1fdeb513f56608cd2c32c51f8194bf51).
## ReleaseKit
Selected module: `gitea.maximumdirect.net/eric/ReleaseKit` at `v0.4.2` (transitive Go tool requirement; not a runtime import).
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [LGPL v3.0](../../LICENSE) |
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8).
## Goldmark
Selected module: `github.com/yuin/goldmark/v2` at `v2.1.6` (ReleaseKit tool dependency).
Copyright (c) 2019 Yusuke Inuzuka
SPDX-License-Identifier: MIT
License and notices: [MIT](licenses/MIT.txt) |
[LICENSE](https://github.com/yuin/goldmark/blob/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c/LICENSE).
[Source](https://github.com/yuin/goldmark/tree/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c).
## Go modules
Selected module: `golang.org/x/mod` at `v0.40.0` (ReleaseKit tool dependency).
Copyright 2009 The Go Authors.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
[LICENSE](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f/LICENSE).
[Source](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f).

View File

@@ -0,0 +1,73 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,9 @@
Copyright (c) <year> <owner>
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,11 @@
Copyright (c) <year> <owner>.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,170 @@
Creative Commons Attribution-ShareAlike 4.0 International
Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors.
Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public.
Creative Commons Attribution-ShareAlike 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Section 1 – Definitions.
a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
c. BY-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses, approved by Creative Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
e. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike.
h. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
i. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights under this Public License.
k. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
l. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
m. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
Section 2 – Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
A. reproduce and Share the Licensed Material, in whole or in part; and
B. produce, reproduce, and Share Adapted Material.
2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
3. Term. The term of this Public License is specified in Section 6(a).
4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
5. Downstream recipients.
A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
B. Additional offer from the Licensor – Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply.
C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this Public License.
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties.
Section 3 – License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified form), You must:
A. retain the following if it is supplied by the Licensor with the Licensed Material:
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of warranties;
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
b. ShareAlike.In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
Section 4 – Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
Section 5 – Disclaimer of Warranties and Limitation of Liability.
a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.
b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.
c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
Section 6 – Term and Termination.
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
2. upon express reinstatement by the Licensor.
c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
Section 7 – Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
Section 8 – Interpretation.
a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.
Creative Commons may be contacted at creativecommons.org.

View File

@@ -0,0 +1,18 @@
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,45 @@
# CommandKit ckconfig v0.1.0
## Summary
The initial independently versioned ConfigKit integration provides configuration
inspection and native comparison through CommandKit command declarations. It
uses CommandKit v0.2.0 and ConfigKit v0.8.0 and is distributed as a source-only
Go module.
## Features
- `ckconfig.AppCommand`, `ckconfig.Command`, and `ckconfig.FromConfigKit` adapt
application loaders and named scopes into explicitly installed declarations.
- The `config` family exposes scopes, validation, effective YAML, sources, and
path explanation according to each scope's declared capabilities.
- `config diff` compares two selections through ConfigKit's native API and emits
YAML additions, right-side changes, and deletion paths. Comparison can be
provided independently of ordinary configuration acquisition; differences
alone do not make the command fail.
- Source reports expose native destination, placement, unresolved destination,
and version-check evidence alongside the source inventory.
- Typed selectors, per-side comparison inputs, and invocation state support
application-specific acquisition without transferring configuration semantics
to CommandKit.
## Requirements and adoption
**Go 1.26.0 or newer is required.** Add
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig@v0.1.0`, then pass
the returned declaration to `commandkit.New` or `App.Register`. Importing the
module does not install commands.
Applications migrating from the ConfigKit helpers in CommandKit v0.1.0 must
move to the separate module and update scope, loader, binding, and installation
wiring for the core v0.2.0 API. Update report consumers for the added source
evidence and any newly installed comparison commands. ConfigKit remains the
authority on configuration loading, inspection, and comparison semantics.
See the [module README](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/integrations/ckconfig/README.md),
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/docs/consumers/guide.md),
and [CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/docs/cli.md).
[COPYRIGHT](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/integrations/ckconfig/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/integrations/ckconfig/LICENSE) |
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/integrations/ckconfig/docs/oss/attribution.md)

View File

@@ -0,0 +1,22 @@
# Simple consumer
Run the maintained example with:
```sh
go run . config show
go run . config show --config fixtures/before.yml --verbose
go run . config show --config fixtures/before.yml --quiet
go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml
go run . greet
go run . greet --count=0 --tag=first --tag second
```
The `config` family comes from one typed ConfigKit scope. The custom `greet`
command uses CommandKit's typed bindings and shows the difference between an
omitted `--count` and an explicitly supplied zero, plus ordered repeated tags.
With no `--config`, application settings use the in-memory default. A supplied
configuration and overlay are passed to ConfigKit unchanged. The comparison
command loads the two local fixtures and reports `changes.greeting: Goodbye`.
The before fixture uses schema version 1, which ConfigKit reports as a provider
warning in verbose mode. Ordinary mode hides the warning while preserving the
effective YAML report; quiet mode suppresses that report.

View File

@@ -0,0 +1,3 @@
schema:
version: 2
greeting: Goodbye

View File

@@ -0,0 +1,3 @@
schema:
version: 1
greeting: Hello

View File

@@ -0,0 +1,107 @@
package main
import (
"context"
"fmt"
"io"
"os"
"strconv"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type appConfiguration struct {
Schema struct {
Version int `yaml:"version"`
} `yaml:"schema"`
Greeting string `yaml:"greeting"`
}
type greetInput struct {
Count commandkit.Supplied[int]
Tags commandkit.Supplied[[]string]
}
func loadAppConfiguration(_ context.Context, selection ckconfig.Selection) (*configkit.Result[appConfiguration], error) {
source := configkit.Mapping("simple application settings", map[string]any{
"schema": map[string]any{"version": 2},
"greeting": "Hello",
})
if selection.Config.Set {
source = configkit.File(selection.Config.Value)
}
options := configkit.Options[appConfiguration]{
Definition: configkit.Definition[appConfiguration]{
Version: &configkit.VersionRule{
Path: configkit.Keys("schema", "version"),
Check: func(version configkit.Version, validation *configkit.Validation) error {
if version.Text == "1" {
validation.Warning(configkit.Keys("schema", "version"), "schema version 1 is supported; upgrade to version 2 before the next example release")
}
return nil
},
},
},
}
if selection.Overlay.Set {
options.Overlay = selection.Overlay.Value
options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."}
}
return configkit.Load(source, options)
}
func newApp() (*commandkit.App, error) {
countDefault := 1
tagDefaults := []string{"example"}
greet := commandkit.NewCommand(commandkit.CommandSpec[greetInput]{
Name: "greet",
Summary: "Show flag presence and repeated values.",
Flags: []commandkit.Flag[greetInput]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Usage: "Greeting count."}, &countDefault, strconv.Atoi, func(input *greetInput, value commandkit.Supplied[int]) {
input.Count = value
}),
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "tag", Usage: "A repeatable label."}, tagDefaults, func(value string) (string, error) { return value, nil }, func(input *greetInput, value commandkit.Supplied[[]string]) {
input.Tags = value
}),
},
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
reporter := invocation.Reporter()
configuration, err := loadAppConfiguration(invocation.Context, ckconfig.Selection{})
if err != nil {
return commandkit.Failure("Could not load application configuration.").WithCause(err)
}
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
if input.Count.Set {
count = fmt.Sprintf("explicit %d", input.Count.Value)
}
tags := fmt.Sprintf("default %v (not supplied)", input.Tags.Value)
if input.Tags.Set {
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
}
_ = reporter.RenderOutput(func(writer io.Writer) error {
_, err := fmt.Fprintf(writer, "%s; count=%s; tags=%s\n", configuration.Config.Greeting, count, tags)
return err
})
return commandkit.Success()
},
})
return commandkit.New(
commandkit.Identity{Name: "simple-example", Version: "1.0"},
ckconfig.AppCommand(loadAppConfiguration),
greet,
)
}
func main() {
app, err := newApp()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
outcome := app.Execute(context.Background(), os.Args[1:], commandkit.Streams{Out: os.Stdout, Err: os.Stderr})
if outcome.Status != 0 {
os.Exit(outcome.Status)
}
}

View File

@@ -0,0 +1,149 @@
package main
import (
"bytes"
"context"
"io"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
"go.yaml.in/yaml/v4"
)
func TestSimpleAppComparesFixturesAndKeepsDefaultCommands(t *testing.T) {
app, err := newApp()
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "fixtures/before.yml", "--against-config", "fixtures/after.yml"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("fixture comparison outcome = %#v", outcome)
}
var report yaml.Node
if err := yaml.Unmarshal(output.Bytes(), &report); err != nil {
t.Fatalf("comparison report is not YAML: %q: %v", output.String(), err)
}
changes := field(report.Content[0], "changes")
greeting := field(changes, "greeting")
if greeting == nil || greeting.Value != "Goodbye" {
t.Fatalf("fixture comparison report = %q", output.String())
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--config", "fixtures/before.yml", "--against-config", "fixtures/before.yml"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("equal fixture comparison outcome = %#v", outcome)
}
if reportNode, err := decodeReport(output.Bytes()); err != nil || len(field(reportNode, "additions").Content) != 0 || len(field(reportNode, "changes").Content) != 0 || len(field(reportNode, "deletions").Content) != 0 {
t.Fatalf("equal fixture report = %q, decode error %v", output.String(), err)
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "show"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "greeting: Hello\nschema:\n version: 2\n" {
t.Fatalf("default show outcome/output = %#v / %q", outcome, output.String())
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"greet"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || len(output.String()) == 0 || output.String()[:5] != "Hello" {
t.Fatalf("default greeting outcome/output = %#v / %q", outcome, output.String())
}
}
func TestSimpleLoaderDoesNotReplaceExplicitEmptyConfiguration(t *testing.T) {
_, err := loadAppConfiguration(context.Background(), ckconfig.Selection{Config: commandkit.Supplied[string]{Set: true}})
if err == nil {
t.Fatal("explicit empty configuration fell back to the default source")
}
}
func TestSimpleConfigurationReportingModes(t *testing.T) {
for _, test := range []struct {
name string
args []string
wantOutput string
wantDiagnostic string
quiet bool
}{
{
name: "ordinary output hides provider warning",
args: []string{"config", "show", "--config", "fixtures/before.yml"},
wantOutput: "greeting: Hello\nschema:\n version: 1\n",
},
{
name: "quiet suppresses configuration report",
args: []string{"config", "show", "--config", "fixtures/before.yml", "--quiet"},
quiet: true,
},
{
name: "verbose shows ConfigKit warning",
args: []string{"config", "show", "--config", "fixtures/before.yml", "--verbose"},
wantOutput: "greeting: Hello\nschema:\n version: 1\n",
wantDiagnostic: "schema version 1 is supported",
},
} {
t.Run(test.name, func(t *testing.T) {
outcome, output, diagnostics := executeSimple(t, test.args...)
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Cause != nil {
t.Fatalf("outcome = %#v", outcome)
}
if output != test.wantOutput {
t.Errorf("output = %q, want %q", output, test.wantOutput)
}
if test.wantDiagnostic != "" && !strings.Contains(diagnostics, test.wantDiagnostic) {
t.Errorf("diagnostics = %q, want provider warning %q", diagnostics, test.wantDiagnostic)
}
if test.wantDiagnostic == "" && diagnostics != "" {
t.Errorf("diagnostics = %q, want no warning outside verbose mode", diagnostics)
}
if strings.Contains(diagnostics, "ERROR:") {
t.Errorf("diagnostics %q unexpectedly contain a terminal failure", diagnostics)
}
if test.quiet && (outcome.Presentation.Output.State != commandkit.DeliverySuppressed || outcome.Presentation.Diagnostics.State != commandkit.DeliverySuppressed) {
t.Errorf("quiet presentation = %#v, want report and provider warning suppressed", outcome.Presentation)
}
})
}
}
func TestSimpleCustomCommandUsesSharedReportingFlags(t *testing.T) {
outcome, output, diagnostics := executeSimple(t, "greet", "--quiet")
if outcome.Kind != commandkit.OutcomeSuccess || output != "" || diagnostics != "" {
t.Fatalf("quiet greet outcome/output/diagnostics = %#v / %q / %q", outcome, output, diagnostics)
}
}
func executeSimple(t *testing.T, args ...string) (commandkit.Outcome, string, string) {
t.Helper()
app, err := newApp()
if err != nil {
t.Fatal(err)
}
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostics})
return outcome, output.String(), diagnostics.String()
}
func field(mapping *yaml.Node, name string) *yaml.Node {
if mapping == nil || mapping.Kind != yaml.MappingNode {
return nil
}
for index := 0; index+1 < len(mapping.Content); index += 2 {
if mapping.Content[index].Value == name {
return mapping.Content[index+1]
}
}
return nil
}
func decodeReport(data []byte) (*yaml.Node, error) {
var document yaml.Node
if err := yaml.Unmarshal(data, &document); err != nil {
return nil, err
}
return document.Content[0], nil
}

View File

@@ -0,0 +1,153 @@
package ckconfig
import (
"bytes"
"fmt"
"io"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type explanationReport struct {
Scope string `yaml:"scope"`
Path string `yaml:"path"`
Effective snapshotDTO `yaml:"effective"`
Origin explanationOriginDTO `yaml:"origin"`
History []explanationChangeDTO `yaml:"history"`
SecretReference *secretReferenceDTO `yaml:"secret_reference"`
}
type snapshotDTO struct {
Present bool `yaml:"present"`
Redacted bool `yaml:"redacted"`
YAML string `yaml:"yaml"`
}
type explanationOriginDTO struct {
Layer string `yaml:"layer"`
Locations []locationDTO `yaml:"locations"`
Operation string `yaml:"operation"`
Inheritance []inheritanceStepDTO `yaml:"inheritance"`
}
type inheritanceStepDTO struct {
From string `yaml:"from"`
To string `yaml:"to"`
Reference string `yaml:"reference"`
Locations []locationDTO `yaml:"locations"`
}
type explanationChangeDTO struct {
Before snapshotDTO `yaml:"before"`
After snapshotDTO `yaml:"after"`
Origin explanationOriginDTO `yaml:"origin"`
Reasons []string `yaml:"reasons"`
Dependencies []string `yaml:"dependencies"`
}
type secretReferenceDTO struct {
Name string `yaml:"name"`
}
func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
selected := plan.scopes[0]
name := selected.scope.scope.Name
reporter := invocation.Reporter()
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration explanation was canceled.", commandkit.Explanation{}, err)
}
if selected.scope.scope.Acquire == nil {
cause := fmt.Errorf("configuration scope %q has no acquisition callback", name)
return configFailure(fmt.Sprintf("Unable to explain configuration scope %q.", name), commandkit.Explanation{}, cause)
}
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Explain,
Selection: plan.selection,
}, selected.input, state)
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if acquireErr != nil {
return configFailure(fmt.Sprintf("Unable to explain configuration scope %q.", name), acquired.Explanation, acquireErr)
}
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
}
if acquired.Value.Explanation == nil {
cause := fmt.Errorf("configuration scope %q did not provide an explanation reader", name)
return configFailure("Configuration scope did not provide explanation data.", acquired.Explanation, cause)
}
explanation, found := acquired.Value.Explanation.Explain(plan.path)
if !found {
cause := fmt.Errorf("configuration path %q is not known in scope %q", plan.path.String(), name)
return configFailure("The requested configuration path is not available.", acquired.Explanation, cause)
}
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
}
if plan.format != "yaml" {
err := reporter.RenderOutput(func(writer io.Writer) error {
return acquired.Value.Explanation.WriteExplanation(writer, plan.path)
})
if err != nil && invocation.Context.Err() != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
}
return commandkit.Success()
}
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
var report bytes.Buffer
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
return nil, err
}
return report.Bytes(), nil
})
}
func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {
report := explanationReport{
Scope: scope,
Path: explanation.Path.String(),
Effective: snapshotReport(explanation.Effective),
Origin: originReport(explanation.Origin),
History: make([]explanationChangeDTO, 0, len(explanation.History)),
}
for _, change := range explanation.History {
entry := explanationChangeDTO{
Before: snapshotReport(change.Before),
After: snapshotReport(change.After),
Origin: originReport(change.Origin),
Reasons: append([]string{}, change.Reasons...),
Dependencies: make([]string, 0, len(change.Dependencies)),
}
for _, dependency := range change.Dependencies {
entry.Dependencies = append(entry.Dependencies, dependency.String())
}
report.History = append(report.History, entry)
}
if explanation.SecretReference != nil {
report.SecretReference = &secretReferenceDTO{Name: explanation.SecretReference.Name}
}
return report
}
func snapshotReport(snapshot configkit.ValueSnapshot) snapshotDTO {
return snapshotDTO{Present: snapshot.Present, Redacted: snapshot.Redacted, YAML: snapshot.YAML}
}
func originReport(origin configkit.Origin) explanationOriginDTO {
report := explanationOriginDTO{
Layer: string(origin.Layer),
Locations: locationReports(origin.Locations),
Operation: string(origin.Operation),
Inheritance: make([]inheritanceStepDTO, 0, len(origin.Inheritance)),
}
for _, step := range origin.Inheritance {
report.Inheritance = append(report.Inheritance, inheritanceStepDTO{
From: step.From.String(),
To: step.To.String(),
Reference: step.Reference.String(),
Locations: locationReports(step.Locations),
})
}
return report
}

View File

@@ -0,0 +1,315 @@
package ckconfig
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"sort"
"strings"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type scopeMetadata struct {
Name string `yaml:"name"`
Operations []Operation `yaml:"operations"`
}
type scopeInventory struct {
Scopes []scopeMetadata `yaml:"scopes"`
}
type preparedScope[I, S any] struct {
scope Scope[I, S]
bindings []commandkit.Flag[I]
}
type preparedSuite[I, S any] struct {
newInput func() I
newState func() S
checkInput func(string, Selection, I) error
scopes []*preparedScope[I, S]
byName map[string]*preparedScope[I, S]
bindingsByOperation map[Operation][]commandkit.Flag[I]
scopeChoices string
operations []Operation
inventory scopeInventory
issues []error
}
// Command eagerly captures a configuration suite as a root-only config family.
// It copies suite slices, retains callback references, and returns a reusable
// declaration; installation does not invoke factories or providers. During an
// inspection, it initializes input separately for each selected scope. A
// comparison initializes and checks both sides before creating shared state
// and calling the pair provider. Suite documents factory timing, zero values,
// shared state, and caller ownership of returned objects.
func Command[I, S any](suite Suite[I, S]) commandkit.Command {
prepared := captureSuite(suite)
if len(prepared.scopes) == 0 {
prepared.issues = append(prepared.issues, errors.New("configuration suite must declare at least one named scope"))
return commandkit.WithError(commandkit.NoCommand(), errors.Join(prepared.issues...))
}
children := make([]commandkit.Command, 0, len(prepared.operations)+1)
for _, operation := range prepared.operations {
children = append(children, newConfigOperationCommand(prepared, operation))
}
children = append(children, newConfigScopesCommand(prepared))
declaration := commandkit.RootGroup("config", "Inspect configuration.", children...)
if len(prepared.issues) > 0 {
declaration = commandkit.WithError(declaration, errors.Join(prepared.issues...))
}
return declaration
}
// AppCommand builds the state-free, input-free convenience suite for application
// configuration. The application consumer supplies only its ConfigKit loader,
// which runs for ordinary operations and separately for each reached comparison
// side after input preflight.
func AppCommand[T any](load func(context.Context, Selection) (*configkit.Result[T], error)) commandkit.Command {
var adapterLoad func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[T], error)
if load != nil {
adapterLoad = func(ctx context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[T], error) {
return load(ctx, selection)
}
}
adapted := FromConfigKit[struct{}, struct{}, T]("app", "Application configuration.", adapterLoad)
return Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{adapted}})
}
func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
prepared := &preparedSuite[I, S]{
newInput: suite.NewInput,
newState: suite.NewState,
checkInput: suite.CheckInput,
byName: make(map[string]*preparedScope[I, S], len(suite.Scopes)),
bindingsByOperation: make(map[Operation][]commandkit.Flag[I]),
}
selectors := make([]Selector[I], len(suite.Selectors))
for index, selector := range suite.Selectors {
selector.Scopes = append([]string(nil), selector.Scopes...)
selectors[index] = selector
}
seenNames := make(map[string]struct{}, len(suite.Scopes))
for _, original := range suite.Scopes {
scope := original
scope.Operations = normalizeOperations(scope.Operations, &prepared.issues, scope.Name)
scope.issues = append([]error(nil), original.issues...)
if scope.Name == "" {
prepared.issues = append(prepared.issues, errors.New("configuration scope name must not be empty"))
}
if scope.Name == "all" {
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope name %q is reserved", scope.Name))
}
if _, exists := seenNames[scope.Name]; exists {
prepared.issues = append(prepared.issues, fmt.Errorf("duplicate configuration scope %q", scope.Name))
} else {
seenNames[scope.Name] = struct{}{}
}
for _, issue := range scope.issues {
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q: %w", scope.Name, issue))
}
requiresAcquire, requiresCompare := false, false
for _, operation := range scope.Operations {
if operation == Diff {
requiresCompare = true
} else {
requiresAcquire = true
}
}
if requiresAcquire && scope.Acquire == nil {
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q declares operations without an acquisition callback", scope.Name))
}
if requiresCompare && scope.Compare == nil {
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q declares diff without a comparison callback", scope.Name))
}
prepared.scopes = append(prepared.scopes, &preparedScope[I, S]{scope: scope})
}
sort.SliceStable(prepared.scopes, func(i, j int) bool {
return scopeOrder(prepared.scopes[i].scope.Name) < scopeOrder(prepared.scopes[j].scope.Name)
})
names := make([]string, 0, len(prepared.scopes))
for _, scope := range prepared.scopes {
prepared.byName[scope.scope.Name] = scope
names = append(names, scope.scope.Name)
}
prepared.scopeChoices = joinNames(names)
for index, selector := range selectors {
seen := make(map[string]struct{}, len(selector.Scopes))
uniqueScopes := make([]string, 0, len(selector.Scopes))
for _, name := range selector.Scopes {
if _, exists := seenNames[name]; !exists {
prepared.issues = append(prepared.issues, fmt.Errorf("configuration selector %d references unknown scope %q", index, name))
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
uniqueScopes = append(uniqueScopes, name)
}
selectors[index].Scopes = uniqueScopes
if len(selector.Scopes) == 0 {
for scopeIndex := range prepared.scopes {
if len(prepared.scopes[scopeIndex].scope.Operations) > 0 {
prepared.scopes[scopeIndex].bindings = append(prepared.scopes[scopeIndex].bindings, selector.Binding)
}
}
} else {
for _, name := range uniqueScopes {
if scope, exists := prepared.byName[name]; exists && len(scope.scope.Operations) > 0 {
scope.bindings = append(scope.bindings, selector.Binding)
}
}
}
}
for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} {
for _, scope := range prepared.scopes {
if containsOperation(scope.scope.Operations, operation) {
prepared.operations = append(prepared.operations, operation)
break
}
}
for _, selector := range selectors {
for _, scope := range prepared.scopes {
if containsOperation(scope.scope.Operations, operation) && selectorApplies(selector.Scopes, scope.scope.Name) {
prepared.bindingsByOperation[operation] = append(prepared.bindingsByOperation[operation], selector.Binding)
break
}
}
}
}
for _, scope := range prepared.scopes {
prepared.inventory.Scopes = append(prepared.inventory.Scopes, scopeMetadata{
Name: scope.scope.Name,
Operations: append([]Operation(nil), scope.scope.Operations...),
})
}
return prepared
}
func normalizeOperations(operations []Operation, issues *[]error, scope string) []Operation {
seen := make(map[Operation]struct{}, len(operations))
for _, operation := range operations {
switch operation {
case Validate, Show, Sources, Explain, Diff:
default:
*issues = append(*issues, fmt.Errorf("configuration scope %q declares unknown operation %q", scope, operation))
}
seen[operation] = struct{}{}
}
ordered := make([]Operation, 0, len(seen))
for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} {
if _, exists := seen[operation]; exists {
ordered = append(ordered, operation)
}
}
return ordered
}
func scopeOrder(name string) string {
switch name {
case "app":
return "\x00"
case "promptkit":
return "\x01"
default:
return "\x02" + name
}
}
func containsOperation(operations []Operation, requested Operation) bool {
for _, operation := range operations {
if operation == requested {
return true
}
}
return false
}
func selectorApplies(scopes []string, name string) bool {
if len(scopes) == 0 {
return true
}
for _, scope := range scopes {
if scope == name {
return true
}
}
return false
}
func joinNames(names []string) string {
ordered := append([]string(nil), names...)
sort.Strings(ordered)
if len(ordered) == 0 {
return "none"
}
return strings.Join(ordered, ", ")
}
func newConfigScopesCommand[I, S any](prepared *preparedSuite[I, S]) commandkit.Command {
formatDefault := "text"
spec := commandkit.CommandSpec[scopeInventoryInput]{
Name: "scopes",
Summary: "List configuration scopes and supported operations.",
Flags: []commandkit.Flag[scopeInventoryInput]{commandkit.ValueFlag(
commandkit.FlagMeta{Name: "format", Usage: "Output format: text or yaml."}, &formatDefault, parseConfigString,
func(input *scopeInventoryInput, value commandkit.Supplied[string]) { input.Format = value.Value },
)},
CheckInput: func(input scopeInventoryInput) error { return checkConfigFormat(input.Format) },
Run: func(invocation commandkit.Invocation, input scopeInventoryInput) commandkit.Outcome {
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
var report bytes.Buffer
if input.Format == "yaml" {
if err := encodeYAML(&report, prepared.inventory); err != nil {
return err
}
} else {
_, _ = report.WriteString(formatScopeInventory(prepared.inventory))
}
_, err := writer.Write(report.Bytes())
return err
})
return commandkit.Success()
},
}
return commandkit.NewCommand(spec)
}
type scopeInventoryInput struct{ Format string }
type scopeInventoryYAML struct {
Scopes []scopeMetadata `yaml:"scopes"`
}
func formatScopeInventory(inventory scopeInventory) string {
var output strings.Builder
for _, scope := range inventory.Scopes {
output.WriteString(scope.Name)
output.WriteString(": ")
for index, operation := range scope.Operations {
if index > 0 {
output.WriteString(", ")
}
output.WriteString(string(operation))
}
output.WriteByte('\n')
}
return output.String()
}
func checkConfigFormat(format string) error {
if format != "text" && format != "yaml" {
return fmt.Errorf("unsupported configuration format %q", format)
}
return nil
}
func parseConfigString(value string) (string, error) { return value, nil }

View File

@@ -0,0 +1,589 @@
package ckconfig
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestScopeDiscoveryIsOrderedAndDoesNotUseSuiteFactories(t *testing.T) {
inputCalls, stateCalls, acquireCalls := 0, 0, 0
suite := Suite[struct{}, struct{}]{
NewInput: func() struct{} { inputCalls++; return struct{}{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Scopes: []Scope[struct{}, struct{}]{
{Name: "zeta"},
{Name: "promptkit", Operations: []Operation{}},
{Name: "app", Summary: "Application settings."},
{Name: "alpha"},
},
}
for index := range suite.Scopes {
suite.Scopes[index].Acquire = func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, nil
}
}
// An unused malformed binding has no generated command to validate.
suite.Selectors = []Selector[struct{}]{{Binding: commandkit.Flag[struct{}]{}, Scopes: []string{"app"}}}
app := newConfigTestApp(t, Command(suite))
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("discovery outcome = %#v; diagnostics %q", outcome, diagnostic.String())
}
if got, want := output.String(), "app: \npromptkit: \nalpha: \nzeta: \n"; got != want {
t.Fatalf("discovery output = %q, want %q", got, want)
}
if inputCalls != 0 || stateCalls != 0 || acquireCalls != 0 {
t.Fatalf("discovery invoked suite work: input=%d state=%d acquire=%d", inputCalls, stateCalls, acquireCalls)
}
}
func TestOperationSelectorsAreCapturedForSupportedScopes(t *testing.T) {
type input struct{ Tenant, Campaign commandkit.Supplied[string] }
tenant := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant", Required: true}, (*string)(nil), parseConfigString,
func(target *input, value commandkit.Supplied[string]) { target.Tenant = value })
campaign := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Required: true}, (*string)(nil), parseConfigString,
func(target *input, value commandkit.Supplied[string]) { target.Campaign = value })
calls := 0
suite := Suite[input, struct{}]{
Selectors: []Selector[input]{{Binding: tenant, Scopes: []string{"app"}}, {Binding: campaign, Scopes: []string{"promptkit"}}},
Scopes: []Scope[input, struct{}]{
{Name: "app", Operations: []Operation{Validate}, Acquire: func(_ context.Context, _ Request, value input, _ struct{}) (commandkit.Acquired[View], error) {
calls++
if !value.Tenant.Set || value.Tenant.Value != "team" || value.Campaign.Set {
t.Errorf("app input = %#v", value)
}
return commandkit.Acquired[View]{}, nil
}},
{Name: "promptkit", Operations: []Operation{Sources}, Acquire: func(_ context.Context, _ Request, value input, _ struct{}) (commandkit.Acquired[View], error) {
calls++
if !value.Campaign.Set || value.Campaign.Value != "spring" || value.Tenant.Set {
t.Errorf("promptkit input = %#v", value)
}
return commandkit.Acquired[View]{Value: View{Sources: &sourceInventory{}}}, nil
}},
},
}
app := newConfigTestApp(t, Command(suite))
// Captured capabilities, applicability, and handles survive source edits.
suite.Scopes[0].Operations[0] = Sources
suite.Selectors[0].Scopes[0] = "promptkit"
suite.Selectors[1].Binding = commandkit.Flag[input]{}
for _, args := range [][]string{
{"config", "validate", "--tenant", "team"},
{"config", "sources", "--scope", "promptkit", "--campaign", "spring"},
} {
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("%v: %#v", args, outcome)
}
}
for _, args := range [][]string{
{"config", "validate", "--campaign", "spring"},
{"config", "sources", "--scope", "promptkit", "--tenant", "team"},
} {
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage {
t.Fatalf("selector for another operation accepted: %v: %#v", args, outcome)
}
}
if calls != 2 {
t.Fatalf("acquisition calls = %d, want 2", calls)
}
}
func TestValidationPreparesIndependentScopeInputsAndOneSharedState(t *testing.T) {
type inspectInput struct {
Campaign commandkit.Supplied[string]
Retained string
}
type runState struct{ ID int }
var inputCalls, stateCalls, checkCalls int
var checked []*inspectInput
var acquired []*inspectInput
var states []*runState
var checkErr error
commandkitFlag := commandkit.ValueFlag[*inspectInput, string](
commandkit.FlagMeta{Name: "campaign", Usage: "Campaign selector.", Required: true},
(*string)(nil), func(value string) (string, error) { return value, nil },
func(input **inspectInput, value commandkit.Supplied[string]) { (*input).Campaign = value },
)
makeScope := func(name string) Scope[*inspectInput, *runState] {
return Scope[*inspectInput, *runState]{Name: name, Operations: []Operation{Validate}, Acquire: func(_ context.Context, request Request, input *inspectInput, state *runState) (commandkit.Acquired[View], error) {
if request.Operation != Validate || !request.Selection.Config.Set || request.Selection.Config.Value != "chosen.yaml" {
t.Errorf("request = %#v", request)
}
acquired = append(acquired, input)
states = append(states, state)
return commandkit.Acquired[View]{Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(commandkit.SeverityWarning, name+" warning")}}, nil
}}
}
suite := Suite[*inspectInput, *runState]{
NewInput: func() *inspectInput {
inputCalls++
return &inspectInput{Retained: fmt.Sprintf("initial-%d", inputCalls)}
},
NewState: func() *runState {
stateCalls++
if checkCalls != 2 {
t.Errorf("shared state created after %d input checks", checkCalls)
}
return &runState{ID: stateCalls}
},
Selectors: []Selector[*inspectInput]{{Binding: commandkitFlag, Scopes: []string{"promptkit"}}},
CheckInput: func(name string, selection Selection, input *inspectInput) error {
checkCalls++
checked = append(checked, input)
if !selection.Config.Set || input.Retained == "" {
return fmt.Errorf("missing initialized input for %s", name)
}
if name == "app" && input.Campaign.Set {
return fmt.Errorf("inapplicable selector was applied to app")
}
if name == "promptkit" && (!input.Campaign.Set || input.Campaign.Value != "spring") {
return fmt.Errorf("promptkit campaign = %#v", input.Campaign)
}
if checkErr != nil {
return checkErr
}
return nil
},
Scopes: []Scope[*inspectInput, *runState]{makeScope("promptkit"), makeScope("app")},
}
app := newConfigTestApp(t, Command(suite))
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--config", "chosen.yaml", "--campaign", "spring", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(diagnostic.String(), "app warning") || !strings.Contains(diagnostic.String(), "promptkit warning") {
t.Fatalf("validation outcome=%#v diagnostics=%q", outcome, diagnostic.String())
}
if got, want := output.String(), "app: valid\npromptkit: valid\n"; got != want {
t.Fatalf("validation output = %q, want %q", got, want)
}
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || len(acquired) != 2 {
t.Fatalf("factory/callback counts input=%d check=%d state=%d acquire=%d", inputCalls, checkCalls, stateCalls, len(acquired))
}
if checked[0] != acquired[0] || checked[1] != acquired[1] || checked[0] == checked[1] {
t.Fatalf("scope inputs were not retained independently: checked=%p,%p acquired=%p,%p", checked[0], checked[1], acquired[0], acquired[1])
}
if states[0] == nil || states[0] != states[1] || states[0].ID != 1 {
t.Fatalf("shared state values = %#v", states)
}
}
func TestValidationChecksAggregateCoverageBeforeFactories(t *testing.T) {
newInputCalls, newStateCalls, acquireCalls := 0, 0, 0
makeScope := func(name string, operations []Operation) Scope[struct{}, struct{}] {
return Scope[struct{}, struct{}]{Name: name, Operations: operations, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, nil
}}
}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{
NewInput: func() struct{} { newInputCalls++; return struct{}{} },
NewState: func() struct{} { newStateCalls++; return struct{}{} },
Scopes: []Scope[struct{}, struct{}]{makeScope("app", []Operation{Validate}), makeScope("promptkit", []Operation{Show})},
}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || newInputCalls != 0 || newStateCalls != 0 || acquireCalls != 0 || output.Len() != 0 {
t.Fatalf("unsupported aggregate outcome=%#v counts=%d/%d/%d output=%q", outcome, newInputCalls, newStateCalls, acquireCalls, output.String())
}
}
func TestRequiredSelectorAppliesOnlyToItsSelectedScope(t *testing.T) {
type input struct{ Value commandkit.Supplied[string] }
var calls []string
flag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.Value = value })
makeScope := func(name string) Scope[input, struct{}] {
return Scope[input, struct{}]{Name: name, Operations: []Operation{Validate}, Acquire: func(_ context.Context, _ Request, value input, _ struct{}) (commandkit.Acquired[View], error) {
calls = append(calls, name)
if name == "app" && value.Value.Set {
t.Errorf("tenant selector applied to app: %#v", value)
}
return commandkit.Acquired[View]{}, nil
}}
}
app := newConfigTestApp(t, Command(Suite[input, struct{}]{Selectors: []Selector[input]{{Binding: flag, Scopes: []string{"promptkit"}}}, Scopes: []Scope[input, struct{}]{makeScope("app"), makeScope("promptkit")}}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "app"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || len(calls) != 1 || calls[0] != "app" {
t.Fatalf("unselected required selector outcome=%#v calls=%v", outcome, calls)
}
calls = nil
outcome = app.Execute(context.Background(), []string{"config", "validate", "--scope", "promptkit"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || len(calls) != 0 || !errors.Is(outcome.Cause, commandkit.ErrFlagInput) {
t.Fatalf("selected required selector outcome=%#v calls=%v", outcome, calls)
}
}
func TestConfigAliasesForwardSelectedTenantAndGlobalHelp(t *testing.T) {
type input struct{ Tenant commandkit.Supplied[string] }
flag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant"}, (*string)(nil),
func(value string) (string, error) { return value, nil },
func(target *input, value commandkit.Supplied[string]) { target.Tenant = value })
var acquired []commandkit.Supplied[string]
command := Command(Suite[input, struct{}]{
Selectors: []Selector[input]{{Binding: flag, Scopes: []string{"promptkit"}}},
Scopes: []Scope[input, struct{}]{
{
Name: "promptkit",
Operations: []Operation{Validate},
Acquire: func(_ context.Context, _ Request, value input, _ struct{}) (commandkit.Acquired[View], error) {
acquired = append(acquired, value.Tenant)
return commandkit.Acquired[View]{}, nil
},
},
},
})
app, err := commandkit.New(commandkit.Identity{Name: "example"},
command,
commandkit.Alias("check-config", "config validate"),
commandkit.FlagAliasFor("config validate", "--tenant-id", "--tenant"),
commandkit.FlagAlias("--hlep", "--help"),
)
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"check-config", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "validate") || len(acquired) != 0 {
t.Fatalf("global help alias outcome/output/acquisitions = (%#v, %q, %#v)", outcome, output.String(), acquired)
}
outcome = app.Execute(context.Background(), []string{"check-config", "--scope", "promptkit", "--tenant-id=acme"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || len(acquired) != 1 || acquired[0] != (commandkit.Supplied[string]{Value: "acme", Set: true}) {
t.Fatalf("aliased selector outcome/acquisitions = (%#v, %#v)", outcome, acquired)
}
outcome = app.Execute(context.Background(), []string{"config", "validate", "--scope", "promptkit", "--tenant=canonical"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || len(acquired) != 2 || acquired[1] != (commandkit.Supplied[string]{Value: "canonical", Set: true}) {
t.Fatalf("canonical selector outcome/acquisitions = (%#v, %#v)", outcome, acquired)
}
}
func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
var output bytes.Buffer
var acquisitionOutputLengths []int
makeScope := func(name, native string, acquireErr error) Scope[struct{}, *int] {
return Scope[struct{}, *int]{Name: name, Operations: []Operation{Show}, Acquire: func(_ context.Context, _ Request, _ struct{}, state *int) (commandkit.Acquired[View], error) {
*state++
acquisitionOutputLengths = append(acquisitionOutputLengths, output.Len())
if acquireErr != nil {
return commandkit.Acquired[View]{}, acquireErr
}
return commandkit.Acquired[View]{Value: View{Effective: effectiveValue(native)}}, nil
}}
}
providerErr := errors.New("second provider failed")
app := newConfigTestApp(t, Command(Suite[struct{}, *int]{NewState: func() *int { return new(int) }, Scopes: []Scope[struct{}, *int]{
makeScope("app", "---\nname: app\n", nil),
makeScope("promptkit", "---\nname: promptkit\n", providerErr),
}}))
outcome := app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr || output.Len() != 0 || outcome.Presentation.Output.State != commandkit.DeliveryNone {
t.Fatalf("aggregate provider failure outcome=%#v output=%q", outcome, output.String())
}
if len(acquisitionOutputLengths) != 2 || acquisitionOutputLengths[0] != 0 || acquisitionOutputLengths[1] != 0 {
t.Fatalf("show rendered before acquisition completed: %v", acquisitionOutputLengths)
}
output.Reset()
acquisitionOutputLengths = nil
app = newConfigTestApp(t, Command(Suite[struct{}, *int]{NewState: func() *int { return new(int) }, Scopes: []Scope[struct{}, *int]{
makeScope("app", "---\nname: app\n", nil),
makeScope("promptkit", "name: promptkit\n", nil),
}}))
outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Presentation.Output.State != commandkit.DeliveryComplete {
t.Fatalf("aggregate show outcome = %#v", outcome)
}
want := "---\n# scope: app\nname: app\n---\n# scope: promptkit\nname: promptkit\n"
if output.String() != want {
t.Fatalf("aggregate show output = %q, want %q", output.String(), want)
}
}
func TestConfigShowSeparatesWarningsFromTerminalContextAcrossModes(t *testing.T) {
providerErr := errors.New("private provider cause")
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{
Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(commandkit.SeverityWarning, "independent warning")},
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
_, err := io.WriteString(writer, "native terminal context\n")
return err
}}},
}, providerErr
},
}}}))
for _, test := range []struct {
name string
flags []string
wantWarning bool
wantTerminal bool
}{
{name: "ordinary", wantTerminal: true},
{name: "verbose", flags: []string{"--verbose"}, wantWarning: true, wantTerminal: true},
{name: "quiet", flags: []string{"--quiet"}, wantTerminal: true},
} {
t.Run(test.name, func(t *testing.T) {
args := append([]string{"config", "show"}, test.flags...)
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr {
t.Fatalf("provider failure outcome = %#v", outcome)
}
if got := strings.Contains(diagnostic.String(), "independent warning"); got != test.wantWarning {
t.Fatalf("warning visibility = %v, output %q", got, diagnostic.String())
}
if got := strings.Contains(diagnostic.String(), "native terminal context"); got != test.wantTerminal {
t.Fatalf("terminal context visibility = %v, output %q", got, diagnostic.String())
}
if strings.Contains(diagnostic.String(), providerErr.Error()) {
t.Fatalf("cause text was duplicated into presentation: %q", diagnostic.String())
}
if test.wantWarning && strings.Index(diagnostic.String(), "independent warning") > strings.Index(diagnostic.String(), "native terminal context") {
t.Fatalf("deferred warning did not precede terminal context: %q", diagnostic.String())
}
})
}
}
func TestConfigShowAndValidationUseManagedRendering(t *testing.T) {
t.Run("quiet show still records native render errors", func(t *testing.T) {
renderErr := errors.New("native renderer failed")
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Effective: failedEffective{err: renderErr}}}, nil
},
}}}))
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "show", "--quiet"}, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || outcome.Cause != nil || output.Len() != 0 {
t.Fatalf("quiet render failure outcome=%#v output=%q", outcome, output.String())
}
})
for _, operation := range []Operation{Show, Validate} {
t.Run(string(operation)+" write failure", func(t *testing.T) {
writeErr := errors.New("report destination failed")
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app",
Operations: []Operation{operation},
Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("value: ready\n")}}, nil
},
}}}))
commandName := "show"
if operation == Validate {
commandName = "validate"
}
outcome := app.Execute(context.Background(), []string{"config", commandName}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Cause != nil {
t.Fatalf("report write failure outcome = %#v", outcome)
}
})
t.Run(string(operation)+" quiet suppression", func(t *testing.T) {
writeErr := errors.New("quiet destination should not be used")
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app",
Operations: []Operation{operation},
Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("value: ready\n")}}, nil
},
}}}))
commandName := "show"
if operation == Validate {
commandName = "validate"
}
outcome := app.Execute(context.Background(), []string{"config", commandName, "--quiet"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Presentation.Output.State != commandkit.DeliverySuppressed || outcome.Presentation.Output.WriteError != nil || outcome.Cause != nil {
t.Fatalf("quiet report outcome = %#v", outcome)
}
})
}
}
func TestSourcesReadsNativeInventoryOnceAndPreservesZeroValues(t *testing.T) {
reader := &sourceInventory{values: []configkit.SourceInfo{{
ID: "synthetic", Label: "", Path: "", Layer: configkit.Layer("base"), Kind: configkit.SourceKind("mapping"), UnderlayIndex: 0,
ImportedFrom: &configkit.Location{SourceID: "", Line: 0, Column: 0},
}}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Sources}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Sources: reader}}, nil
}}}}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "sources", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || reader.calls != 1 {
t.Fatalf("sources outcome=%#v source calls=%d", outcome, reader.calls)
}
for _, fragment := range []string{"sources:", "id: synthetic", "label: \"\"", "path: \"\"", "underlay_index: 0", "imported_from:", "line: 0", "column: 0"} {
if !strings.Contains(output.String(), fragment) {
t.Errorf("source report missing %q: %s", fragment, output.String())
}
}
}
func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.T) {
reader := &nativeExplanation{
path: configkit.Path{},
value: configkit.Explanation{
Path: configkit.Path{},
Effective: configkit.ValueSnapshot{Present: true, Redacted: true},
History: []configkit.ValueChange{{
Before: configkit.ValueSnapshot{Present: true, Redacted: true},
After: configkit.ValueSnapshot{Present: true, Redacted: true},
Reasons: []string{"override"},
Dependencies: []configkit.Path{},
}},
SecretReference: &configkit.SecretReferenceInfo{Name: "APP_TOKEN"},
}, found: true,
}
acquireCalls := 0
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{Value: View{Explanation: reader}}, nil
}}}}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path=", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || acquireCalls != 1 || reader.path.String() != "" {
t.Fatalf("root explanation outcome=%#v calls=%d path=%q", outcome, acquireCalls, reader.path.String())
}
if strings.Contains(output.String(), "APP_TOKEN_VALUE") || !strings.Contains(output.String(), "redacted: true") || !strings.Contains(output.String(), "history:") || !strings.Contains(output.String(), "dependencies: []") || !strings.Contains(output.String(), "name: APP_TOKEN") {
t.Fatalf("safe explanation report = %s", output.String())
}
output.Reset()
acquireCalls = 0
outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", "/bad~2"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || acquireCalls != 0 || output.Len() != 0 {
t.Fatalf("malformed pointer was not rejected before acquisition: outcome=%#v calls=%d", outcome, acquireCalls)
}
}
func TestExplainTracksNativeRenderFailureWithoutReplacingOperationCause(t *testing.T) {
renderErr := errors.New("native explanation renderer failed")
reader := &failingNativeExplanation{err: renderErr}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Explanation: reader}}, nil
},
}}}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", "/token"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || output.String() != "partial native explanation\n" {
t.Fatalf("native explanation failure outcome/output = %#v / %q", outcome, output.String())
}
}
func TestReportWriteFailureIsRecordedByReporterAndPreservesCancellation(t *testing.T) {
writeErr := errors.New("output failed")
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Sources}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Sources: &sourceInventory{values: []configkit.SourceInfo{}}}}, nil
}}}}))
outcome := app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
t.Fatalf("source delivery failure outcome = %#v", outcome)
}
ctx, cancel := context.WithCancel(context.Background())
calls := 0
app = newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{
{Name: "app", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, nil
}},
{Name: "promptkit", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
cancel()
return commandkit.Acquired[View]{}, nil
}},
}}))
var output bytes.Buffer
outcome = app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
if !errors.Is(outcome.Cause, context.Canceled) || outcome.Presentation.Output.State != commandkit.DeliveryComplete || calls != 2 || output.String() != "app: valid\n" {
t.Fatalf("cancelled aggregate validation outcome=%#v calls=%d output=%q", outcome, calls, output.String())
}
}
type effectiveValue string
func (value effectiveValue) WriteEffective(writer io.Writer) error {
_, err := io.WriteString(writer, string(value))
return err
}
type failedEffective struct{ err error }
func (value failedEffective) WriteEffective(io.Writer) error {
return value.err
}
type sourceInventory struct {
values []configkit.SourceInfo
calls int
}
func (reader *sourceInventory) Sources() []configkit.SourceInfo {
reader.calls++
return reader.values
}
type nativeExplanation struct {
path configkit.Path
value configkit.Explanation
found bool
}
type failingNativeExplanation struct{ err error }
func (reader *failingNativeExplanation) Explain(configkit.Path) (configkit.Explanation, bool) {
return configkit.Explanation{}, true
}
func (reader *failingNativeExplanation) WriteExplanation(writer io.Writer, _ configkit.Path) error {
if _, err := io.WriteString(writer, "partial native explanation\n"); err != nil {
return err
}
return reader.err
}
func (reader *nativeExplanation) Explain(path configkit.Path) (configkit.Explanation, bool) {
reader.path = path
return reader.value, reader.found
}
func (reader *nativeExplanation) WriteExplanation(writer io.Writer, path configkit.Path) error {
reader.path = path
_, err := fmt.Fprintf(writer, "Explanation for %q\n", path.String())
return err
}
type failingOutput struct{ err error }
func (writer failingOutput) Write(value []byte) (int, error) {
if len(value) == 0 {
return 0, writer.err
}
return 1, writer.err
}
func newConfigTestApp(t *testing.T, command commandkit.Command) *commandkit.App {
t.Helper()
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
return app
}

View File

@@ -0,0 +1,15 @@
module gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig
go 1.26.0
require (
gitea.maximumdirect.net/eric/CommandKit v0.2.0
gitea.maximumdirect.net/eric/ConfigKit v0.8.0
go.yaml.in/yaml/v4 v4.0.0-rc.6
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/cobra v1.10.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)

View File

@@ -0,0 +1,16 @@
gitea.maximumdirect.net/eric/CommandKit v0.2.0 h1:UNBjSq6XDZHdPqvZLOL3N5R1E1ol0jnfldgradU7eqw=
gitea.maximumdirect.net/eric/CommandKit v0.2.0/go.mod h1:47DQ6Mo/yZqUVn5PAFRmPck91Zhf1jfaHDMoEZvUzsQ=
gitea.maximumdirect.net/eric/ConfigKit v0.8.0 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,247 @@
package ckconfig
import (
"fmt"
"strings"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type configCommandInput[I, S any] struct {
selection Selection
against Selection
scope commandkit.Supplied[string]
format commandkit.Supplied[string]
path commandkit.Supplied[string]
values commandkit.FlagValues[I]
plan *configOperationPlan[I, S]
}
type selectedScopeInput[I, S any] struct {
scope *preparedScope[I, S]
input I
}
type preparedComparison[I, S any] struct {
scope *preparedScope[I, S]
request ComparisonRequest[I]
}
type configOperationPlan[I, S any] struct {
prepared *preparedSuite[I, S]
operation Operation
selection Selection
scopeName string
format string
path configkit.Path
scopes []selectedScopeInput[I, S]
comparison *preparedComparison[I, S]
}
func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operation Operation) commandkit.Command {
defaultScope := "app"
defaultFormat := "text"
if operation == Show || operation == Diff {
defaultFormat = "yaml"
}
flags := []commandkit.Flag[*configCommandInput[I, S]]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported."}, &defaultScope, parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Output format."}, &defaultFormat, parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).format = value }),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration source."}, (*string)(nil), parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
(*input).selection.Config = value
}),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Overlay source."}, (*string)(nil), parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
(*input).selection.Overlay = value
}),
}
if operation == Diff {
flags = append(flags,
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-config", Usage: "Configuration source for the right side."}, (*string)(nil), parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
(*input).against.Config = value
}),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-overlay", Usage: "Overlay source for the right side."}, (*string)(nil), parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
(*input).against.Overlay = value
}),
)
}
if operation == Explain {
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain.", Required: true}, (*string)(nil), parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }))
}
flags = append(flags, commandkit.CaptureFlags(prepared.bindingsByOperation[operation], func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] {
if input == nil || *input == nil {
return nil
}
return &(*input).values
})...)
name, summary := operationDeclaration(operation)
return commandkit.NewCommand(commandkit.CommandSpec[*configCommandInput[I, S]]{
Name: name, Summary: summary,
NewInput: func() *configCommandInput[I, S] { return &configCommandInput[I, S]{} },
Flags: flags,
CheckInput: func(input *configCommandInput[I, S]) error {
if input == nil {
return fmt.Errorf("configuration input must not be nil")
}
return prepareConfigOperation(prepared, operation, input)
},
Run: func(invocation commandkit.Invocation, input *configCommandInput[I, S]) commandkit.Outcome {
return runConfigOperation(invocation, operation, input)
},
})
}
func operationDeclaration(operation Operation) (string, string) {
switch operation {
case Validate:
return "validate", "Validate configuration."
case Show:
return "show", "Show effective configuration."
case Sources:
return "sources", "List configuration sources."
case Explain:
return "explain", "Explain a configuration path."
case Diff:
return "diff", "Compare two configuration selections."
default:
return string(operation), "Inspect configuration."
}
}
func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) error {
format := input.format.Value
if err := checkConfigFormat(format); err != nil {
return err
}
if operation == Show && format != "yaml" {
return fmt.Errorf("unsupported configuration show format %q", format)
}
if operation == Diff && format != "yaml" {
return fmt.Errorf("unsupported configuration diff format %q", format)
}
scopeName := "app"
if input.scope.Set {
scopeName = input.scope.Value
}
if scopeName == "" {
return fmt.Errorf("configuration scope must not be empty")
}
if scopeName == "all" && operation != Validate && operation != Show {
return fmt.Errorf("scope all is supported only for validate and show")
}
var selected []*preparedScope[I, S]
if scopeName == "all" {
selected = prepared.scopes
} else if scope, exists := prepared.byName[scopeName]; exists {
selected = []*preparedScope[I, S]{scope}
} else {
return fmt.Errorf("unknown configuration scope %q (available: %s)", scopeName, prepared.scopeChoices)
}
for _, scope := range selected {
if !containsOperation(scope.scope.Operations, operation) {
return fmt.Errorf("configuration scope %q does not support %s (available: %s)", scope.scope.Name, operation, joinOperations(scope.scope.Operations))
}
}
var path configkit.Path
if operation == Explain {
if !input.path.Set {
return fmt.Errorf("configuration explanation requires --path")
}
parsed, err := configkit.ParsePath(input.path.Value)
if err != nil {
return fmt.Errorf("invalid configuration path: %w", err)
}
path = parsed
}
plan := &configOperationPlan[I, S]{
prepared: prepared,
operation: operation,
selection: input.selection,
scopeName: scopeName,
format: format,
path: path,
scopes: make([]selectedScopeInput[I, S], 0, len(selected)),
}
if operation == Diff {
input.against = inheritComparisonSelection(input.selection, input.against)
scope := selected[0]
left, err := prepareScopeInput(prepared, input.values, scope, input.selection)
if err != nil {
return fmt.Errorf("configuration scope %q left input: %w", scope.scope.Name, err)
}
right, err := prepareScopeInput(prepared, input.values, scope, input.against)
if err != nil {
return fmt.Errorf("configuration scope %q right input: %w", scope.scope.Name, err)
}
plan.comparison = &preparedComparison[I, S]{
scope: scope,
request: ComparisonRequest[I]{
Left: ComparisonSide[I]{Selection: input.selection, Input: left},
Right: ComparisonSide[I]{Selection: input.against, Input: right},
},
}
input.plan = plan
return nil
}
for _, scope := range selected {
value, err := prepareScopeInput(prepared, input.values, scope, input.selection)
if err != nil {
return fmt.Errorf("configuration scope %q: %w", scope.scope.Name, err)
}
plan.scopes = append(plan.scopes, selectedScopeInput[I, S]{scope: scope, input: value})
}
input.plan = plan
return nil
}
func prepareScopeInput[I, S any](prepared *preparedSuite[I, S], values commandkit.FlagValues[I], scope *preparedScope[I, S], selection Selection) (I, error) {
var value I
if prepared.newInput != nil {
value = prepared.newInput()
}
// Availability was checked before this helper, so every binding for the
// selected scope belongs to the operation's captured union.
if err := values.ApplyTo(&value, scope.bindings...); err != nil {
return value, err
}
if prepared.checkInput != nil {
if err := prepared.checkInput(scope.scope.Name, selection, value); err != nil {
return value, err
}
}
return value, nil
}
func inheritComparisonSelection(left, right Selection) Selection {
if !right.Config.Set {
right.Config = left.Config
}
if !right.Overlay.Set {
right.Overlay = left.Overlay
}
return right
}
func joinOperations(operations []Operation) string {
values := make([]string, len(operations))
for index, operation := range operations {
values[index] = string(operation)
}
if len(values) == 0 {
return "none"
}
return strings.Join(values, ", ")
}

View File

@@ -0,0 +1,100 @@
package ckconfig
import (
"fmt"
"io"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
)
func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Operation, input *configCommandInput[I, S]) commandkit.Outcome {
if input == nil || input.plan == nil {
return commandkit.Failure("Configuration operation was not prepared.")
}
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration operation was canceled.", commandkit.Explanation{}, err)
}
plan := input.plan
var state S
if plan.prepared.newState != nil {
state = plan.prepared.newState()
}
switch operation {
case Validate:
return runConfigValidation(invocation, plan, state)
case Show:
return runConfigShow(invocation, plan, state)
case Sources:
return runConfigSources(invocation, plan, state)
case Explain:
return runConfigExplanation(invocation, plan, state)
case Diff:
return runConfigDiff(invocation, plan, state)
default:
return commandkit.Failure(fmt.Sprintf("Unsupported configuration operation %q.", operation))
}
}
func queueConfigDiagnostics(reporter *commandkit.Reporter, diagnostics []commandkit.Diagnostic) {
deferred := reporter.AtEnd()
for _, diagnostic := range diagnostics {
if diagnostic.Write == nil {
continue
}
switch diagnostic.Severity {
case commandkit.SeverityInfo:
_ = deferred.Info("", diagnostic.Write)
case commandkit.SeverityDebug:
_ = deferred.Debug("", diagnostic.Write)
case commandkit.SeverityProgress:
_ = reporter.Progress("", diagnostic.Write)
default:
_ = deferred.Warn("", diagnostic.Write)
}
}
}
func configFailure(message string, explanation commandkit.Explanation, cause error) commandkit.Outcome {
if explanation.Message != "" || len(explanation.Details) > 0 {
message = explanation.Message
}
outcome := commandkit.Failure(message, explanation.Details...)
if cause != nil {
outcome = outcome.WithCause(cause)
}
return outcome
}
func configAcquisitionFailure(
reporter *commandkit.Reporter,
operation, scope string,
acquired commandkit.Acquired[View],
cause error,
) commandkit.Outcome {
queueConfigDiagnostics(reporter, acquired.Diagnostics)
message := fmt.Sprintf("Unable to %s configuration scope %q.", operation, scope)
return configFailure(message, acquired.Explanation, cause)
}
// Preserve the preparation/delivery cancellation checkpoint for buffered reports.
func renderPreparedConfigReport(invocation commandkit.Invocation, explanation commandkit.Explanation, prepare func() ([]byte, error)) commandkit.Outcome {
var canceled error
renderErr := invocation.Reporter().RenderOutput(func(writer io.Writer) error {
report, err := prepare()
if err != nil {
return err
}
if canceled = invocation.Context.Err(); canceled != nil {
return nil
}
_, err = writer.Write(report)
return err
})
if canceled == nil && renderErr != nil {
canceled = invocation.Context.Err()
}
if canceled != nil {
return configFailure("Configuration report preparation was canceled.", explanation, canceled)
}
return commandkit.Success()
}

View File

@@ -0,0 +1,13 @@
{
"product": "CommandKit ckconfig",
"module_dir": "integrations/ckconfig",
"remote": "origin",
"expected_push_url": "https://gitea.maximumdirect.net/eric/CommandKit.git",
"validation": {
"shared_files": [
"COPYRIGHT",
"LICENSE",
"docs/oss/attribution.md"
]
}
}

View File

@@ -0,0 +1,69 @@
package ckconfig
import (
"context"
"errors"
"io"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
)
func TestBufferedReportCancellationBeforeDelivery(t *testing.T) {
for _, quiet := range []bool{false, true} {
t.Run(map[bool]string{false: "ordinary", true: "quiet"}[quiet], func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
prepared := false
app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "report",
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
return renderPreparedConfigReport(invocation, commandkit.Explanation{Message: "selected context"}, func() ([]byte, error) {
prepared = true
cancel()
return []byte("prepared report"), nil
})
},
}))
args := []string{"report"}
if quiet {
args = append(args, "--quiet")
}
writes := 0
got := app.Execute(ctx, args, commandkit.Streams{Out: reportWriteFunc(func(p []byte) (int, error) {
writes++
return len(p), nil
})})
if !prepared || writes != 0 || got.Status != 1 || !errors.Is(got.Cause, context.Canceled) || got.Explanation.Message != "selected context" {
t.Fatalf("prepared=%t writes=%d outcome=%#v", prepared, writes, got)
}
if got.Presentation.Output.RenderError != nil || got.Presentation.Output.WriteError != nil {
t.Fatalf("cancellation became a presentation failure: %#v", got.Presentation.Output)
}
})
}
}
type reportWriteFunc func([]byte) (int, error)
func (write reportWriteFunc) Write(p []byte) (int, error) { return write(p) }
func TestBufferedReportDoesNotRejectCancellationAfterDeliveryStarts(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "report",
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
return renderPreparedConfigReport(invocation, commandkit.Explanation{}, func() ([]byte, error) {
return []byte("report"), nil
})
},
}))
got := app.Execute(ctx, []string{"report"}, commandkit.Streams{Out: reportWriteFunc(func(p []byte) (int, error) {
cancel()
return io.Discard.Write(p)
})})
if got.Status != 0 || got.Cause != nil {
t.Fatalf("post-delivery cancellation changed the operation outcome: %#v", got)
}
}

View File

@@ -0,0 +1,176 @@
package ckconfig
import (
"bytes"
"fmt"
"io"
"strconv"
"unicode"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
)
type effectiveDocument struct {
name string
view View
}
func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
documents := make([]effectiveDocument, 0, len(plan.scopes))
reporter := invocation.Reporter()
for _, selected := range plan.scopes {
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
}
if selected.scope.scope.Acquire == nil {
message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name)
return commandkit.Failure(message)
}
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Show,
Selection: plan.selection,
}, selected.input, state)
if acquireErr != nil {
return configAcquisitionFailure(reporter, "show", selected.scope.scope.Name, acquired, acquireErr)
}
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration show was canceled.", acquired.Explanation, err)
}
if acquired.Value.Effective == nil {
message := fmt.Sprintf("Configuration scope %q did not provide an effective configuration writer.", selected.scope.scope.Name)
return commandkit.Failure(message)
}
documents = append(documents, effectiveDocument{name: selected.scope.scope.Name, view: acquired.Value})
}
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
}
if len(documents) == 1 && plan.scopeName != "all" {
err := reporter.RenderOutput(func(writer io.Writer) error {
return documents[0].view.Effective.WriteEffective(writer)
})
if err != nil && invocation.Context.Err() != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err())
}
return commandkit.Success()
}
err := reporter.RenderOutput(func(writer io.Writer) error {
for index, document := range documents {
if err := invocation.Context.Err(); err != nil {
return err
}
var native bytes.Buffer
if err := document.view.Effective.WriteEffective(&native); err != nil {
return err
}
if err := invocation.Context.Err(); err != nil {
return err
}
framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes())
if index > 0 && hasDirective {
if _, err := io.WriteString(writer, "...\n"); err != nil {
return err
}
}
if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' {
framed = append(framed, '\n')
}
if _, err := writer.Write(framed); err != nil {
return err
}
}
return nil
})
if err != nil && invocation.Context.Err() != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err())
}
return commandkit.Success()
}
type yamlPreamble struct {
markerStart int
markerEnd int
markerFound bool
inlineMarker bool
preambleEnd int
hasDirective bool
}
func analyzeYAMLPreamble(native []byte) yamlPreamble {
var analysis yamlPreamble
for start := 0; start < len(native); {
end := len(native)
if newline := bytes.IndexByte(native[start:], '\n'); newline >= 0 {
end = start + newline + 1
}
lineEnd := end
if lineEnd > start && native[lineEnd-1] == '\n' {
lineEnd--
}
trimmed := bytes.TrimSpace(native[start:lineEnd])
if isYAMLDocumentStart(trimmed) {
content := bytes.TrimSpace(trimmed[3:])
analysis.markerStart = start
analysis.markerEnd = end
analysis.markerFound = true
analysis.inlineMarker = len(content) > 0 && content[0] != '#'
return analysis
}
if bytes.HasPrefix(trimmed, []byte("%YAML ")) || bytes.HasPrefix(trimmed, []byte("%TAG ")) {
analysis.hasDirective = true
}
if len(trimmed) == 0 || trimmed[0] == '#' || bytes.HasPrefix(trimmed, []byte("%YAML ")) || bytes.HasPrefix(trimmed, []byte("%TAG ")) {
analysis.preambleEnd = end
} else {
return analysis
}
start = end
}
return analysis
}
func frameEffectiveYAML(scope string, native []byte) ([]byte, bool) {
analysis := analyzeYAMLPreamble(native)
label := scope
for _, character := range scope {
if unicode.IsControl(character) {
label = strconv.Quote(scope)
break
}
}
comment := []byte("# scope: " + label + "\n")
if analysis.markerFound {
out := make([]byte, 0, len(native)+len(comment))
if analysis.inlineMarker {
out = append(out, native[:analysis.markerStart]...)
out = append(out, comment...)
out = append(out, native[analysis.markerStart:]...)
return out, analysis.hasDirective
}
out = append(out, native[:analysis.markerEnd]...)
if analysis.markerEnd > analysis.markerStart && native[analysis.markerEnd-1] != '\n' {
out = append(out, '\n')
}
out = append(out, comment...)
out = append(out, native[analysis.markerEnd:]...)
return out, analysis.hasDirective
}
result := make([]byte, 0, len(native)+len(comment)+5)
if analysis.hasDirective {
result = append(result, native[:analysis.preambleEnd]...)
}
result = append(result, []byte("---\n")...)
result = append(result, comment...)
if analysis.hasDirective {
result = append(result, native[analysis.preambleEnd:]...)
} else {
result = append(result, native...)
}
return result, analysis.hasDirective
}
func isYAMLDocumentStart(line []byte) bool {
return bytes.HasPrefix(line, []byte("---")) && (len(line) == 3 || line[3] == ' ' || line[3] == '\t')
}

View File

@@ -1,12 +1,13 @@
package commandkit package ckconfig
import ( import (
"bytes" "bytes"
"go.yaml.in/yaml/v4"
"io" "io"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
"go.yaml.in/yaml/v4"
) )
func TestEffectiveYAMLFramingPreservesOneNativeDocument(t *testing.T) { func TestEffectiveYAMLFramingPreservesOneNativeDocument(t *testing.T) {

View File

@@ -0,0 +1,186 @@
package ckconfig
import (
"bytes"
"fmt"
"io"
"strconv"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"go.yaml.in/yaml/v4"
)
type sourceReport struct {
Scope string `yaml:"scope"`
Sources []sourceDTO `yaml:"sources"`
}
type sourceDTO struct {
ID string `yaml:"id"`
Label string `yaml:"label"`
Path string `yaml:"path"`
Destination string `yaml:"destination"`
Placement string `yaml:"placement"`
DestinationUnresolved bool `yaml:"destination_unresolved"`
Layer string `yaml:"layer"`
Kind string `yaml:"kind"`
UnderlayIndex int `yaml:"underlay_index"`
ImportedFrom *locationDTO `yaml:"imported_from"`
VersionCheck *sourceVersionCheckDTO `yaml:"version_check"`
}
type locationDTO struct {
SourceID string `yaml:"source_id"`
Line int `yaml:"line"`
Column int `yaml:"column"`
}
type sourceVersionCheckDTO struct {
Path string `yaml:"path"`
Version versionDTO `yaml:"version"`
Location *locationDTO `yaml:"location"`
Redacted bool `yaml:"redacted"`
}
type versionDTO struct {
Present bool `yaml:"present"`
Null bool `yaml:"null"`
Text string `yaml:"text"`
}
func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
selected := plan.scopes[0]
name := selected.scope.scope.Name
reporter := invocation.Reporter()
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration source listing was canceled.", commandkit.Explanation{}, err)
}
if selected.scope.scope.Acquire == nil {
cause := fmt.Errorf("configuration scope %q has no acquisition callback", name)
return configFailure(fmt.Sprintf("Unable to list sources for configuration scope %q.", name), commandkit.Explanation{}, cause)
}
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Sources,
Selection: plan.selection,
}, selected.input, state)
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if acquireErr != nil {
return configFailure(fmt.Sprintf("Unable to list sources for configuration scope %q.", name), acquired.Explanation, acquireErr)
}
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
}
if acquired.Value.Sources == nil {
cause := fmt.Errorf("configuration scope %q did not provide a source reader", name)
return configFailure("Configuration scope did not provide source information.", acquired.Explanation, cause)
}
sources := acquired.Value.Sources.Sources()
document := sourceReport{Scope: name, Sources: make([]sourceDTO, 0, len(sources))}
for _, source := range sources {
placement := "mapping"
if source.PlacementMode == configkit.PlacementModeSequenceItem {
placement = "sequence_item"
}
item := sourceDTO{
ID: source.ID,
Label: source.Label,
Path: source.Path,
Destination: source.Destination.String(),
Placement: placement,
DestinationUnresolved: source.DestinationUnresolved,
Layer: string(source.Layer),
Kind: string(source.Kind),
UnderlayIndex: source.UnderlayIndex,
}
if source.ImportedFrom != nil {
item.ImportedFrom = &locationDTO{SourceID: source.ImportedFrom.SourceID, Line: source.ImportedFrom.Line, Column: source.ImportedFrom.Column}
}
if source.VersionCheck != nil {
check := source.VersionCheck
item.VersionCheck = &sourceVersionCheckDTO{
Path: check.Path.String(),
Version: versionDTO{
Present: check.Version.Present,
Null: check.Version.Null,
Text: check.Version.Text,
},
Redacted: check.Redacted,
}
if check.Location != nil {
item.VersionCheck.Location = &locationDTO{SourceID: check.Location.SourceID, Line: check.Location.Line, Column: check.Location.Column}
}
}
document.Sources = append(document.Sources, item)
}
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
}
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
var report bytes.Buffer
if plan.format == "yaml" {
if err := encodeYAML(&report, document); err != nil {
return nil, err
}
} else {
writeSourceReportText(&report, document)
}
return report.Bytes(), nil
})
}
func writeSourceReportText(writer *bytes.Buffer, document sourceReport) {
_, _ = fmt.Fprintf(writer, "Scope: %s\n", strconv.Quote(document.Scope))
if len(document.Sources) == 0 {
_, _ = writer.WriteString("Sources: []\n")
return
}
_, _ = writer.WriteString("Sources:\n")
for _, source := range document.Sources {
_, _ = fmt.Fprintf(writer, " - id: %s\n", strconv.Quote(source.ID))
_, _ = fmt.Fprintf(writer, " label: %s\n", strconv.Quote(source.Label))
_, _ = fmt.Fprintf(writer, " path: %s\n", strconv.Quote(source.Path))
_, _ = fmt.Fprintf(writer, " destination: %s\n", strconv.Quote(source.Destination))
_, _ = fmt.Fprintf(writer, " placement: %s\n", strconv.Quote(source.Placement))
_, _ = fmt.Fprintf(writer, " destination_unresolved: %t\n", source.DestinationUnresolved)
_, _ = fmt.Fprintf(writer, " layer: %s\n", strconv.Quote(source.Layer))
_, _ = fmt.Fprintf(writer, " kind: %s\n", strconv.Quote(source.Kind))
_, _ = fmt.Fprintf(writer, " underlay_index: %d\n", source.UnderlayIndex)
if source.ImportedFrom == nil {
_, _ = writer.WriteString(" imported_from: null\n")
} else {
_, _ = fmt.Fprintf(writer, " imported_from: {source_id: %s, line: %d, column: %d}\n",
strconv.Quote(source.ImportedFrom.SourceID), source.ImportedFrom.Line, source.ImportedFrom.Column)
}
if source.VersionCheck == nil {
_, _ = writer.WriteString(" version_check: null\n")
continue
}
check := source.VersionCheck
_, _ = fmt.Fprintf(writer, " version_check:\n path: %s\n", strconv.Quote(check.Path))
_, _ = fmt.Fprintf(writer, " version:\n present: %t\n null: %t\n text: %s\n",
check.Version.Present, check.Version.Null, strconv.Quote(check.Version.Text))
if check.Location == nil {
_, _ = writer.WriteString(" location: null\n")
} else {
_, _ = fmt.Fprintf(writer, " location: {source_id: %s, line: %d, column: %d}\n",
strconv.Quote(check.Location.SourceID), check.Location.Line, check.Location.Column)
}
_, _ = fmt.Fprintf(writer, " redacted: %t\n", check.Redacted)
}
}
func encodeYAML(writer io.Writer, document any) error {
encoder := yaml.NewEncoder(writer)
encoder.SetIndent(2)
return encoder.Encode(document)
}
func locationReports(locations []configkit.Location) []locationDTO {
report := make([]locationDTO, 0, len(locations))
for _, location := range locations {
report = append(report, locationDTO{SourceID: location.SourceID, Line: location.Line, Column: location.Column})
}
return report
}

Some files were not shown because too many files have changed in this diff Show More