Skip to content

Commit 12a9b59

Browse files
feat(docs): enhance activity documentation generation and validation scripts
1 parent 9469ae6 commit 12a9b59

5 files changed

Lines changed: 302 additions & 26 deletions

File tree

.claude/skills/activity-doc-generator/SKILL.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,22 @@ For each activity in the JSON output, generate a markdown doc using the template
7171
8. **Behavioral context** — When the activity wraps a well-known library or API (e.g., Excel Interop, Orchestrator REST API, SMTP), briefly note this so the AI agent understands the activity's capabilities and limitations.
7272
9. **References** (optional) — If an activity has supplementary reference files (detailed examples, extended guidance, complex scenarios) that would bloat the main doc, place them in a `{ActivityClassName}/` subdirectory alongside the `.md` file and add a References section linking to them. Only include when such files exist.
7373

74+
### Phase 3: Validate Generated Docs (Mandatory)
75+
76+
Before presenting docs as complete, run the validator script and fail the generation pass if any errors are reported.
77+
78+
```bash
79+
python {skillPath}/scripts/validate-activity-docs.py "{docsRoot}" --strict
80+
```
81+
82+
Validation must fail on:
83+
- broken or missing fenced XML blocks in XAML examples
84+
- output rows incorrectly marked as `Property` instead of `OutArgument`/`InOutArgument`
85+
- mutually exclusive one-of fields both marked `Required: Yes`
86+
- required input properties missing from XAML example attributes
87+
- leaked internal/infrastructure properties (`Body`, `*InputModeSwitch`, `DeprecatedWarning`)
88+
- placeholder empty Input/Output rows (for example, `| `-` | - | - | `-` | - |`)
89+
7490
---
7591

7692
## Step-by-Step
@@ -232,3 +248,4 @@ When documenting:
232248
- [ ] Project settings are documented when present (`[ArgumentSettingAttribute]`)
233249
- [ ] References section included when supplementary files exist in `{ActivityClassName}/` subdirectory
234250
- [ ] `overview.md` lists all activities with correct relative links
251+
- [ ] Validator passes with zero errors (`validate-activity-docs.py --strict`)

.claude/skills/activity-doc-generator/assets/xaml-activity-template.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,29 +24,35 @@ This is the template for generating per-activity markdown documentation files fo
2424

2525
## Properties
2626

