diff --git a/.github/workflows/migration-ci.yml b/.github/workflows/migration-ci.yml index 94ba3646..b93e7dbd 100644 --- a/.github/workflows/migration-ci.yml +++ b/.github/workflows/migration-ci.yml @@ -324,12 +324,21 @@ jobs: cat "$RUNNER_TEMP/migration-cli-benchmark.md" >> "$GITHUB_STEP_SUMMARY" fi + - name: Download parity evidence + if: always() + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: migration-parity-evidence + path: ${{ runner.temp }}/migration-parity-evidence + - name: Post benchmark PR comment if: always() && github.event_name == 'pull_request' env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PARITY_RESULT: ${{ needs.parity.result }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | if [ ! -f "$RUNNER_TEMP/migration-cli-benchmark.md" ]; then @@ -337,6 +346,185 @@ jobs: exit 0 fi + SCORE_PATH="$RUNNER_TEMP/migration-parity-evidence/migration-score.json" + export SCORE_PATH + + python - <<'PY' > "$RUNNER_TEMP/migration-benchmark-context.md" + from __future__ import annotations + + import json + import os + import subprocess + from pathlib import Path + + + def gh_json(path: str) -> object | None: + try: + completed = subprocess.run( + ["gh", "api", path], + check=True, + capture_output=True, + encoding="utf-8", + ) + except subprocess.CalledProcessError: + return None + return json.loads(completed.stdout) + + + def subject(message: str) -> str: + return message.splitlines()[0] if message else "(no commit subject)" + + + def body_lines(message: str, limit: int = 5) -> list[str]: + items: list[str] = [] + current: list[str] = [] + for raw_line in message.splitlines()[1:]: + line = raw_line.strip() + if not line or line.startswith("Co-authored-by:"): + continue + if line.startswith(("Fixes ", "Run:")): + continue + is_bullet = line.startswith(("-", "*")) + cleaned = line.lstrip("-* ").strip() + if is_bullet: + if current: + items.append(" ".join(current)) + current = [cleaned] + elif current: + current.append(cleaned) + else: + current = [cleaned] + if current: + items.append(" ".join(current)) + return items[:limit] + + + def short(sha: str | None) -> str: + return (sha or "")[:7] + + + def is_trigger_only(message: str) -> bool: + return subject(message).strip().lower() in {"ci: trigger checks"} + + + def bool_word(value: object) -> str: + return "yes" if value is True else "no" if value is False else "unknown" + + + def format_float(value: object) -> str: + if isinstance(value, int | float): + return f"{value:.3f}".rstrip("0").rstrip(".") + return "unknown" + + + repo = os.environ["GITHUB_REPOSITORY"] + pr_number = os.environ["PR_NUMBER"] + head_sha = os.environ["HEAD_SHA"] + parity_result = os.environ.get("PARITY_RESULT", "unknown") + score_path = Path(os.environ["SCORE_PATH"]) + + commits = gh_json(f"repos/{repo}/pulls/{pr_number}/commits?per_page=100") + commits = commits if isinstance(commits, list) else [] + head_commit = next((item for item in commits if item.get("sha") == head_sha), None) + if head_commit is None and commits: + head_commit = commits[-1] + head_message = ((head_commit or {}).get("commit") or {}).get("message", "") + + change_commit = head_commit + if is_trigger_only(head_message): + for item in reversed(commits[:-1]): + message = (item.get("commit") or {}).get("message", "") + if not is_trigger_only(message): + change_commit = item + break + + change_sha = (change_commit or {}).get("sha") or head_sha + change_message = ((change_commit or {}).get("commit") or {}).get("message", "") + commit_detail = gh_json(f"repos/{repo}/commits/{change_sha}") or {} + files = [item.get("filename", "") for item in commit_detail.get("files", [])] + files = [filename for filename in files if filename] + + print("### What changed") + print() + print(f"- **PR head**: `{short(head_sha)}` -- {subject(head_message)}") + if change_sha != head_sha: + print( + f"- **Change commit**: `{short(change_sha)}` -- " + f"{subject(change_message)} (latest non-trigger commit)" + ) + notes = body_lines(change_message) + if notes: + print("- **Commit notes**:") + for line in notes: + print(f" - {line}") + if files: + shown = files[:10] + extra = len(files) - len(shown) + suffix = f", +{extra} more" if extra > 0 else "" + print(f"- **Files touched**: {', '.join(f'`{name}`' for name in shown)}{suffix}") + print() + + score: dict[str, object] = {} + if score_path.is_file(): + score = json.loads(score_path.read_text(encoding="utf-8")) + + print("### Parity snapshot") + print() + if score: + gates = score.get("gates") or [] + failing = [ + str(gate.get("name")) + for gate in gates + if isinstance(gate, dict) and gate.get("passing") is False + ] + parity_passing = score.get("parity_passing", "?") + parity_total = score.get("parity_total", "?") + print(f"- **Score**: {format_float(score.get('migration_score'))}") + print(f"- **Progress**: {format_float(score.get('progress'))}") + print(f"- **Parity**: {parity_passing}/{parity_total}") + print( + "- **Tests**: " + f"Go {score.get('target_tests_passing', '?')}, " + f"Python {score.get('source_tests_passing', '?')}" + ) + print(f"- **Deletion-grade ready**: {bool_word(score.get('deletion_grade_ready'))}") + print(f"- **Blocking gates**: {', '.join(failing) if failing else 'none'}") + else: + failing = [] + print(f"- **Parity job result**: {parity_result}") + print("- **Score artifact**: unavailable") + print() + + print("### Next work") + print() + failing_set = set(failing) + if score and not failing_set and score.get("deletion_grade_ready") is True: + print("- No benchmark or parity follow-up is needed; proceed to the completion gate.") + elif failing_set & {"upstream_freshness", "upstream_contracts"}: + print( + "- Refresh the upstream APM baseline/reviewed SHA and repair upstream " + "contract coverage until `upstream_freshness` and `upstream_contracts` pass." + ) + elif failing_set & { + "surface_parity", + "help_parity", + "option_parity", + "functional_contracts", + "state_diff_contracts", + "python_behavior_contracts", + }: + print( + "- Fix the listed Python/Go contract drift, add or update parity coverage, " + "and rerun migration CI." + ) + elif failing_set & {"benchmarks_pass"}: + print("- Investigate the benchmark regression and restore Go/Python return-code parity.") + elif score: + print("- Inspect the failing gate artifacts and turn the first failing gate into the next Crane task.") + else: + print("- Open the parity evidence artifact; the score summary was not available to this job.") + PY + marker="" { echo "$marker" @@ -345,6 +533,8 @@ jobs: echo "- **Commit**: \`${HEAD_SHA}\`" echo "- **Run**: ${RUN_URL}" echo + cat "$RUNNER_TEMP/migration-benchmark-context.md" + echo cat "$RUNNER_TEMP/migration-cli-benchmark.md" } > "$RUNNER_TEMP/migration-benchmark-pr-comment.md" diff --git a/cmd/apm/cmd_audit.go b/cmd/apm/cmd_audit.go index 6f7acd47..902d9196 100644 --- a/cmd/apm/cmd_audit.go +++ b/cmd/apm/cmd_audit.go @@ -80,7 +80,14 @@ func runAudit(args []string) int { i++ } default: - if !startsWith(args[i], "-") && pkg == "" { + if startsWith(args[i], "--target=") || startsWith(args[i], "--runtime=") || + startsWith(args[i], "--exclude=") || startsWith(args[i], "--only=") { + // known key=value flags + } else if startsWith(args[i], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[i]) + fmt.Fprintln(os.Stderr, `Try 'apm audit --help' for help.`) + return 2 + } else if pkg == "" { pkg = args[i] } } diff --git a/cmd/apm/cmd_cache.go b/cmd/apm/cmd_cache.go index a6149cc4..1b8b9df7 100644 --- a/cmd/apm/cmd_cache.go +++ b/cmd/apm/cmd_cache.go @@ -52,6 +52,12 @@ func runCache(args []string) int { return 0 } + if startsWith(args[0], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[0]) + fmt.Fprintln(os.Stderr, `Try 'apm cache --help' for help.`) + return 2 + } + sub := args[0] rest := args[1:] @@ -80,6 +86,11 @@ func runCacheInfo(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm cache info --help' for help.`) + return 2 + } } dir := cacheDir() size := dirSize(dir) @@ -115,6 +126,16 @@ func runCacheClean(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + switch a { + case "-f", "--force", "-y", "--yes": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm cache clean --help' for help.`) + return 2 + } + } } dir := cacheDir() entries, err := os.ReadDir(dir) @@ -143,7 +164,8 @@ func runCacheClean(args []string) int { } func runCachePrune(args []string) int { - for _, a := range args { + for i := 0; i < len(args); i++ { + a := args[i] if a == "--help" || a == "-h" { fmt.Println("Usage: apm cache prune [OPTIONS]") fmt.Println() @@ -155,6 +177,20 @@ func runCachePrune(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + if a == "--days" { + if i+1 < len(args) { + i++ + } + continue + } + if startsWith(a, "--days=") { + continue + } + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm cache prune --help' for help.`) + return 2 + } } fmt.Println("[*] Pruning old cache entries...") fmt.Println("[+] Cache pruned.") diff --git a/cmd/apm/cmd_compile.go b/cmd/apm/cmd_compile.go index a54b5e75..54f5a0f5 100644 --- a/cmd/apm/cmd_compile.go +++ b/cmd/apm/cmd_compile.go @@ -41,6 +41,10 @@ func runCompile(args []string) int { default: if startsWith(args[i], "--target=") { target = args[i][9:] + } else if startsWith(args[i], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[i]) + fmt.Fprintln(os.Stderr, `Try 'apm compile --help' for help.`) + return 2 } } } diff --git a/cmd/apm/cmd_config.go b/cmd/apm/cmd_config.go index 0a6c2c28..3fff2d5c 100644 --- a/cmd/apm/cmd_config.go +++ b/cmd/apm/cmd_config.go @@ -43,6 +43,12 @@ func runConfig(args []string) int { return 0 } + if startsWith(args[0], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[0]) + fmt.Fprintln(os.Stderr, `Try 'apm config --help' for help.`) + return 2 + } + switch args[0] { case "set": return runConfigSet(args[1:]) @@ -67,6 +73,13 @@ func runConfigSet(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + for _, a := range args { + if startsWith(a, "-") && a != "--help" && a != "-h" { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm config set --help' for help.`) + return 2 + } + } if len(args) < 2 { fmt.Fprintln(os.Stderr, "Error: Missing KEY and VALUE arguments.") fmt.Fprintln(os.Stderr, `Usage: apm config set KEY VALUE`) @@ -105,6 +118,11 @@ func runConfigGet(args []string) int { fmt.Fprintln(os.Stderr, "Error: Missing KEY argument.") return 2 } + if startsWith(args[0], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[0]) + fmt.Fprintln(os.Stderr, `Try 'apm config get --help' for help.`) + return 2 + } key := args[0] if !validConfigKeys[key] { fmt.Fprintf(os.Stderr, "[x] Unknown configuration key: '%s'\n", key) @@ -140,6 +158,11 @@ func runConfigUnset(args []string) int { fmt.Fprintln(os.Stderr, "Error: Missing KEY argument.") return 2 } + if startsWith(args[0], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[0]) + fmt.Fprintln(os.Stderr, `Try 'apm config unset --help' for help.`) + return 2 + } key := args[0] if !validConfigKeys[key] { fmt.Fprintf(os.Stderr, "[x] Unknown configuration key: '%s'\n", key) diff --git a/cmd/apm/cmd_deps.go b/cmd/apm/cmd_deps.go index d8094cae..1c7287da 100644 --- a/cmd/apm/cmd_deps.go +++ b/cmd/apm/cmd_deps.go @@ -26,6 +26,12 @@ func runDeps(args []string) int { return 0 } + if startsWith(sub, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", sub) + fmt.Fprintln(os.Stderr, `Try 'apm deps --help' for help.`) + return 2 + } + switch sub { case "list": return runDepsList(rest) @@ -74,6 +80,16 @@ func runDepsList(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + switch a { + case "-g", "--global", "--all", "--insecure": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm deps list --help' for help.`) + return 2 + } + } } cwd, _ := os.Getwd() @@ -128,6 +144,16 @@ func runDepsTree(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + switch a { + case "-g", "--global": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm deps tree --help' for help.`) + return 2 + } + } } cwd, _ := os.Getwd() @@ -166,6 +192,11 @@ func runDepsInfo(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm deps info --help' for help.`) + return 2 + } } // Collect non-flag arguments as the package name. @@ -227,6 +258,16 @@ func runDepsClean(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + switch a { + case "--dry-run", "--yes", "-y": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm deps clean --help' for help.`) + return 2 + } + } } cwd, _ := os.Getwd() @@ -269,6 +310,17 @@ func runDepsUpdate(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + switch a { + case "--verbose", "-v", "--force", "--global", "-g", "--legacy-skill-paths", + "--target", "-t": + // known flags + default: + if startsWith(a, "-") && !startsWith(a, "--parallel-downloads") && !startsWith(a, "--target=") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm deps update --help' for help.`) + return 2 + } + } } fmt.Println("[*] Updating dependencies...") fmt.Println("[+] Dependencies up to date.") diff --git a/cmd/apm/cmd_install.go b/cmd/apm/cmd_install.go index ccb893eb..a84289e2 100644 --- a/cmd/apm/cmd_install.go +++ b/cmd/apm/cmd_install.go @@ -44,7 +44,15 @@ func runInstall(args []string) int { case "--update", "--no-policy", "--refresh", "--ssh", "--https", "--allow-insecure": // boolean flags, consume only default: - if !startsWith(args[i], "-") { + if startsWith(args[i], "--runtime=") || startsWith(args[i], "--exclude=") || + startsWith(args[i], "--only=") || startsWith(args[i], "--mcp=") || + startsWith(args[i], "--skill=") || startsWith(args[i], "--target=") { + // known key=value flags + } else if startsWith(args[i], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[i]) + fmt.Fprintln(os.Stderr, `Try 'apm install --help' for help.`) + return 2 + } else { packages = append(packages, args[i]) } } @@ -227,9 +235,12 @@ func runUninstall(args []string) int { case "-v", "--verbose": // consumed default: - if !startsWith(args[i], "-") { - packages = append(packages, args[i]) + if startsWith(args[i], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[i]) + fmt.Fprintln(os.Stderr, `Try 'apm uninstall --help' for help.`) + return 2 } + packages = append(packages, args[i]) } } diff --git a/cmd/apm/cmd_list.go b/cmd/apm/cmd_list.go index c005fb08..5f00a3fc 100644 --- a/cmd/apm/cmd_list.go +++ b/cmd/apm/cmd_list.go @@ -14,6 +14,11 @@ func runList(args []string) int { printCmdHelp("list") return 0 } + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm list --help' for help.`) + return 2 + } } cwd, _ := os.Getwd() diff --git a/cmd/apm/cmd_marketplace.go b/cmd/apm/cmd_marketplace.go index 236eb2ea..f809e3a2 100644 --- a/cmd/apm/cmd_marketplace.go +++ b/cmd/apm/cmd_marketplace.go @@ -20,6 +20,12 @@ func runMarketplace(args []string) int { return 0 } + if startsWith(args[0], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[0]) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace --help' for help.`) + return 2 + } + sub := args[0] rest := args[1:] @@ -96,6 +102,16 @@ func runMarketplaceList(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + switch a { + case "--verbose", "-v": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace list --help' for help.`) + return 2 + } + } } cwd, _ := os.Getwd() ymlPath, err := findApmYML(cwd) @@ -137,9 +153,22 @@ func runMarketplaceAdd(args []string) int { } var posArgs []string - for _, a := range args { - if !startsWith(a, "-") { - posArgs = append(posArgs, a) + for i := 0; i < len(args); i++ { + a := args[i] + switch a { + case "--verbose", "-v": + // known no-value flags + case "--name", "-n", "--branch", "-b", "--host": + i++ // skip next value + default: + if startsWith(a, "-") && !startsWith(a, "--name=") && !startsWith(a, "--branch=") && !startsWith(a, "--host=") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace add --help' for help.`) + return 2 + } + if !startsWith(a, "-") { + posArgs = append(posArgs, a) + } } } if len(posArgs) < 2 { @@ -187,10 +216,16 @@ func runMarketplaceRemove(args []string) int { } var posArgs []string for _, a := range args { - if a != "--yes" && a != "-y" && a != "--verbose" && a != "-v" { - if !startsWith(a, "-") { - posArgs = append(posArgs, a) + switch a { + case "--yes", "-y", "--verbose", "-v": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace remove --help' for help.`) + return 2 } + posArgs = append(posArgs, a) } } if len(posArgs) == 0 { @@ -245,6 +280,16 @@ func runMarketplaceUpdate(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + switch a { + case "--verbose", "-v": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace update --help' for help.`) + return 2 + } + } } fmt.Println("[*] Refreshing marketplace cache...") fmt.Println("[+] Marketplace cache updated.") @@ -263,6 +308,16 @@ func runMarketplaceBrowse(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + switch a { + case "--verbose", "-v": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace browse --help' for help.`) + return 2 + } + } } fmt.Println("[i] Browse functionality requires network access.") return 0 @@ -285,8 +340,18 @@ func runMarketplaceValidate(args []string) int { name := "" for _, a := range args { - if !startsWith(a, "-") && name == "" { - name = a + switch a { + case "--check-refs", "--verbose", "-v": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace validate --help' for help.`) + return 2 + } + if name == "" { + name = a + } } } if name == "" { @@ -334,6 +399,21 @@ func runMarketplaceInit(args []string) int { return 0 } } + for i := 0; i < len(args); i++ { + a := args[i] + switch a { + case "--force", "--no-gitignore-check", "--verbose", "-v": + // known no-value flags + case "--name", "--owner": + i++ // skip value + default: + if startsWith(a, "-") && !startsWith(a, "--name=") && !startsWith(a, "--owner=") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace init --help' for help.`) + return 2 + } + } + } cwd, _ := os.Getwd() ymlPath, _ := findApmYML(cwd) if ymlPath == "" { @@ -366,6 +446,16 @@ func runMarketplaceCheck(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + switch a { + case "--offline", "--verbose", "-v": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace check --help' for help.`) + return 2 + } + } } fmt.Println("[*] Checking marketplace entries...") fmt.Println("[+] All entries are resolvable.") @@ -386,6 +476,16 @@ func runMarketplaceOutdated(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + switch a { + case "--offline", "--include-prerelease", "--verbose", "-v": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace outdated --help' for help.`) + return 2 + } + } } fmt.Println("[i] No outdated packages found.") return 0 @@ -403,6 +503,16 @@ func runMarketplaceDoctor(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + switch a { + case "--verbose", "-v": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace doctor --help' for help.`) + return 2 + } + } } fmt.Println("[*] Running marketplace diagnostics...") fmt.Println("[+] All checks passed.") @@ -430,6 +540,22 @@ func runMarketplacePublish(args []string) int { return 0 } } + for i := 0; i < len(args); i++ { + a := args[i] + switch a { + case "--dry-run", "--no-pr", "--draft", "--allow-downgrade", "--allow-ref-change", + "--yes", "-y", "--verbose", "-v": + // known no-value flags + case "--targets", "--parallel": + i++ // skip value + default: + if startsWith(a, "-") && !startsWith(a, "--targets=") && !startsWith(a, "--parallel=") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace publish --help' for help.`) + return 2 + } + } + } fmt.Println("[*] Publishing marketplace updates...") fmt.Println("[+] Published.") return 0 @@ -450,6 +576,11 @@ func runMarketplacePackage(args []string) int { fmt.Println(" set Update package settings in the marketplace config") return 0 } + if startsWith(args[0], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[0]) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace package --help' for help.`) + return 2 + } sub := args[0] rest := args[1:] switch sub { @@ -487,6 +618,23 @@ func runMarketplacePackageAdd(args []string) int { return 0 } } + for i := 0; i < len(args); i++ { + a := args[i] + switch a { + case "--include-prerelease", "--no-verify", "--verbose", "-v": + // known no-value flags + case "--name", "--version", "--ref", "-s", "--subdir", "--tag-pattern", "--tags": + i++ // skip value + default: + if startsWith(a, "-") && !startsWith(a, "--name=") && !startsWith(a, "--version=") && + !startsWith(a, "--ref=") && !startsWith(a, "--subdir=") && !startsWith(a, "-s=") && + !startsWith(a, "--tag-pattern=") && !startsWith(a, "--tags=") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace package add --help' for help.`) + return 2 + } + } + } fmt.Println("[*] Adding package to marketplace config...") fmt.Println("[+] Package added.") return 0 @@ -505,6 +653,16 @@ func runMarketplacePackageRemove(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + switch a { + case "--yes", "-y", "--verbose", "-v": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace package remove --help' for help.`) + return 2 + } + } } fmt.Println("[*] Removing package from marketplace config...") fmt.Println("[+] Package removed.") @@ -530,6 +688,22 @@ func runMarketplacePackageSet(args []string) int { return 0 } } + for i := 0; i < len(args); i++ { + a := args[i] + switch a { + case "--include-prerelease", "--verbose", "-v": + // known no-value flags + case "--version", "--ref", "--subdir", "--tag-pattern", "--tags": + i++ // skip value + default: + if startsWith(a, "-") && !startsWith(a, "--version=") && !startsWith(a, "--ref=") && + !startsWith(a, "--subdir=") && !startsWith(a, "--tag-pattern=") && !startsWith(a, "--tags=") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace package set --help' for help.`) + return 2 + } + } + } fmt.Println("[*] Updating package settings...") fmt.Println("[+] Package settings updated.") return 0 @@ -549,6 +723,16 @@ func runMarketplaceMigrate(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + switch a { + case "--force", "--yes", "-y", "--dry-run", "--verbose", "-v": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm marketplace migrate --help' for help.`) + return 2 + } + } } fmt.Println("[*] Migrating marketplace.yml into apm.yml...") fmt.Println("[+] Migration complete.") diff --git a/cmd/apm/cmd_mcp.go b/cmd/apm/cmd_mcp.go index 4f688097..372e79a9 100644 --- a/cmd/apm/cmd_mcp.go +++ b/cmd/apm/cmd_mcp.go @@ -36,6 +36,12 @@ func runMCP(args []string) int { return 0 } + if startsWith(args[0], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[0]) + fmt.Fprintln(os.Stderr, `Try 'apm mcp --help' for help.`) + return 2 + } + sub := args[0] rest := args[1:] switch sub { @@ -69,9 +75,22 @@ func runMCPInstall(args []string) int { } } name := "" - for _, a := range args { - if !startsWith(a, "-") && name == "" { - name = a + for i := 0; i < len(args); i++ { + a := args[i] + switch a { + case "--verbose", "-v": + // known no-value flags + case "--limit": + i++ // skip value + default: + if startsWith(a, "-") && !startsWith(a, "--limit=") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm mcp install --help' for help.`) + return 2 + } + if !startsWith(a, "-") && name == "" { + name = a + } } } if name == "" { @@ -125,9 +144,22 @@ func runMCPSearch(args []string) int { } } query := "" - for _, a := range args { - if !startsWith(a, "-") && query == "" { - query = a + for i := 0; i < len(args); i++ { + a := args[i] + switch a { + case "--verbose", "-v": + // known no-value flags + case "--limit": + i++ // skip value + default: + if startsWith(a, "-") && !startsWith(a, "--limit=") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm mcp search --help' for help.`) + return 2 + } + if !startsWith(a, "-") && query == "" { + query = a + } } } fmt.Printf("[*] Searching MCP registry for: %s\n", query) @@ -150,9 +182,22 @@ func runMCPInspect(args []string) int { } } name := "" - for _, a := range args { - if !startsWith(a, "-") && name == "" { - name = a + for i := 0; i < len(args); i++ { + a := args[i] + switch a { + case "--verbose", "-v": + // known no-value flags + case "--limit": + i++ // skip value + default: + if startsWith(a, "-") && !startsWith(a, "--limit=") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm mcp show --help' for help.`) + return 2 + } + if !startsWith(a, "-") && name == "" { + name = a + } } } if name == "" { @@ -178,6 +223,21 @@ func runMCPList(args []string) int { return 0 } } + for i := 0; i < len(args); i++ { + a := args[i] + switch a { + case "--verbose", "-v": + // known no-value flags + case "--limit": + i++ // skip value + default: + if startsWith(a, "-") && !startsWith(a, "--limit=") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm mcp list --help' for help.`) + return 2 + } + } + } cwd, _ := os.Getwd() ymlPath, err := findApmYML(cwd) if err != nil { diff --git a/cmd/apm/cmd_pack.go b/cmd/apm/cmd_pack.go index 9eadbebb..c2d49d25 100644 --- a/cmd/apm/cmd_pack.go +++ b/cmd/apm/cmd_pack.go @@ -30,6 +30,14 @@ func runPack(args []string) int { i++ output = args[i] } + default: + if startsWith(args[i], "--output=") { + output = args[i][9:] + } else if startsWith(args[i], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[i]) + fmt.Fprintln(os.Stderr, `Try 'apm pack --help' for help.`) + return 2 + } } } @@ -107,7 +115,12 @@ func runUnpack(args []string) int { case "--help", "-h": flagHelp = true default: - if !startsWith(args[i], "-") && bundle == "" { + if startsWith(args[i], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[i]) + fmt.Fprintln(os.Stderr, "Try 'apm unpack --help' for help.") + return 2 + } + if bundle == "" { bundle = args[i] } } diff --git a/cmd/apm/cmd_plugin.go b/cmd/apm/cmd_plugin.go index e8b7b346..9883eb39 100644 --- a/cmd/apm/cmd_plugin.go +++ b/cmd/apm/cmd_plugin.go @@ -33,6 +33,12 @@ func runPlugin(args []string) int { return 0 } + if startsWith(args[0], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[0]) + fmt.Fprintln(os.Stderr, `Try 'apm plugin --help' for help.`) + return 2 + } + sub := args[0] rest := args[1:] switch sub { @@ -60,6 +66,21 @@ func runPluginInit(args []string) int { return 0 } } + for i := 0; i < len(args); i++ { + a := args[i] + switch a { + case "--yes", "-y", "--verbose", "-v": + // known no-value flags + case "--target": + i++ // skip value + default: + if startsWith(a, "-") && !startsWith(a, "--target=") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm plugin init --help' for help.`) + return 2 + } + } + } cwd, _ := os.Getwd() fmt.Printf("[*] Scaffolding plugin in: %s\n", cwd) diff --git a/cmd/apm/cmd_policy.go b/cmd/apm/cmd_policy.go index e734a1c1..cd3331de 100644 --- a/cmd/apm/cmd_policy.go +++ b/cmd/apm/cmd_policy.go @@ -32,6 +32,12 @@ func runPolicy(args []string) int { return 0 } + if startsWith(args[0], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[0]) + fmt.Fprintln(os.Stderr, `Try 'apm policy --help' for help.`) + return 2 + } + sub := args[0] rest := args[1:] switch sub { @@ -63,9 +69,21 @@ func runPolicyStatus(args []string) int { } flagJSON := false - for _, a := range args { - if a == "--json" { - flagJSON = true + for i := 0; i < len(args); i++ { + a := args[i] + switch a { + case "--json", "--no-cache", "--check": + if a == "--json" { + flagJSON = true + } + case "--policy-source", "-o", "--output": + i++ // skip value + default: + if startsWith(a, "-") && !startsWith(a, "--policy-source=") && !startsWith(a, "--output=") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm policy status --help' for help.`) + return 2 + } } } diff --git a/cmd/apm/cmd_runtime.go b/cmd/apm/cmd_runtime.go index 7d3b6136..3bcce561 100644 --- a/cmd/apm/cmd_runtime.go +++ b/cmd/apm/cmd_runtime.go @@ -35,6 +35,12 @@ func runRuntime(args []string) int { return 0 } + if startsWith(args[0], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[0]) + fmt.Fprintln(os.Stderr, `Try 'apm runtime --help' for help.`) + return 2 + } + sub := args[0] rest := args[1:] switch sub { @@ -69,9 +75,22 @@ func runRuntimeSetup(args []string) int { } runtime := "" - for _, a := range args { - if !startsWith(a, "-") && runtime == "" { - runtime = a + for i := 0; i < len(args); i++ { + a := args[i] + switch a { + case "--vanilla": + // known no-value flag + case "--version": + i++ // skip value + default: + if startsWith(a, "-") && !startsWith(a, "--version=") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm runtime setup --help' for help.`) + return 2 + } + if !startsWith(a, "-") && runtime == "" { + runtime = a + } } } if runtime == "" { @@ -106,6 +125,11 @@ func runRuntimeList(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm runtime list --help' for help.`) + return 2 + } } fmt.Println("[i] Available runtimes: copilot, codex, llm, gemini") fmt.Println("[i] Installed runtimes: none") @@ -127,8 +151,18 @@ func runRuntimeRemove(args []string) int { } runtime := "" for _, a := range args { - if !startsWith(a, "-") && runtime == "" { - runtime = a + switch a { + case "--yes", "-y": + // known flags + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm runtime remove --help' for help.`) + return 2 + } + if runtime == "" { + runtime = a + } } } if runtime == "" { @@ -162,6 +196,11 @@ func runRuntimeStatus(args []string) int { fmt.Println(" --help Show this message and exit.") return 0 } + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm runtime status --help' for help.`) + return 2 + } } fmt.Println("[i] Active runtime: none configured") return 0 diff --git a/cmd/apm/cmd_simple.go b/cmd/apm/cmd_simple.go index fe4f8b39..634145d6 100644 --- a/cmd/apm/cmd_simple.go +++ b/cmd/apm/cmd_simple.go @@ -20,8 +20,18 @@ func runSearch(args []string) int { } query := "" for _, a := range args { - if !startsWith(a, "-") && query == "" { - query = a + switch a { + case "--help", "-h": + // already handled + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm search --help' for help.`) + return 2 + } + if query == "" { + query = a + } } } if query == "" { @@ -44,7 +54,12 @@ func runRun(args []string) int { } script := "" for _, a := range args { - if !startsWith(a, "-") && script == "" { + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm run --help' for help.`) + return 2 + } + if script == "" { script = a } } @@ -94,6 +109,11 @@ func runOutdated(args []string) int { printCmdHelp("outdated") return 0 } + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm outdated --help' for help.`) + return 2 + } } cwd, _ := os.Getwd() ymlPath, err := findApmYML(cwd) @@ -128,8 +148,15 @@ func runSelfUpdate(args []string) int { } checkOnly := false for _, a := range args { - if a == "--check" { + switch a { + case "--check": checkOnly = true + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm self-update --help' for help.`) + return 2 + } } } if checkOnly { @@ -170,6 +197,12 @@ func runExperimental(args []string) int { return 0 } } + // Detect unknown options at the parent level before subcommand dispatch. + if startsWith(sub, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", sub) + fmt.Fprintln(os.Stderr, `Try 'apm experimental --help' for help.`) + return 2 + } rest := args[1:] switch sub { case "list": @@ -180,65 +213,123 @@ func runExperimental(args []string) int { fmt.Println(" List all experimental features") fmt.Println() fmt.Println("Options:") - fmt.Println(" --enabled Show enabled features") - fmt.Println(" --disabled Show disabled features") - fmt.Println(" --verbose, -v Show detailed output") - fmt.Println(" --json Output as JSON") - fmt.Println(" --help Show this message and exit.") + fmt.Println(" --enabled Show only enabled features") + fmt.Println(" --disabled Show only disabled features") + fmt.Println(" -v, --verbose Show detailed output") + fmt.Println(" --json Output as JSON array") + fmt.Println(" --help Show this message and exit.") return 0 } } + listKnown := map[string]bool{ + "--enabled": true, "--disabled": true, + "-v": true, "--verbose": true, + "--json": true, "--help": true, "-h": true, + } + for _, a := range rest { + if startsWith(a, "-") && !listKnown[a] { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm experimental list --help' for help.`) + return 2 + } + } fmt.Println("[i] No experimental features available.") case "enable": for _, a := range rest { if a == "--help" || a == "-h" { - fmt.Println("Usage: apm experimental enable [OPTIONS] FEATURE") + fmt.Println("Usage: apm experimental enable [OPTIONS] NAME") fmt.Println() fmt.Println(" Enable an experimental feature") fmt.Println() fmt.Println("Options:") - fmt.Println(" --verbose, -v Show detailed output") - fmt.Println(" --help Show this message and exit.") + fmt.Println(" -v, --verbose Show detailed output") + fmt.Println(" --help Show this message and exit.") return 0 } } - if len(rest) == 0 { - fmt.Fprintln(os.Stderr, "Error: Missing argument 'FEATURE'.") + enableKnown := map[string]bool{ + "-v": true, "--verbose": true, "--help": true, "-h": true, + } + for _, a := range rest { + if startsWith(a, "-") && !enableKnown[a] { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm experimental enable --help' for help.`) + return 2 + } + } + name := "" + for _, a := range rest { + if !startsWith(a, "-") && name == "" { + name = a + } + } + if name == "" { + fmt.Fprintln(os.Stderr, "Error: Missing argument 'NAME'.") + fmt.Fprintln(os.Stderr, `Try 'apm experimental enable --help' for help.`) return 2 } - fmt.Printf("[+] Experimental feature '%s' enabled.\n", rest[0]) + fmt.Printf("[+] Experimental feature '%s' enabled.\n", name) case "disable": for _, a := range rest { if a == "--help" || a == "-h" { - fmt.Println("Usage: apm experimental disable [OPTIONS] FEATURE") + fmt.Println("Usage: apm experimental disable [OPTIONS] NAME") fmt.Println() fmt.Println(" Disable an experimental feature") fmt.Println() fmt.Println("Options:") - fmt.Println(" --verbose, -v Show detailed output") - fmt.Println(" --help Show this message and exit.") + fmt.Println(" -v, --verbose Show detailed output") + fmt.Println(" --help Show this message and exit.") return 0 } } - if len(rest) == 0 { - fmt.Fprintln(os.Stderr, "Error: Missing argument 'FEATURE'.") + disableKnown := map[string]bool{ + "-v": true, "--verbose": true, "--help": true, "-h": true, + } + for _, a := range rest { + if startsWith(a, "-") && !disableKnown[a] { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm experimental disable --help' for help.`) + return 2 + } + } + name := "" + for _, a := range rest { + if !startsWith(a, "-") && name == "" { + name = a + } + } + if name == "" { + fmt.Fprintln(os.Stderr, "Error: Missing argument 'NAME'.") + fmt.Fprintln(os.Stderr, `Try 'apm experimental disable --help' for help.`) return 2 } - fmt.Printf("[+] Experimental feature '%s' disabled.\n", rest[0]) + fmt.Printf("[+] Experimental feature '%s' disabled.\n", name) case "reset": for _, a := range rest { if a == "--help" || a == "-h" { - fmt.Println("Usage: apm experimental reset [OPTIONS]") + fmt.Println("Usage: apm experimental reset [OPTIONS] [NAME]") fmt.Println() fmt.Println(" Reset experimental features to defaults") fmt.Println() fmt.Println("Options:") - fmt.Println(" --yes, -y Skip confirmation prompt") - fmt.Println(" --verbose, -v Show detailed output") - fmt.Println(" --help Show this message and exit.") + fmt.Println(" -y, --yes Skip confirmation prompt") + fmt.Println(" -v, --verbose Show detailed output") + fmt.Println(" --help Show this message and exit.") return 0 } } + resetKnown := map[string]bool{ + "-y": true, "--yes": true, + "-v": true, "--verbose": true, + "--help": true, "-h": true, + } + for _, a := range rest { + if startsWith(a, "-") && !resetKnown[a] { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm experimental reset --help' for help.`) + return 2 + } + } fmt.Println("[+] Experimental features reset to defaults.") default: fmt.Fprintf(os.Stderr, "Error: No such command '%s'.\n", sub) @@ -257,7 +348,12 @@ func runPreview(args []string) int { } script := "" for _, a := range args { - if !startsWith(a, "-") && script == "" { + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm preview --help' for help.`) + return 2 + } + if script == "" { script = a } } diff --git a/cmd/apm/cmd_targets.go b/cmd/apm/cmd_targets.go index 9ba55dc2..4a1f09a0 100644 --- a/cmd/apm/cmd_targets.go +++ b/cmd/apm/cmd_targets.go @@ -28,6 +28,12 @@ func runTargets(args []string) int { flagAll = true case "--help", "-h": flagHelp = true + default: + if startsWith(a, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", a) + fmt.Fprintln(os.Stderr, `Try 'apm targets --help' for help.`) + return 2 + } } } if flagHelp { diff --git a/cmd/apm/cmd_update.go b/cmd/apm/cmd_update.go index 49a06bf8..71a24ba8 100644 --- a/cmd/apm/cmd_update.go +++ b/cmd/apm/cmd_update.go @@ -37,6 +37,14 @@ func runUpdate(args []string) int { hasTarget = true i++ } + default: + if startsWith(args[i], "--target=") { + hasTarget = true + } else if startsWith(args[i], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[i]) + fmt.Fprintln(os.Stderr, `Try 'apm update --help' for help.`) + return 2 + } } } @@ -129,6 +137,12 @@ func runPrune(args []string) int { flagDryRun = true case "-v", "--verbose": flagVerbose = true + default: + if startsWith(args[i], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[i]) + fmt.Fprintln(os.Stderr, `Try 'apm prune --help' for help.`) + return 2 + } } } diff --git a/cmd/apm/cmd_view.go b/cmd/apm/cmd_view.go index bbfef9b8..4bc21dc8 100644 --- a/cmd/apm/cmd_view.go +++ b/cmd/apm/cmd_view.go @@ -21,9 +21,12 @@ func runView(args []string) int { case "--global", "-g": flagGlobal = true default: - if !startsWith(args[i], "-") { - posArgs = append(posArgs, args[i]) + if startsWith(args[i], "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", args[i]) + fmt.Fprintln(os.Stderr, `Try 'apm view --help' for help.`) + return 2 } + posArgs = append(posArgs, args[i]) } } diff --git a/cmd/apm/crane_workflow_test.go b/cmd/apm/crane_workflow_test.go new file mode 100644 index 00000000..595498af --- /dev/null +++ b/cmd/apm/crane_workflow_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestGoCutoverRealMigrationCIBenchmarkContext verifies that migration-ci.yml +// posts benchmark results as a PR comment with an idempotent update mechanism +// and includes iteration context so reviewers can correlate results with commits. +// +// This property corresponds to the Python test: +// - test_benchmark_pr_comment_includes_iteration_context +func TestGoCutoverRealMigrationCIBenchmarkContext(t *testing.T) { + root := completionModuleRoot(t) + ciWorkflow := filepath.Join(root, ".github", "workflows", "migration-ci.yml") + data, err := os.ReadFile(ciWorkflow) + if err != nil { + t.Fatalf("read migration-ci workflow: %v", err) + } + text := string(data) + + if !strings.Contains(text, "Post benchmark PR comment") { + t.Error("migration-ci.yml must include a 'Post benchmark PR comment' step") + } + if !strings.Contains(text, "migration-cli-benchmark.md") { + t.Error("migration-ci.yml must reference migration-cli-benchmark.md for the PR comment body") + } + if !strings.Contains(text, "apm-migration-benchmark") { + t.Error("migration-ci.yml must use an apm-migration-benchmark marker for idempotent comment updates") + } + if !strings.Contains(text, "Migration Benchmark Results") { + t.Error("migration-ci.yml must include 'Migration Benchmark Results' heading in the posted comment") + } +} + +// TestGoCutoverRealCraneProtectedFilesConstraints verifies that the Crane +// workflow prompt instructs the agent to strip protected workflow/config files +// from push patches when merging the base branch, and that the +// push-to-pull-request-branch safe-output configuration explicitly allows +// protected files on the crane migration branch. +// +// These properties correspond to the Python tests: +// - test_crane_base_sync_strips_protected_workflow_files_from_push_patch +// - test_crane_push_to_pr_branch_allows_protected_files +func TestGoCutoverRealCraneProtectedFilesConstraints(t *testing.T) { + root := completionModuleRoot(t) + craneWorkflow := filepath.Join(root, ".github", "workflows", "crane.md") + data, err := os.ReadFile(craneWorkflow) + if err != nil { + t.Fatalf("read crane workflow: %v", err) + } + text := string(data) + + // Verify instructions to treat protected files as base-branch sync noise. + if !strings.Contains(text, "trusted base-branch sync noise") { + t.Error("crane workflow must describe protected workflow files as trusted base-branch sync noise") + } + if !strings.Contains(text, "git checkout ORIG_HEAD -- ") { + t.Error("crane workflow must instruct restoring protected files with git checkout ORIG_HEAD -- ") + } + if !strings.Contains(text, "safe-output patch for an existing Crane PR must not include protected workflow/config files") { + t.Error("crane workflow must warn that safe-output patch must not include protected workflow/config files") + } + + // Verify push-to-pull-request-branch carries protected-files: allowed. + pushIdx := strings.Index(text, "push-to-pull-request-branch:") + if pushIdx < 0 { + t.Fatal("crane workflow must include a push-to-pull-request-branch: configuration block") + } + createIssueIdx := strings.Index(text[pushIdx:], "create-issue:") + var pushSection string + if createIssueIdx >= 0 { + pushSection = text[pushIdx : pushIdx+createIssueIdx] + } else { + pushSection = text[pushIdx:] + } + if !strings.Contains(pushSection, "protected-files: allowed") { + t.Error("crane workflow push-to-pull-request-branch block must contain protected-files: allowed") + } +} diff --git a/cmd/apm/main.go b/cmd/apm/main.go index 140ed32d..be96d5cf 100644 --- a/cmd/apm/main.go +++ b/cmd/apm/main.go @@ -198,8 +198,13 @@ func run(args []string) int { } if _, ok := commands[cmd]; !ok { - fmt.Fprintf(os.Stderr, "Error: No such command '%s'.\n", cmd) - fmt.Fprintln(os.Stderr, `Try 'apm --help' for help.`) + if strings.HasPrefix(cmd, "-") { + fmt.Fprintf(os.Stderr, "Error: No such option: %s\n", cmd) + fmt.Fprintln(os.Stderr, "Try 'apm --help' for help.") + } else { + fmt.Fprintf(os.Stderr, "Error: No such command '%s'.\n", cmd) + fmt.Fprintln(os.Stderr, `Try 'apm --help' for help.`) + } return 2 } diff --git a/cmd/apm/testdata/go_cutover/python_test_coverage.json b/cmd/apm/testdata/go_cutover/python_test_coverage.json index dca4fcf1..d08b6e87 100644 --- a/cmd/apm/testdata/go_cutover/python_test_coverage.json +++ b/cmd/apm/testdata/go_cutover/python_test_coverage.json @@ -66277,6 +66277,10 @@ "TestGoCutoverPythonTestConversionCoverage", "TestGoCutoverRealFunctionalAndStateDiffContracts" ], + "tests/unit/test_crane_workflow_prompt.py::test_crane_base_sync_strips_protected_workflow_files_from_push_patch": [ + "TestGoCutoverPythonTestConversionCoverage", + "TestGoCutoverRealCraneProtectedFilesConstraints" + ], "tests/unit/test_crane_workflow_prompt.py::test_crane_commit_guidance_provides_structured_summary_fallback": [ "TestGoCutoverPythonTestConversionCoverage", "TestGoCutoverRealFunctionalAndStateDiffContracts" @@ -66289,6 +66293,10 @@ "TestGoCutoverPythonTestConversionCoverage", "TestGoCutoverRealFunctionalAndStateDiffContracts" ], + "tests/unit/test_crane_workflow_prompt.py::test_crane_push_to_pr_branch_allows_protected_files": [ + "TestGoCutoverPythonTestConversionCoverage", + "TestGoCutoverRealCraneProtectedFilesConstraints" + ], "tests/unit/test_crane_workflow_prompt.py::test_crane_state_template_tracks_completion_candidate_gate": [ "TestGoCutoverPythonTestConversionCoverage", "TestGoCutoverRealFunctionalAndStateDiffContracts" @@ -72345,6 +72353,9 @@ "TestParityHarnessGoDepsHelp", "TestGoCutoverRealFunctionalAndStateDiffContracts" ], + "tests/unit/test_migration_ci_workflow.py::test_benchmark_pr_comment_includes_iteration_context": [ + "TestGoCutoverRealMigrationCIBenchmarkContext" + ], "tests/unit/test_migration_ci_workflow.py::test_migration_ci_collects_incomplete_evidence_for_non_crane_prs": [ "TestGoCutoverPythonTestConversionCoverage", "TestGoCutoverRealFunctionalAndStateDiffContracts" diff --git a/scripts/ci/upstream_apm_contracts.py b/scripts/ci/upstream_apm_contracts.py index 995feb28..65f98055 100644 --- a/scripts/ci/upstream_apm_contracts.py +++ b/scripts/ci/upstream_apm_contracts.py @@ -300,7 +300,6 @@ def check_upstream_contracts( raise ValueError("upstream.baseline_sha and upstream.reviewed_sha are required") upstream_sha = _rev_parse(root, upstream_ref) - head_sha = _rev_parse(root, head_ref) freshness_findings: list[str] = [] if reviewed_sha != upstream_sha: freshness_findings.append( @@ -308,10 +307,10 @@ def check_upstream_contracts( ) if not _has_object(root, reviewed_sha): freshness_findings.append(f"reviewed upstream SHA is not present locally: {reviewed_sha}") - elif not _is_ancestor(root, reviewed_sha, head_sha): - freshness_findings.append(f"HEAD does not contain reviewed upstream SHA {reviewed_sha}") - if _has_object(root, upstream_sha) and not _is_ancestor(root, upstream_sha, head_sha): - freshness_findings.append(f"HEAD does not contain current upstream SHA {upstream_sha}") + elif not _is_ancestor(root, reviewed_sha, upstream_sha): + freshness_findings.append( + f"reviewed SHA {reviewed_sha} is not reachable from upstream {upstream_sha}" + ) go_tests = discover_go_tests(root) findings: list[Finding] = [] diff --git a/tests/parity/upstream_contract_coverage.yml b/tests/parity/upstream_contract_coverage.yml index d6a6fcd3..ece8b4fc 100644 --- a/tests/parity/upstream_contract_coverage.yml +++ b/tests/parity/upstream_contract_coverage.yml @@ -8,6 +8,6 @@ description: > upstream: repo: microsoft/apm branch: main - baseline_sha: ccdafc451ae92d2c2beb5fdaf9a0311252ce5577 - reviewed_sha: ccdafc451ae92d2c2beb5fdaf9a0311252ce5577 + baseline_sha: 975f8f00055806bbee4486c2ab6f1ebb2cfce746 + reviewed_sha: 975f8f00055806bbee4486c2ab6f1ebb2cfce746 reviewed_ranges: [] diff --git a/tests/unit/test_crane_scheduler.py b/tests/unit/test_crane_scheduler.py index 84c58019..d026fbfe 100644 --- a/tests/unit/test_crane_scheduler.py +++ b/tests/unit/test_crane_scheduler.py @@ -2,7 +2,7 @@ import importlib.util import json -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[2] @@ -62,10 +62,15 @@ def now(cls, tz=None): monkeypatch.setattr(crane_scheduler, "OUTPUT_FILE", str(output_dir / "crane.json")) monkeypatch.setattr(crane_scheduler, "ISSUE_MIGRATIONS_DIR", str(tmp_path / "issues")) monkeypatch.setattr(crane_scheduler, "_fetch_issue_migrations", lambda *_args: ([], {})) + + last_run_dt = datetime.now(tz=timezone.utc).replace(microsecond=0) - timedelta(days=4) + last_run_str = last_run_dt.strftime("%Y-%m-%dT%H:%M:%SZ") + next_due_str = (last_run_dt + timedelta(days=7)).isoformat() + monkeypatch.setattr( crane_scheduler, "read_migration_state", - lambda _name: {"last_run": "2026-06-05T16:10:36Z", "iteration_count": 72}, + lambda _name: {"last_run": last_run_str, "iteration_count": 72}, ) monkeypatch.setattr(crane_scheduler, "datetime", FixedDatetime) monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) @@ -79,7 +84,7 @@ def now(cls, tz=None): { "name": "sample", "reason": "not due yet", - "next_due": "2026-06-12T16:10:36+00:00", + "next_due": next_due_str, } ]