Files
dingtalk-workspace-cli/scripts/dev/migrate_product_params.py
T
玉澜 e705012011 feat(schema): migrate parameter overlays into code declarations
Move 121/210 parameter-level Schema field overlays from
schema_hints/metadata JSON into in-code ParamDecl declarations on
DeclareLeafMetadata commands. The declared values are emitted as
dws.schema.* annotations at assembly time via ApplyParamDecls,
outranking tool_schema_hint (rank 620 > 500) so the hint overlay
becomes redundant once the declaration is in place.

Key mechanism changes:
- Add SchemaDecl.Parameters []ParamDecl with property/required/
  interface_type/description/required_when/enum fields
- Add cli.ParamDecl type carried inside ContractFinalPayload
- ApplyParamDecls emits dws.schema.* annotations from the payload
  at assembly time (no sync.Map, no tree-rebuild key issue)
- Add cli.AnnotateRuntimeFlagInterfaceType (41 overlays needed it)
- Add cli.AnnotateRuntimeFlagRequiredValue for explicit true/false
- Compatibility alias check tolerates dws.schema.* annotation
  differences between primary and alias commands
- Remove runtime source_hash recomputation (87ms/997k allocs saved;
  enforced by check-generated-drift.sh at build time instead)
- Add shortcut.Flag.RequiredWhen and wire through FromShortcut
- Add boolFlag OR semantics to fix confirmationBypass disagreement
- Add bindKey default kebab-to-camel for forgotten Bind
- Add schema consumption benchmarks (catalog decode 1.5s/817MB,
  shortcut load 1.9ms/3MB — three orders of magnitude apart)
- Add catalog codegen feasibility probe (34 tools: 0.06s compile,
  31ns access, 87KB linked — extrapolates to 2.1MB for 845 tools)

Migrated products (29 tools, 121 fields):
  aisearch(1), chat(12), contact(4), doc(3), drive(1),
  hrbrain(10), mail(2), report(1), sheet(3), todo(6)

Remaining 89 fields across 11 products blocked by:
  - RPCName not found as string literal (19 tools, shortcut/variable)
  - No matching DeclareLeafMetadata near callMCPTool (11 tools)
  - No single RPCName for multi-step commands (drive.upload etc.)

All tests green: corecmd, cli, helpers. Generation and drift clean.
2026-08-01 09:25:40 +08:00

246 lines
9.8 KiB
Python