27+
{{#if inputProperties}}
2728
### Input
2829

2930
| Name | Display Name | Kind | Type | Required | Default | Placeholder | Description |
3031
|------|-------------|------|------|----------|---------|-------------|-------------|
3132
{{#each inputProperties}}
3233
| `{{Name}}` | {{DisplayName}} | {{Kind}} | `{{Type}}` | {{Required}} | {{Default}} | {{Placeholder}} | {{Description}} |
3334
{{/each}}
35+
{{/if}}
3436

37+
{{#if configProperties}}
3538
### Configuration
3639

3740
| Name | Display Name | Type | Default | Description |
3841
|------|-------------|------|---------|-------------|
3942
{{#each configProperties}}
4043
| `{{Name}}` | {{DisplayName}} | `{{Type}}` | {{Default}} | {{Description}} |
4144
{{/each}}
45+
{{/if}}
4246

47+
{{#if outputProperties}}
4348
### Output
4449

4550
| Name | Display Name | Kind | Type | Description |
4651
|------|-------------|------|------|-------------|
4752
{{#each outputProperties}}
4853
| `{{Name}}` | {{DisplayName}} | {{Kind}} | `{{Type}}` | {{Description}} |
4954
{{/each}}
55+
{{/if}}
5056

5157
{{#if validConfigurations}}
5258
## Valid Configurations
@@ -143,7 +149,7 @@ Properties the user provides to configure what the activity does:
143149
- For `InArgument<T>`: the `T` type
144150
- For plain properties: the property type directly
145151
- For enum types: `EnumName` (list values in Enum Reference section)
146-
- **Required**: `Yes` if `[RequiredArgument]` is present or `IsRequired` is set in the ViewModel, otherwise leave empty
152+
- **Required**: `Yes` if `[RequiredArgument]` is present or `IsRequired` is set in the ViewModel, `Conditional` for one-of/mutually-exclusive inputs, otherwise leave empty
147153
- **Default**: From `[DefaultValue(x)]`, inline initializer (`= value`), or constructor assignment
148154
- **Placeholder**: From the ViewModel's `Placeholder` property (resolved via `.resx`). Shows the expected format to the user (e.g., `"hh:mm:ss"`, `"dd/MM/yyyy"`). Include when present — it helps coding agents provide correctly formatted values and avoids unnecessary errors
149155
- **Description**: From the ViewModel's `Tooltip` property, or `[LocalizedDescription]` on the activity class

.claude/skills/activity-doc-generator/scripts/extract-activity-metadata.py

Lines changed: 101 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
import re
6060
import sys
6161
from pathlib import Path
62+
from typing import Any
6263

6364
# Add shared utilities to path (relative to this script's location)
6465
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "shared"))
@@ -95,6 +96,12 @@ def parse_metadata_json(json_paths: list[str]) -> tuple[list[dict], list[str]]:
9596
category_order: list[str] = []
9697
seen_names: set[str] = set()
9798

99+
def _get_first(data: dict[str, Any], *keys: str, default: Any = None) -> Any:
100+
for key in keys:
101+
if key in data:
102+
return data[key]
103+
return default
104+
98105
for path in json_paths:
99106
try:
100107
with open(path, "r", encoding="utf-8-sig") as f:
@@ -103,33 +110,33 @@ def parse_metadata_json(json_paths: list[str]) -> tuple[list[dict], list[str]]:
103110
print(f"Warning: Could not parse metadata file {path}: {e}", file=sys.stderr)
104111
continue
105112

106-
if "orderedCategoryDisplayNameKeys" in data:
107-
category_order = data["orderedCategoryDisplayNameKeys"]
108-
109-
# Support both lowercase "activities" and capitalized "Activities" keys
110-
acts_list = data.get("activities", data.get("Activities", []))
111-
for act in acts_list:
112-
# Helper to get values from either camelCase or PascalCase keys
113-
def get_value(obj, camel_key, pascal_key=None):
114-
if pascal_key is None:
115-
pascal_key = camel_key[0].upper() + camel_key[1:]
116-
return obj.get(camel_key, obj.get(pascal_key, ""))
117-
118-
full_name = get_value(act, "fullName", "FullName")
113+
category_order = _get_first(
114+
data,
115+
"orderedCategoryDisplayNameKeys",
116+
"OrderedCategoryDisplayNameKeys",
117+
default=category_order,
118+
)
119+
120+
for act in _get_first(data, "activities", "Activities", default=[]):
121+
full_name = _get_first(act, "fullName", "FullName", default="")
119122
if not full_name or full_name in seen_names:
120123
continue
121124
seen_names.add(full_name)
122125
activities.append({
123126
"fullName": full_name,
124-
"shortName": get_value(act, "shortName", "ShortName") or full_name.split(".")[-1],
125-
"displayNameKey": get_value(act, "displayNameKey", "DisplayNameKey"),
126-
"descriptionKey": get_value(act, "descriptionKey", "DescriptionKey"),
127-
"categoryKey": get_value(act, "categoryKey", "CategoryKey"),
128-
"viewModelType": get_value(act, "viewModelType", "ViewModelType"),
129-
"codedWorkflowSupport": act.get("codedWorkflowSupport", act.get("CodedWorkflowSupport", False)),
130-
"browsable": act.get("browsable", act.get("Browsable", True)),
131-
"mandatoryParentActivityFullName": get_value(act, "mandatoryParentActivityFullName", "MandatoryParentActivityFullName"),
132-
"properties": act.get("properties", act.get("Properties", [])),
127+
"shortName": _get_first(act, "shortName", "ShortName", default=full_name.split(".")[-1]),
128+
"displayNameKey": _get_first(act, "displayNameKey", "DisplayNameKey"),
129+
"descriptionKey": _get_first(act, "descriptionKey", "DescriptionKey"),
130+
"categoryKey": _get_first(act, "categoryKey", "CategoryKey"),
131+
"viewModelType": _get_first(act, "viewModelType", "ViewModelType"),
132+
"codedWorkflowSupport": _get_first(act, "codedWorkflowSupport", "CodedWorkflowSupport", default=False),
133+
"browsable": _get_first(act, "browsable", "Browsable", default=True),
134+
"mandatoryParentActivityFullName": _get_first(
135+
act,
136+
"mandatoryParentActivityFullName",
137+
"MandatoryParentActivityFullName",
138+
),
139+
"properties": _get_first(act, "properties", "Properties", default=[]),
133140
"metadataFile": path,
134141
})
135142

@@ -175,7 +182,17 @@ def find_cs_file_for_class(root: str, full_class_name: str) -> str | None:
175182
(?P<plain_type>[A-Za-z_][\w.<>,\[\]\s?]*)
176183
)\s+
177184
(?P<name>[A-Za-z_]\w*)\s*
178-
\{\s*get;\s*set;\s*\}
185+
\{\s*
186+
(
187+
get;\s*(?:private\s+)?set;
188+
|
189+
set;\s*(?:private\s+)?get;
190+
|
191+
get\s*=>[^;]+;\s*set\s*=>[^;]+;
192+
|
193+
set\s*=>[^;]+;\s*get\s*=>[^;]+;
194+
)
195+
\s*\}
179196
(?:\s*=\s*(?P<default>[^;]+))?
180197
""",
181198
re.VERBOSE | re.MULTILINE | re.DOTALL,
@@ -201,13 +218,22 @@ def find_cs_file_for_class(root: str, full_class_name: str) -> str | None:
201218
_SKIP_TYPE_PREFIXES = ("ActivityAction", "ActivityFunc")
202219

203220

221+
def _should_skip_property_name(name: str) -> bool:
222+
"""Return True for non-user-facing infrastructure property names."""
223+
if name in _SKIP_PROPERTY_NAMES:
224+
return True
225+
if name == "DeprecatedWarning":
226+
return True
227+
return name.endswith("InputModeSwitch")
228+
229+
204230
def extract_activity_properties(content: str) -> list[dict]:
205231
"""Extract public auto-properties from an Activity .cs file."""
206232
properties = []
207233

208234
for m in RE_PROPERTY.finditer(content):
209235
name = m.group("name")
210-
if name in _SKIP_PROPERTY_NAMES:
236+
if _should_skip_property_name(name):
211237
continue
212238

213239
arg_type = m.group("arg_type")
@@ -418,12 +444,17 @@ def _merge_single_property(
418444
or resolve_key(prop.get("categoryKey"), resx_map)
419445
)
420446

447+
merged_type = prop["type"]
448+
vm_type = vm.get("type")
449+
if merged_type.lower() == "object" and isinstance(vm_type, str) and vm_type.strip():
450+
merged_type = vm_type.strip()
451+
421452
return {
422453
"name": prop["name"],
423454
"displayName": display_name,
424455
"description": description,
425456
"kind": vm.get("kind") or prop["kind"],
426-
"type": prop["type"],
457+
"type": merged_type,
427458
"genericType": prop.get("genericType"),
428459
"required": vm.get("isRequired") if vm.get("isRequired") is not None else prop["required"],
429460
"defaultValue": prop.get("defaultValue"),
@@ -472,6 +503,47 @@ def _property_sort_key(prop: dict) -> tuple:
472503
return (9999, prop["name"])
473504

474505

506+
def _normalize_required_flags(merged_props: list[dict]) -> None:
507+
"""Normalize requiredness for mutually-exclusive property sets.
508+
509+
This avoids marking both sides of one-of choices as required.
510+
"""
511+
by_name = {p["name"]: p for p in merged_props}
512+
513+
one_of_pairs = [
514+
("Key", "KeySecureString"),
515+
("ConnectionString", "ConnectionSecureString"),
516+
("Password", "SecurePassword"),
517+
("ProxyPassword", "ProxySecurePassword"),
518+
("ClientCertificatePassword", "ClientCertificateSecurePassword"),
519+
("Code", "ScriptFile"),
520+
("TargetObject", "TargetType"),
521+
]
522+
523+
for left, right in one_of_pairs:
524+
if left in by_name and right in by_name and by_name[left].get("required") and by_name[right].get("required"):
525+
by_name[left]["required"] = False
526+
by_name[right]["required"] = False
527+
group = [left, right]
528+
by_name[left]["requiredOneOf"] = group
529+
by_name[right]["requiredOneOf"] = group
530+
531+
by_group: dict[str, list[dict]] = {}
532+
for prop in merged_props:
533+
group = prop.get("overloadGroup")
534+
if group:
535+
by_group.setdefault(group, []).append(prop)
536+
537+
for group_name, group_props in by_group.items():
538+
required_props = [p for p in group_props if p.get("required")]
539+
if len(required_props) > 1:
540+
names = [p["name"] for p in group_props]
541+
for prop in required_props:
542+
prop["required"] = False
543+
prop["requiredOneOf"] = names
544+
prop["requiredGroup"] = group_name
545+
546+
475547
def merge_activity_data(
476548
meta_entry: dict,
477549
activity_props: list[dict],
@@ -493,9 +565,13 @@ def merge_activity_data(
493565
# Add ViewModel-only properties not found in the activity class
494566
activity_prop_names = {p["name"] for p in activity_props}
495567
for name, vm in vm_metadata.items():
568+
if _should_skip_property_name(name):
569+
continue
496570
if name not in activity_prop_names and not vm.get("notMapped"):
497571
merged_props.append(_make_vm_only_property(name, vm, resx_map))
498572

573+
_normalize_required_flags(merged_props)
574+
499575
merged_props.sort(key=_property_sort_key)
500576

501577
return {

0 commit comments

Comments
 (0)