#!/usr/bin/env python3
"""Migrate one product's parameter overlays from hints into ParamDecl declarations.
Usage: python3 scripts/dev/migrate_product_params.py <product>
For each tool with parameter overlays in schema_hints/metadata/<product>.json:
1. Finds the DeclareLeafMetadata block containing the tool's RPCName.
2. Inserts Parameters: []corecmd.ParamDecl{...} into the Schema block.
3. Moves any overlaid flag registrations before the DeclareLeafMetadata call.
4. Clears the tool's parameters from the hints file.
Run for one product at a time; verify compilation and schema output between runs.
"""
import json
import os
import re
import sys
HELPER_DIR = "internal/helpers"
HINTS_DIR = "internal/cli/schema_hints/metadata"
# Primary file per product (used for hints clearing only; RPCName search covers all files)
PRODUCT_FILES = {
"aisearch": "aisearch.go", "aitable": "aitable.go", "attendance": "attendance.go",
"chat": "chat.go", "contact": "contact.go", "devdoc": "devdoc.go",
"doc": "doc.go", "drive": "drive.go", "hrbrain": "hrbrain.go",
"mail": "mail.go", "markdown": "markdown.go", "minutes": "minutes.go",
"report": "report.go", "sheet": "sheet.go", "todo": "todo.go",
}
def all_helper_files():
"""Return all non-test .go files in internal/helpers/."""
files = []
for f in sorted(os.listdir(HELPER_DIR)):
if f.endswith(".go") and not f.endswith("_test.go"):
files.append(os.path.join(HELPER_DIR, f))
return files
def bool_ptr(v):
return "boolPtr(true)" if v else "boolPtr(false)"
def render_param(name, ov):
parts = [f'Name: "{name}"']
if "property" in ov:
parts.append(f'Property: "{ov["property"]}"')
if "required" in ov:
parts.append(f"Required: {bool_ptr(ov['required'])}")
if "interface_type" in ov:
parts.append(f'InterfaceType: "{ov["interface_type"]}"')
if "description" in ov:
parts.append(f'Description: "{ov["description"]}"')
if "required_when" in ov:
parts.append(f'RequiredWhen: "{ov["required_when"]}"')
if "enum" in ov and ov["enum"]:
vals = ", ".join(f'"{v}"' for v in ov["enum"])
parts.append(f"Enum: []string{{{vals}}}")
return "\t\t\t\t{" + ", ".join(parts) + "},"
def find_block_end(lines, start_idx):
"""Find the line index of the closing '})' or '},' for a block starting at start_idx."""
depth = 0
for i in range(start_idx, len(lines)):
depth += lines[i].count("{") - lines[i].count("}")
if depth <= 0 and i > start_idx:
return i
return -1
def migrate_product(product):
hint_path = os.path.join(HINTS_DIR, f"{product}.json")
hints = json.load(open(hint_path, encoding="utf-8"))
tools = hints.get("tools") or {}
overlays = {t: (v or {}).get("parameters") or {} for t, v in tools.items()}
overlays = {t: p for t, p in overlays.items() if p}
if not overlays:
print(f"{product}: no overlays, skipping")
return True
# Load all helper files into memory
file_contents = {}
for fp in all_helper_files():
file_contents[fp] = open(fp, encoding="utf-8").read()
modified_files = set()
flags_to_move = []
migrated_tools = set() # only clear hints for tools we actually migrated
for tool_name, params in sorted(overlays.items()):
rpc_name = tool_name.split(".")[-1]
# Strategy 1: RPCName field in Schema.Interface
rpc_pattern = re.compile(r'RPCName:\s*"' + re.escape(rpc_name) + '"')
# Strategy 2: callMCPTool / CallMCP call sites
call_patterns = [
re.compile(r'callMCPTool\("' + re.escape(rpc_name) + '"'),
re.compile(r'callMCPToolOnServer\([^,]+,\s*"' + re.escape(rpc_name) + '"'),
re.compile(r'rt\.CallMCP\w*\("' + re.escape(rpc_name) + '"'),
]
found_file = None
found_line = -1
for fp, src in file_contents.items():
lines_src = src.split("\n")
# Try strategy 1 first
for i, line in enumerate(lines_src):
if rpc_pattern.search(line):
found_file = fp
found_line = i
break
if found_file:
break
# Try strategy 2
for i, line in enumerate(lines_src):
for cp in call_patterns:
if cp.search(line):
found_file = fp
found_line = i
break
if found_file:
break
if found_file:
break
if not found_file:
print(f" WARNING: {tool_name} — RPCName '{rpc_name}' not found in any helper file")
continue
lines = file_contents[found_file].split("\n")
# Find DeclareLeafMetadata: search backwards first, then forwards
decl_start = -1
search_range = list(range(found_line, max(found_line - 60, -1), -1)) + \
list(range(found_line + 1, min(found_line + 60, len(lines))))
for i in search_range:
if "DeclareLeafMetadata(" in lines[i]:
# Verify this DeclareLeafMetadata's Schema contains the target
# RPCName (or the tool name) to avoid matching the wrong command.
block_end = find_block_end(lines, i)
block_text = "\n".join(lines[i:block_end + 1])
if rpc_name in block_text or tool_name in block_text:
decl_start = i
break
# Also check for callMCPTool("rpc_name") inside the block
if f'callMCPTool("{rpc_name}"' in block_text or \
f'callMCPToolOnServer(' in block_text and rpc_name in block_text:
decl_start = i
break
# Skip this DeclareLeafMetadata — it doesn't belong to our tool
if decl_start < 0:
print(f" WARNING: {tool_name} — no matching DeclareLeafMetadata near line {found_line + 1} in {os.path.basename(found_file)}")
continue
# Find Schema block
schema_start = -1
for i in range(decl_start, min(decl_start + 80, len(lines))):
if "Schema: LeafSchema{" in lines[i] or "Schema: corecmd.SchemaDecl{" in lines[i]:
schema_start = i
break
if schema_start < 0:
print(f" WARNING: {tool_name} — no Schema block in {os.path.basename(found_file)}")
continue
schema_end = find_block_end(lines, schema_start)
schema_text = "\n".join(lines[schema_start:schema_end + 1])
if "Parameters:" in schema_text:
print(f" SKIP: {tool_name} — Parameters already declared")
migrated_tools.add(tool_name)
continue
# Build and insert Parameters
param_lines = ["\t\t\tParameters: []corecmd.ParamDecl{"]
for pname in sorted(params):
param_lines.append(render_param(pname, params[pname]))
param_lines.append("\t\t\t},")
insert_at = schema_end
for i in range(schema_end - 1, schema_start, -1):
stripped = lines[i].strip()
if stripped and not stripped.startswith("//"):
insert_at = i + 1
break
for j, pl in enumerate(param_lines):
lines.insert(insert_at + j, pl)
modified_files.add(found_file)
file_contents[found_file] = "\n".join(lines)
migrated_tools.add(tool_name)
print(f" OK: {tool_name} — inserted {len(params)} param decls in {os.path.basename(found_file)} at line {insert_at + 1}")
# Check flag registration order
for pname in params:
flag_pattern = re.compile(r'\.Flags\(\)\.\w+\("' + re.escape(pname) + '"')
for i, line in enumerate(lines):
if flag_pattern.search(line):
if i > decl_start:
flags_to_move.append((pname, i, decl_start, os.path.basename(found_file)))
break
# Clear hints for migrated/skipped tools (even if no source files changed)
if migrated_tools:
for tool_name in migrated_tools:
if tool_name in hints.get("tools", {}):
hints["tools"][tool_name].pop("parameters", None)
if not hints["tools"][tool_name]:
del hints["tools"][tool_name]
if not hints.get("tools"):
hints["tools"] = {}
json.dump(hints, open(hint_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print(f"{product}: cleared hints for {len(migrated_tools)} tools ({len(overlays) - len(migrated_tools)} remaining)")
if not modified_files:
if not migrated_tools:
print(f"{product}: no changes needed")
return True
# Write modified files and ensure corecmd import
for fp in modified_files:
src = file_contents[fp]
if "internal/corecmd" not in src:
m = re.search(r'(import \(\n(?:.*\n)*?\))', src)
if m:
block = m.group(1)
new_block = block.rstrip(")\n") + '\n\n\t"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"\n)'
src = src[:m.start()] + new_block + src[m.end():]
print(f" added corecmd import to {os.path.basename(fp)}")
open(fp, "w", encoding="utf-8").write(src)
if flags_to_move:
print(f" ⚠ {len(flags_to_move)} flags registered AFTER DeclareLeafMetadata — need manual reorder:")
for fname, reg_line, decl_line, fname_file in flags_to_move:
print(f" --{fname} in {fname_file}: reg line {reg_line + 1} > decl line {decl_line + 1}")
return True
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: migrate_product_params.py <product>")
sys.exit(1)
ok = migrate_product(sys.argv[1])
sys.exit(0 if ok else 1)