From b9fb445f222290f475324ee96b3d01c9ed173ac1 Mon Sep 17 00:00:00 2001 From: AndreasS Date: Thu, 24 Sep 2026 16:07:51 +0200 Subject: [PATCH 1/3] docs(#170): add change reject-unconsumed-cli-args --- .../reject-unconsumed-cli-args/proposal.md | 123 ++++++++++++++++++ .../reject-unconsumed-cli-args/tasks.md | 65 +++++++++ 2 files changed, 188 insertions(+) create mode 100644 openspec/changes/reject-unconsumed-cli-args/proposal.md create mode 100644 openspec/changes/reject-unconsumed-cli-args/tasks.md diff --git a/openspec/changes/reject-unconsumed-cli-args/proposal.md b/openspec/changes/reject-unconsumed-cli-args/proposal.md new file mode 100644 index 0000000..3d66124 --- /dev/null +++ b/openspec/changes/reject-unconsumed-cli-args/proposal.md @@ -0,0 +1,123 @@ +# Reject unconsumed CLI arguments instead of silently dropping them + +## Why + +Reproduced 2026-09-24 against a real, shared GitHub repo (`ExopenGitHub/portal`): + +``` +$ specsync -repo ExopenGitHub/portal https://github.com/ExopenGitHub/portal/issues/4304 -dry-run +``` + +Intent: pull/inspect a single issue. Actual result: specsync ran a full **live** +`sync` of every local OpenSpec change under the current directory (`~/dev/specsync`'s +own ~80-item internal backlog) and created all of them as real issues on +`ExopenGitHub/portal` — a repo those changes have nothing to do with. `-dry-run` had +no effect whatsoever; every issue was actually created. Cleanup required manually +deleting issues #4370–#4450. + +Two independent defects compounded: + +1. **`runSync` never checks for leftover positional arguments.** `resolveSubcommand` + already dispatches a bare `-repo ...` invocation (no subcommand word) to `sync`, + which is correct — but the URL positional argument that followed had nowhere to + go. `runSync` calls `fs.Parse(args)` and then proceeds unconditionally; it never + inspects `fs.NArg()`. The stray URL was silently discarded, `-change` stayed + unset, and an unset `-change` means "sync **every** change" — turning a mistyped + single-issue command into a full destructive sync with no error, warning, or + pause of any kind. +2. **`-dry-run` was silently never parsed at all**, independent of defect 1. Go's + stdlib `flag` package stops parsing at the very first non-flag token. Because the + URL came *before* `-dry-run` on the command line, `flag.Parse` stopped at the URL + and never saw `-dry-run` — it kept its zero value (`false`). This is a generic, + well-known footgun of `flag`, and it means **any** flag placed after **any** + positional-looking token on **any** specsync subcommand — not just `sync` — is + silently dropped without so much as a warning. `main.go` already documents this + exact mechanism in a comment on `resolveSubcommand` (guarding against a + *mistyped subcommand word* reaching this trap) but the guard was never extended + to stray positional arguments *within* an otherwise-valid flag set. + +Either defect alone would have been a near-miss. Together they turned a typo'd +invocation into 81 live writes against a shared repository with zero feedback that +anything unusual was happening — the tool's own stdout looked exactly like a normal +successful bulk sync. + +## What Changes + +- **`sync` (and every other subcommand whose flag set takes no free-form positional + arguments) fails loudly on any leftover argument.** After `fs.Parse(args)`, check + `fs.NArg()`; if non-zero, exit non-zero with an error naming the exact unconsumed + argument(s) and stating plainly that any flags appearing after them (Go's `flag` + package stops parsing at the first non-flag token) were **not applied** — do not + let the run proceed as if nothing were wrong. Audit every subcommand in + `cmd/specsync/main.go` (`sync`, `audit`, `audit-tasks`, `validate`, `archive`, + `ideas`, and any others) to classify which legitimately accept positional + arguments (`link`, `set-stage`, `set-priority`, `note`, `spinoff` via `-from`) and + add the same `NArg()` guard to every one that does not. +- **Detect flags shadowed by an earlier positional token, across every subcommand — + including ones that legitimately take positional arguments.** Before `fs.Parse`, + scan the raw `args` for any token matching a flag registered on that subcommand's + `FlagSet` (`-name` / `--name` / `-name=value`) that appears *after* the first + non-flag token. If found, fail loudly listing the shadowed flag name(s) instead of + silently keeping their zero value — this is the generic form of defect 2 and would + independently have caught this incident even if defect 1 had not existed. +- **Print an unmissable pre-flight summary before any real (non-dry-run) write.** + Every real `sync` run should state, before doing anything: the resolved target + repo, the resolved spec-source directory, and the number of changes about to be + synced (`target: ExopenGitHub/portal — spec source: ~/dev/specsync/openspec (81 + changes) — 0 already linked, 81 new`). A human skimming this line would have + caught "81 new" against a repo they'd only ever pushed one issue to. +- **Regression test reproducing this incident's exact shape**: `specsync -repo + -dry-run` run against a directory whose `openspec/changes/` holds + unrelated changes must fail with a clear "unexpected argument" error, make **zero** + provider calls, and exit non-zero. + +## Capabilities + +### Modified Capabilities + +- CLI argument parsing (`cmd/specsync/main.go`): every subcommand rejects + unconsumed positional arguments and shadowed flags instead of silently + discarding them. +- `sync`'s real-run output: gains a pre-flight target/count summary before any + write. + +## Non-Goals + +- **Not** reworking argument parsing onto a different library (e.g. `cobra`, + `kong`). The fix is a validation pass around the existing stdlib `flag` usage, + not a framework migration — that's a much larger, separate change. +- **Not** making `-repo` scope *which* local `openspec/` directory is read. That + remains cwd-based by design (documented in `resolveRoot`); the incident's actual + bug was that a mismatch between the two produced *no signal at all*, not that the + mismatch itself is disallowed. A deliberate cross-repo `sync` (e.g. `scopedTargetRepos`) + is a legitimate, existing use case and must keep working. +- **Not** adding a confirmation prompt before every real sync. That would make + routine, correct usage (CI, scripted syncs) annoying or outright broken. The + pre-flight summary is informational, not a blocking prompt. + +## Open Questions + +- **Should the shadowed-flag scan be exact-name matching only, or also catch + near-miss typos** (e.g. `-dryrun` instead of `-dry-run`)? Leaning exact-match + only for this change — typo detection is a separate, fuzzier problem + (`advisory-title-suggestions` territory) and conflating them risks false + positives that make the new guard itself untrustworthy. +- **Does the shadowed-flag scan need to know each flag's arity** (boolean vs + value-consuming) to correctly identify "the first non-flag token"? Yes — e.g. + `-repo ExopenGitHub/portal` is two tokens for one flag; the scan must walk the + `FlagSet`'s registered flags (via `fs.VisitAll`) to know which names consume a + following value, mirroring how `flag.Parse` itself walks arguments, or it will + misidentify a flag's value token as "the first positional." + +## Impact + +- `cmd/specsync/main.go`: `runSync` and every other flag-parsing `run*` function + gains an unconsumed-argument / shadowed-flag check immediately after + `fs.Parse`. +- A new shared helper (e.g. `rejectUnconsumedArgs(fs *flag.FlagSet, rawArgs + []string)`) used by every subcommand, so the fix can't silently regress on the + next new subcommand the way it silently never covered `sync` this time. +- `runSync`'s real-run path gains a one-line pre-flight summary before the first + provider write. +- New regression test(s) under `cmd/specsync/` reproducing the 2026-09-24 + incident's argument shape. diff --git a/openspec/changes/reject-unconsumed-cli-args/tasks.md b/openspec/changes/reject-unconsumed-cli-args/tasks.md new file mode 100644 index 0000000..88e8b2a --- /dev/null +++ b/openspec/changes/reject-unconsumed-cli-args/tasks.md @@ -0,0 +1,65 @@ +# Tasks: reject-unconsumed-cli-args + +Task 1 is the exact defect that caused the 2026-09-24 incident (81 live issues +created on `ExopenGitHub/portal` from an unrelated invocation). Task 2 is the +independent, more general defect that made `-dry-run` silently do nothing. Task 5 +is the regression test proving both are actually fixed. + +- [ ] 1. Add a shared `rejectUnconsumedArgs(fs *flag.FlagSet)` helper (fails loudly, + naming the exact leftover argument(s), if `fs.NArg() > 0`) and call it + immediately after `fs.Parse(args)` in every subcommand whose flag set takes + no legitimate positional arguments: `runSync`, `runAudit`, `runAuditTasks`, + `runValidate`, `runArchive`, `runIdeas`, `runIdea` (idea text is the one + expected positional — special-case it), and any others found during the + audit. Reproduced 2026-09-24: `specsync -repo ExopenGitHub/portal + -dry-run` silently ignored the URL as a stray argument, left + `-change` unset (meaning "sync everything"), and proceeded as a live full + sync. + Validation: `specsync -repo owner/repo https://example.com/foo -dry-run` (or + any subcommand + stray non-flag arg) now exits non-zero with an error + naming the stray argument, and makes no provider calls. + +- [ ] 2. Detect flags shadowed by an earlier positional token — the independent bug + that made `-dry-run` have zero effect regardless of task 1. Before + `fs.Parse`, walk the subcommand's registered flags via `fs.VisitAll` to know + each flag's arity, then scan the raw `args` for the first true positional + token; any subsequent token matching a registered flag name is "shadowed." + Fail loudly listing the shadowed flag name(s) instead of silently keeping + their zero value. Apply this to every subcommand, including ones that + legitimately take positional arguments (`link`, `set-stage`, `set-priority`, + `note`, `spinoff`), since the shadowing bug applies to them too. + Validation: `specsync note some-slug "text" -dry-run` currently silently + writes for real (dry-run shadowed by the two positionals); after the fix it + either parses correctly or fails loudly naming `-dry-run` as shadowed — + either outcome beats "silently does the opposite of what was asked." + +- [ ] 3. Add a pre-flight summary printed before any real (non-dry-run) write in + `runSync`: resolved target repo, resolved spec-source directory, and count + of changes about to be synced (`target: X — spec source: Y (N changes)`). + Validation: a real `sync` run's first line of output states the repo, the + spec source path, and the change count before any provider call. + +- [ ] 4. Decide the shadowed-flag scan's matching strategy (Open Question 1: exact + name only, no typo-fuzzing) and confirm the arity-lookup approach (Open + Question 2: `fs.VisitAll` to distinguish boolean vs value-consuming flags). + Record both decisions. + Validation: a `design.md` note states the decisions; the task-2 + implementation matches them. + +- [ ] 5. Regression test reproducing the 2026-09-24 incident's exact argument + shape end to end: `specsync -repo -dry-run` run against a + fixture directory whose `openspec/changes/` holds changes unrelated to + `` must fail with a clear "unexpected argument" error, make zero + provider calls (assert via a call-counting fake provider), and exit + non-zero. + Validation: the test fails against the pre-fix code and passes after tasks + 1–2 land. + +- [ ] 6. Sweep the rest of `cmd/specsync/main.go` for any other place that calls + `fs.Parse` and ignores `fs.NArg()`/leftover args, beyond the list in task 1 + — confirm the audit was exhaustive rather than covering only the reported + cases. + Validation: every `run*` function in the file either has no free-form + positional arguments and calls `rejectUnconsumedArgs`, or documents (in a + comment) exactly which positional arguments it expects and why leftover + args beyond those still fail loudly. From b9b3eacb8a4151412ac787ec7db1ba924d120180 Mon Sep 17 00:00:00 2001 From: AndreasS Date: Thu, 24 Sep 2026 16:20:13 +0200 Subject: [PATCH 2/3] fix(#170): reject unconsumed CLI arguments instead of silently dropping them Adds a shared checkArgs/shadowedFlags check, wired into every subcommand's flag parsing, that fails loudly on two compounding defects behind the 2026-09-24 incident: leftover positional arguments (an unset -change means "sync everything") and flags shadowed by an earlier positional token (Go's flag package stops parsing at the first non-flag argument, so a trailing -dry-run was never even seen). Also adds a pre-flight target/count summary before any real sync write, and a regression test reproducing the incident's exact argument shape end to end against a stub gh. --- cmd/specsync/argcheck_test.go | 170 ++++++++++++++++++ cmd/specsync/changelog.go | 1 + cmd/specsync/epic.go | 1 + cmd/specsync/installskill.go | 1 + cmd/specsync/main.go | 144 +++++++++++++++ cmd/specsync/topology.go | 1 + cmd/specsync/traceability.go | 5 + .../reject-unconsumed-cli-args/design.md | 19 ++ .../reject-unconsumed-cli-args/tasks.md | 12 +- 9 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 cmd/specsync/argcheck_test.go create mode 100644 openspec/changes/reject-unconsumed-cli-args/design.md diff --git a/cmd/specsync/argcheck_test.go b/cmd/specsync/argcheck_test.go new file mode 100644 index 0000000..8c4a2bf --- /dev/null +++ b/cmd/specsync/argcheck_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "flag" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" +) + +// TestShadowedFlags pins the exact 2026-09-24 incident shape: a flag placed +// after a positional-looking token is never seen by fs.Parse (Go's flag +// package stops parsing at the first non-flag argument) and must be reported +// as shadowed instead of silently keeping its zero value. +func TestShadowedFlags(t *testing.T) { + newFS := func() *flag.FlagSet { + fs := flag.NewFlagSet("sync", flag.ContinueOnError) + fs.String("repo", "", "") + fs.String("change", "", "") + fs.Bool("dry-run", false, "") + return fs + } + + tests := []struct { + name string + args []string + want []string + }{ + { + name: "incident shape: url before -dry-run", + args: []string{"-repo", "ExopenGitHub/portal", "https://github.com/ExopenGitHub/portal/issues/4304", "-dry-run"}, + want: []string{"-dry-run"}, + }, + { + name: "no positional: nothing shadowed", + args: []string{"-repo", "owner/repo", "-dry-run"}, + want: nil, + }, + { + name: "flags before positional: nothing shadowed", + args: []string{"-dry-run", "-repo", "owner/repo", "some-positional"}, + want: nil, + }, + { + name: "value flag after positional is also shadowed", + args: []string{"some-positional", "-repo", "owner/repo"}, + want: []string{"-repo"}, + }, + { + name: "inline = value flag after positional", + args: []string{"some-positional", "-repo=owner/repo"}, + want: []string{"-repo"}, + }, + { + name: "unregistered flag after positional is not reported", + args: []string{"some-positional", "-not-a-real-flag"}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := newFS() + got := shadowedFlags(fs, tt.args) + if strings.Join(got, ",") != strings.Join(tt.want, ",") { + t.Fatalf("shadowedFlags(%v) = %v, want %v", tt.args, got, tt.want) + } + }) + } +} + +// TestCheckArgs verifies checkArgs reports both an unconsumed-positional +// problem and a shadowed-flag problem in the same call when both are +// present — the exact combination that turned the 2026-09-24 incident's +// mistyped invocation into a full live sync with zero feedback. +func TestCheckArgs(t *testing.T) { + fs := flag.NewFlagSet("sync", flag.ContinueOnError) + fs.String("repo", "", "") + fs.Bool("dry-run", false, "") + + rawArgs := []string{"-repo", "ExopenGitHub/portal", "https://github.com/ExopenGitHub/portal/issues/4304", "-dry-run"} + _ = fs.Parse(rawArgs) + if fs.NArg() != 2 { + t.Fatalf("expected 2 leftover args, got %d: %v", fs.NArg(), fs.Args()) + } + + err := checkArgsErr(fs, rawArgs, 0) + if err == nil { + t.Fatal("expected checkArgs to report a problem, got nil") + } + if !strings.Contains(err.Error(), "-dry-run") { + t.Errorf("error %q does not name the shadowed flag -dry-run", err.Error()) + } + if !strings.Contains(err.Error(), "https://github.com/ExopenGitHub/portal/issues/4304") { + t.Errorf("error %q does not name the unconsumed URL argument", err.Error()) + } +} + +var buildOnce sync.Once +var builtBinary string +var buildErr error + +// buildSpecsyncBinary compiles the specsync CLI once per test run and +// returns the path to the resulting binary. +func buildSpecsyncBinary(t *testing.T) string { + t.Helper() + buildOnce.Do(func() { + dir := t.TempDir() + builtBinary = filepath.Join(dir, "specsync-test-bin") + cmd := exec.Command("go", "build", "-o", builtBinary, ".") + cmd.Dir = "." + out, err := cmd.CombinedOutput() + if err != nil { + buildErr = err + t.Logf("go build output: %s", out) + } + }) + if buildErr != nil { + t.Fatalf("build specsync binary: %v", buildErr) + } + return builtBinary +} + +// TestIncidentRegression reproduces the exact 2026-09-24 incident's argument +// shape end to end against the compiled binary: `specsync -repo +// -dry-run` run against a directory whose openspec/changes/ holds changes +// unrelated to must fail with a clear error, make zero provider +// calls, and exit non-zero. A stub `gh` on PATH records every invocation so +// a live sync (the actual incident) would be unmistakable. +func TestIncidentRegression(t *testing.T) { + bin := buildSpecsyncBinary(t) + + fixture := t.TempDir() + changeDir := filepath.Join(fixture, "openspec", "changes", "unrelated-change") + if err := os.MkdirAll(changeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(changeDir, "proposal.md"), []byte("# Unrelated change\n\n## Why\n\nUnrelated.\n"), 0o644); err != nil { + t.Fatal(err) + } + + // Stub `gh` that records every call it receives; the fix must produce + // zero calls to it. + binDir := t.TempDir() + callLog := filepath.Join(binDir, "gh-calls.log") + ghStub := "#!/bin/sh\necho \"$@\" >> " + callLog + "\necho '[]'\n" + ghPath := filepath.Join(binDir, "gh") + if err := os.WriteFile(ghPath, []byte(ghStub), 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(bin, "-repo", "ExopenGitHub/portal", + "https://github.com/ExopenGitHub/portal/issues/4304", "-dry-run") + cmd.Dir = fixture + cmd.Env = append(os.Environ(), "PATH="+binDir+":"+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + + if err == nil { + t.Fatalf("expected non-zero exit, got success; output:\n%s", out) + } + if !strings.Contains(string(out), "unexpected argument") && !strings.Contains(string(out), "not applied") { + t.Errorf("expected a clear unexpected-argument/shadowed-flag error, got:\n%s", out) + } + if _, statErr := os.Stat(callLog); statErr == nil { + logged, _ := os.ReadFile(callLog) + t.Fatalf("expected zero gh calls, got:\n%s", logged) + } +} diff --git a/cmd/specsync/changelog.go b/cmd/specsync/changelog.go index 6c92cfa..ef1a07b 100644 --- a/cmd/specsync/changelog.go +++ b/cmd/specsync/changelog.go @@ -33,6 +33,7 @@ func runChangelog(args []string) { providerName := fs.String("provider", "github", "work provider for -resolve-refs: github (default) or beads") failOnUnlinkedCommits := fs.Bool("fail-on-unlinked-commits", false, "exit non-zero when a conventional commit in range isn't linked to its change's issue (would otherwise render as a raw title+hash fallback)") _ = fs.Parse(args) + checkArgs(fs, args, 0) abs, err := filepath.Abs(*openspec) if err != nil { diff --git a/cmd/specsync/epic.go b/cmd/specsync/epic.go index 3b64021..3d39b94 100644 --- a/cmd/specsync/epic.go +++ b/cmd/specsync/epic.go @@ -24,6 +24,7 @@ func runEpic(args []string) { fs.Var(&children, "child", "a child to attach: local change slug, owner/repo#N, bare #N (resolved against --repo), or issue URL (repeatable)") dryRun := fs.Bool("dry-run", false, "print what would happen without creating or editing any issue") _ = fs.Parse(args) + checkArgs(fs, args, -1) // the title is a free-form positional title := strings.TrimSpace(strings.Join(fs.Args(), " ")) if title == "" { diff --git a/cmd/specsync/installskill.go b/cmd/specsync/installskill.go index ce94f44..2b87362 100644 --- a/cmd/specsync/installskill.go +++ b/cmd/specsync/installskill.go @@ -35,6 +35,7 @@ func runInstallSkill(args []string) { flags[i] = fs.Bool(t.flag, false, fmt.Sprintf("install to %s (~/%s)", t.label, filepath.Join(t.relPath...))) } _ = fs.Parse(args) + checkArgs(fs, args, 0) home, err := os.UserHomeDir() if err != nil { diff --git a/cmd/specsync/main.go b/cmd/specsync/main.go index d457b4d..3b95d96 100644 --- a/cmd/specsync/main.go +++ b/cmd/specsync/main.go @@ -202,6 +202,91 @@ func reorderFlagsFirst(args []string) []string { return append(flags, positional...) } +// checkArgs fails loudly, naming the exact problem, when args left over +// after fs.Parse indicate a mistake rather than legitimate input: either a +// registered flag shadowed by an earlier positional token (see +// shadowedFlags), or unconsumed positional arguments beyond maxPositional. +// Pass -1 for maxPositional to skip the positional-count check for +// subcommands whose positional arguments are free-form and validated +// elsewhere (e.g. link, scan, set-stage, set-priority, note, epic). +// +// This is the fix for the 2026-09-24 incident: `specsync -repo +// ExopenGitHub/portal -dry-run` silently discarded the URL +// (leaving -change unset, meaning "sync everything") and never even parsed +// -dry-run (shadowed by the URL), turning a mistyped single-issue command +// into a full live sync with zero warning. +func checkArgs(fs *flag.FlagSet, rawArgs []string, maxPositional int) { + if err := checkArgsErr(fs, rawArgs, maxPositional); err != nil { + fail(err) + } +} + +// checkArgsErr is checkArgs's testable core: it returns the problem instead +// of exiting, so tests can assert on it in-process. +func checkArgsErr(fs *flag.FlagSet, rawArgs []string, maxPositional int) error { + var problems []string + if shadowed := shadowedFlags(fs, rawArgs); len(shadowed) > 0 { + problems = append(problems, fmt.Sprintf( + "flag(s) %s appeared after a positional argument and were not applied — Go's flag parser stops parsing at the first non-flag token; put flags before positional arguments", + strings.Join(shadowed, ", "))) + } + if maxPositional >= 0 && fs.NArg() > maxPositional { + extra := fs.Args()[maxPositional:] + problems = append(problems, fmt.Sprintf("unexpected argument(s): %s", strings.Join(extra, " "))) + } + if len(problems) == 0 { + return nil + } + return fmt.Errorf("%s: %s", fs.Name(), strings.Join(problems, "; ")) +} + +// shadowedFlags scans rawArgs — the args exactly as given, before fs.Parse — +// for any token naming a flag registered on fs that appears after the first +// non-flag ("positional") token. fs.Parse stops parsing entirely at that +// first positional token (a well-known stdlib `flag` footgun), so such a +// flag is never even looked at and silently keeps its zero value instead of +// erroring or taking effect. Matching is exact-name only — no typo-fuzzing +// (e.g. -dryrun for -dry-run is not caught; that's a separate, fuzzier +// problem). +func shadowedFlags(fs *flag.FlagSet, rawArgs []string) []string { + registered := map[string]bool{} + valueFlag := map[string]bool{} + fs.VisitAll(func(f *flag.Flag) { + registered[f.Name] = true + if bv, ok := f.Value.(interface{ IsBoolFlag() bool }); !ok || !bv.IsBoolFlag() { + valueFlag[f.Name] = true + } + }) + + var shadowed []string + seen := map[string]bool{} + seenPositional := false + for i := 0; i < len(rawArgs); i++ { + arg := rawArgs[i] + if !strings.HasPrefix(arg, "-") { + seenPositional = true + continue + } + name := strings.TrimPrefix(strings.TrimPrefix(arg, "-"), "-") + hasInlineValue := false + if idx := strings.Index(name, "="); idx != -1 { + name = name[:idx] + hasInlineValue = true + } + if !registered[name] { + continue + } + if seenPositional && !seen[name] { + shadowed = append(shadowed, "-"+name) + seen[name] = true + } + if valueFlag[name] && !hasInlineValue && i+1 < len(rawArgs) { + i++ // skip this flag's value token — it is not a positional + } + } + return shadowed +} + // stringSlice implements flag.Value for repeatable -provider flags. type stringSlice []string @@ -234,6 +319,7 @@ func runSync(args []string) { fail(err) } _ = fs.Parse(args) + checkArgs(fs, args, 0) abs, err := filepath.Abs(*openspec) if err != nil { @@ -331,6 +417,50 @@ func runSync(args []string) { fail(err) } + // Pre-flight summary before any real (non-dry-run) write: a human + // skimming this line should be able to catch a mismatched target the + // way "target: ExopenGitHub/portal — spec source: ~/dev/specsync/openspec + // (81 changes)" would have caught the 2026-09-24 incident. Best-effort + // and openspec-source only (no board/API calls beyond what's already + // resolved above); a beads spec source or a preflight read failure + // silently skips the line rather than blocking the sync. + if !*dryRun && *specSource == "openspec" { + if allChanges, err := specsync.LoadChanges(abs); err == nil { + var toSync []specsync.Change + for _, c := range allChanges { + if c.Archived { + continue + } + if *change != "" && c.Slug != *change { + continue + } + toSync = append(toSync, c) + } + linked := 0 + for _, c := range toSync { + if refs, err := specsync.LoadRefs(c.Dir); err == nil && len(refs) > 0 { + linked++ + } + } + repoLabel := *repo + if repoLabel == "" { + for _, prov := range providers { + if gp, ok := prov.(*specsync.GitHubProvider); ok { + if resolved, err := gp.Resolve(context.Background()); err == nil && resolved.Repo != "" { + repoLabel = resolved.Repo + } + break + } + } + } + if repoLabel == "" { + repoLabel = "(unresolved)" + } + fmt.Printf("target: %s — spec source: %s (%d changes) — %d already linked, %d new\n\n", + repoLabel, abs, len(toSync), linked, len(toSync)-linked) + } + } + res, err := specsync.Sync(context.Background(), specsync.Options{ OpenSpecDir: abs, SpecSource: specSrc, @@ -423,6 +553,7 @@ func runPull(args []string) { fail(err) } _ = fs.Parse(args) + checkArgs(fs, args, 0) abs, err := filepath.Abs(*openspec) if err != nil { @@ -512,6 +643,7 @@ func runAdopt(args []string) { fail(err) } _ = fs.Parse(args) + checkArgs(fs, args, 0) if *issue == "" { fail(fmt.Errorf("-issue is required")) @@ -694,6 +826,7 @@ func runLink(args []string) { dryRun := fs.Bool("dry-run", false, "show what would change without writing files or calling GitHub") repo := fs.String("repo", "", "repo (owner/name) for bare issue refs") _ = fs.Parse(args) + checkArgs(fs, args, -1) args = fs.Args() if len(args) < 2 { @@ -1131,6 +1264,7 @@ func runChanges(args []string) { if err := fs.Parse(args); err != nil { fail(err) } + checkArgs(fs, args, 0) if *sortBy != "stage" && *sortBy != "priority" && *sortBy != "slug" { fail(fmt.Errorf("invalid sort value %q: must be stage, priority, or slug", *sortBy)) @@ -1366,6 +1500,7 @@ func runSetStage(args []string) { if err := fs.Parse(args); err != nil { fail(err) } + checkArgs(fs, args, -1) if fs.NArg() < 2 { fail(fmt.Errorf("usage: specsync set-stage ")) } @@ -1400,6 +1535,7 @@ func runSetPriority(args []string) { if err := fs.Parse(args); err != nil { fail(err) } + checkArgs(fs, args, -1) if fs.NArg() < 2 { fail(fmt.Errorf("usage: specsync set-priority <1-100|unset>")) } @@ -1434,6 +1570,7 @@ func runNote(args []string) { if err := fs.Parse(args); err != nil { fail(err) } + checkArgs(fs, args, -1) if fs.NArg() < 2 { fail(fmt.Errorf("usage: specsync note ")) } @@ -1478,6 +1615,7 @@ func runSpinoff(args []string) { if err := fs.Parse(args); err != nil { fail(err) } + checkArgs(fs, args, 0) if strings.TrimSpace(*from) == "" { fail(fmt.Errorf("spinoff: -from is required")) @@ -1570,6 +1708,7 @@ func runAudit(args []string) { if err := fs.Parse(args); err != nil { fail(err) } + checkArgs(fs, args, 0) abs, err := filepath.Abs(*openspec) if err != nil { @@ -1695,6 +1834,7 @@ func runAuditTasks(args []string) { if err := fs.Parse(args); err != nil { fail(err) } + checkArgs(fs, args, 0) abs, err := filepath.Abs(*openspec) if err != nil { @@ -1781,6 +1921,7 @@ func runValidate(args []string) { if err := fs.Parse(args); err != nil { fail(err) } + checkArgs(fs, args, 0) abs, err := filepath.Abs(*openspec) if err != nil { @@ -1826,6 +1967,7 @@ func runIdea(args []string) { if err := fs.Parse(args); err != nil { fail(err) } + checkArgs(fs, args, 1) // idea text is the one expected positional // Resolve repo: -repo flag → openspec/specsync.yml → SPECSYNC_IDEAS_REPO → auto-detect. abs, err := filepath.Abs(*openspec) @@ -1898,6 +2040,7 @@ func runIdeas(args []string) { if err := fs.Parse(args); err != nil { fail(err) } + checkArgs(fs, args, 0) abs, err := filepath.Abs(*openspec) if err != nil { @@ -1980,6 +2123,7 @@ func runArchive(args []string) { if err := fs.Parse(args); err != nil { fail(err) } + checkArgs(fs, args, 0) if *change == "" { fail(fmt.Errorf("archive: -change is required")) diff --git a/cmd/specsync/topology.go b/cmd/specsync/topology.go index 68e75b7..7f9b87a 100644 --- a/cmd/specsync/topology.go +++ b/cmd/specsync/topology.go @@ -24,6 +24,7 @@ func runTopology(args []string) { if err := fs.Parse(args); err != nil { fail(err) } + checkArgs(fs, args, 0) ctx := context.Background() diff --git a/cmd/specsync/traceability.go b/cmd/specsync/traceability.go index e12e363..990a7f7 100644 --- a/cmd/specsync/traceability.go +++ b/cmd/specsync/traceability.go @@ -22,6 +22,7 @@ func runTrace(args []string) { until := fs.String("until", "", "range end (default: HEAD)") asJSON := fs.Bool("json", false, "emit JSON") _ = fs.Parse(args) + checkArgs(fs, args, 0) abs, err := filepath.Abs(*openspec) if err != nil { @@ -45,6 +46,7 @@ func runScan(args []string) { asJSON := fs.Bool("json", false, "emit JSON for a planning agent") references := fs.Bool("references", false, "also show OpenSpec references and worksets") _ = fs.Parse(args) + checkArgs(fs, args, -1) // paths + topic are free-form positionals paths, topic := splitArea(fs.Args()) if len(paths) == 0 && topic == "" { @@ -217,6 +219,7 @@ func runReleasePlan(args []string) { archiveCompleted := fs.Bool("archive-completed", false, "move shipped completed changes from openspec/changes/ to openspec/changes/archive/") apply := fs.Bool("apply", false, "perform suggested spec actions (archive completed changes)") _ = fs.Parse(args) + checkArgs(fs, args, 0) abs, err := filepath.Abs(*openspec) if err != nil { @@ -508,6 +511,7 @@ func runPRBody(args []string) { bodyFile := fs.String("body-file", "", "file whose contents are merged after the reference line") repo := fs.String("repo", "", "target repo as owner/name (default: auto-detect from git remote)") _ = fs.Parse(args) + checkArgs(fs, args, 0) if *change == "" { fail(fmt.Errorf("pr-body: -change is required")) @@ -632,6 +636,7 @@ func runVerify(args []string) { openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") repo := fs.String("repo", "", "target repo as owner/name (default: auto-detect from git remote)") _ = fs.Parse(args) + checkArgs(fs, args, 0) abs, err := filepath.Abs(*openspec) if err != nil { diff --git a/openspec/changes/reject-unconsumed-cli-args/design.md b/openspec/changes/reject-unconsumed-cli-args/design.md new file mode 100644 index 0000000..dc4e84e --- /dev/null +++ b/openspec/changes/reject-unconsumed-cli-args/design.md @@ -0,0 +1,19 @@ +# Design: reject-unconsumed-cli-args + +## Shadowed-flag matching strategy + +Exact flag-name matching only — no typo-fuzzing (e.g. `-dryrun` is not +recognized as a near-miss of `-dry-run`). Typo detection is a separate, +fuzzier problem and conflating it with this check would let false positives +undermine trust in the new guard. + +## Arity lookup + +`shadowedFlags` walks the subcommand's `flag.FlagSet` via `fs.VisitAll` and +classifies each registered flag as boolean or value-consuming by checking +for the `IsBoolFlag() bool` method Go's own `flag` package uses internally +(`fs.Bool` values implement it; `fs.String`/`fs.Var` values generally don't). +This mirrors how `flag.Parse` itself decides whether a token is a bare flag +or a flag plus a following value token, so the scan doesn't misidentify a +value flag's argument (e.g. the `owner/repo` after `-repo`) as the first +positional token. diff --git a/openspec/changes/reject-unconsumed-cli-args/tasks.md b/openspec/changes/reject-unconsumed-cli-args/tasks.md index 88e8b2a..6d38f84 100644 --- a/openspec/changes/reject-unconsumed-cli-args/tasks.md +++ b/openspec/changes/reject-unconsumed-cli-args/tasks.md @@ -5,7 +5,7 @@ created on `ExopenGitHub/portal` from an unrelated invocation). Task 2 is the independent, more general defect that made `-dry-run` silently do nothing. Task 5 is the regression test proving both are actually fixed. -- [ ] 1. Add a shared `rejectUnconsumedArgs(fs *flag.FlagSet)` helper (fails loudly, +- [x] 1. Add a shared `rejectUnconsumedArgs(fs *flag.FlagSet)` helper (fails loudly, naming the exact leftover argument(s), if `fs.NArg() > 0`) and call it immediately after `fs.Parse(args)` in every subcommand whose flag set takes no legitimate positional arguments: `runSync`, `runAudit`, `runAuditTasks`, @@ -19,7 +19,7 @@ is the regression test proving both are actually fixed. any subcommand + stray non-flag arg) now exits non-zero with an error naming the stray argument, and makes no provider calls. -- [ ] 2. Detect flags shadowed by an earlier positional token — the independent bug +- [x] 2. Detect flags shadowed by an earlier positional token — the independent bug that made `-dry-run` have zero effect regardless of task 1. Before `fs.Parse`, walk the subcommand's registered flags via `fs.VisitAll` to know each flag's arity, then scan the raw `args` for the first true positional @@ -33,20 +33,20 @@ is the regression test proving both are actually fixed. either parses correctly or fails loudly naming `-dry-run` as shadowed — either outcome beats "silently does the opposite of what was asked." -- [ ] 3. Add a pre-flight summary printed before any real (non-dry-run) write in +- [x] 3. Add a pre-flight summary printed before any real (non-dry-run) write in `runSync`: resolved target repo, resolved spec-source directory, and count of changes about to be synced (`target: X — spec source: Y (N changes)`). Validation: a real `sync` run's first line of output states the repo, the spec source path, and the change count before any provider call. -- [ ] 4. Decide the shadowed-flag scan's matching strategy (Open Question 1: exact +- [x] 4. Decide the shadowed-flag scan's matching strategy (Open Question 1: exact name only, no typo-fuzzing) and confirm the arity-lookup approach (Open Question 2: `fs.VisitAll` to distinguish boolean vs value-consuming flags). Record both decisions. Validation: a `design.md` note states the decisions; the task-2 implementation matches them. -- [ ] 5. Regression test reproducing the 2026-09-24 incident's exact argument +- [x] 5. Regression test reproducing the 2026-09-24 incident's exact argument shape end to end: `specsync -repo -dry-run` run against a fixture directory whose `openspec/changes/` holds changes unrelated to `` must fail with a clear "unexpected argument" error, make zero @@ -55,7 +55,7 @@ is the regression test proving both are actually fixed. Validation: the test fails against the pre-fix code and passes after tasks 1–2 land. -- [ ] 6. Sweep the rest of `cmd/specsync/main.go` for any other place that calls +- [x] 6. Sweep the rest of `cmd/specsync/main.go` for any other place that calls `fs.Parse` and ignores `fs.NArg()`/leftover args, beyond the list in task 1 — confirm the audit was exhaustive rather than covering only the reported cases. From a8ff48900000a1fb0d094596cedfe5a0c7a7ed3e Mon Sep 17 00:00:00 2001 From: AndreasS Date: Thu, 24 Sep 2026 16:28:56 +0200 Subject: [PATCH 3/3] fix(#170): address code-review findings on the arg-check helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shadowedFlags no longer under-counts: the value-token skip only applies before the positional boundary (where flag.Parse genuinely consumes a value), so two shadowed flags in a row (e.g. a value flag immediately followed by a bool flag) are both reported instead of just the first. - checkArgsErr no longer names the same shadowed flag twice (once as "shadowed", once as "unexpected argument") — the unexpected-argument list excludes tokens already reported as shadowed flags. - Added TestEveryRunFuncChecksArgs: an AST-based guard so a future subcommand can't silently reintroduce this incident's failure mode by omitting the checkArgs call. --- cmd/specsync/argcheck_guard_test.go | 97 +++++++++++++++++++++++++++++ cmd/specsync/argcheck_test.go | 8 +++ cmd/specsync/main.go | 38 +++++++++-- 3 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 cmd/specsync/argcheck_guard_test.go diff --git a/cmd/specsync/argcheck_guard_test.go b/cmd/specsync/argcheck_guard_test.go new file mode 100644 index 0000000..122e371 --- /dev/null +++ b/cmd/specsync/argcheck_guard_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +// subcommandsExemptFromArgCheck lists run* functions taking `args []string` +// that intentionally do not call checkArgs, with the reason it's safe — +// keep this list short and every entry justified, since it's the one place +// a future subcommand could quietly opt back out of the check. +var subcommandsExemptFromArgCheck = map[string]string{ + "runDoctor": "reorders flags first (reorderFlagsFirst) and errors loudly on any unrecognized subcommand word — the silent-drop failure mode doesn't apply", + "runAgentHelp": "reorders flags first (reorderFlagsFirst) and errors loudly on any unrecognized subcommand word — the silent-drop failure mode doesn't apply", + "runRelate": "dead code: not dispatched from main() or knownSubcommands", +} + +// TestEveryRunFuncChecksArgs guards against the exact way this incident's +// defect could silently regress: a future subcommand added to cmd/specsync +// that parses a flag.FlagSet but never calls checkArgs after fs.Parse. +// Hand-adding checkArgs at each call site (rather than one central dispatch +// point) means nothing else stops that omission — this test is that stop. +func TestEveryRunFuncChecksArgs(t *testing.T) { + fset := token.NewFileSet() + paths, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + + checked := 0 + for _, path := range paths { + if strings.HasSuffix(path, "_test.go") { + continue + } + src, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + f, err := parser.ParseFile(fset, path, src, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil || fn.Recv != nil { + continue + } + name := fn.Name.Name + if !strings.HasPrefix(name, "run") || !takesArgsSlice(fn) { + continue + } + if _, exempt := subcommandsExemptFromArgCheck[name]; exempt { + continue + } + checked++ + if !callsCheckArgs(fn.Body) { + t.Errorf("%s (%s) parses a flag.FlagSet from an `args []string` parameter but never calls checkArgs after fs.Parse — call it, or add %q to subcommandsExemptFromArgCheck with a reason", name, path, name) + } + } + } + if checked == 0 { + t.Fatal("scan found no run*(args []string) functions — the scan itself is broken") + } +} + +// takesArgsSlice reports whether fn has exactly one parameter of type +// []string — the signature every dispatched subcommand handler uses. +func takesArgsSlice(fn *ast.FuncDecl) bool { + if fn.Type.Params == nil || len(fn.Type.Params.List) != 1 { + return false + } + arr, ok := fn.Type.Params.List[0].Type.(*ast.ArrayType) + if !ok || arr.Len != nil { + return false + } + ident, ok := arr.Elt.(*ast.Ident) + return ok && ident.Name == "string" +} + +// callsCheckArgs reports whether body contains a call to checkArgs anywhere. +func callsCheckArgs(body ast.Node) bool { + found := false + ast.Inspect(body, func(n ast.Node) bool { + if call, ok := n.(*ast.CallExpr); ok { + if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == "checkArgs" { + found = true + } + } + return true + }) + return found +} diff --git a/cmd/specsync/argcheck_test.go b/cmd/specsync/argcheck_test.go index 8c4a2bf..4e538ca 100644 --- a/cmd/specsync/argcheck_test.go +++ b/cmd/specsync/argcheck_test.go @@ -58,6 +58,11 @@ func TestShadowedFlags(t *testing.T) { args: []string{"some-positional", "-not-a-real-flag"}, want: nil, }, + { + name: "value flag immediately followed by another shadowed flag: both reported", + args: []string{"some-positional", "-repo", "-dry-run"}, + want: []string{"-repo", "-dry-run"}, + }, } for _, tt := range tests { @@ -96,6 +101,9 @@ func TestCheckArgs(t *testing.T) { if !strings.Contains(err.Error(), "https://github.com/ExopenGitHub/portal/issues/4304") { t.Errorf("error %q does not name the unconsumed URL argument", err.Error()) } + if strings.Count(err.Error(), "-dry-run") != 1 { + t.Errorf("error %q names -dry-run more than once — it should be reported only as shadowed, not also as a separate unexpected argument", err.Error()) + } } var buildOnce sync.Once diff --git a/cmd/specsync/main.go b/cmd/specsync/main.go index 3b95d96..79a0a38 100644 --- a/cmd/specsync/main.go +++ b/cmd/specsync/main.go @@ -224,15 +224,37 @@ func checkArgs(fs *flag.FlagSet, rawArgs []string, maxPositional int) { // checkArgsErr is checkArgs's testable core: it returns the problem instead // of exiting, so tests can assert on it in-process. func checkArgsErr(fs *flag.FlagSet, rawArgs []string, maxPositional int) error { + shadowed := shadowedFlags(fs, rawArgs) + shadowedName := map[string]bool{} + for _, s := range shadowed { + shadowedName[strings.TrimPrefix(strings.TrimPrefix(s, "-"), "-")] = true + } + var problems []string - if shadowed := shadowedFlags(fs, rawArgs); len(shadowed) > 0 { + if len(shadowed) > 0 { problems = append(problems, fmt.Sprintf( "flag(s) %s appeared after a positional argument and were not applied — Go's flag parser stops parsing at the first non-flag token; put flags before positional arguments", strings.Join(shadowed, ", "))) } if maxPositional >= 0 && fs.NArg() > maxPositional { - extra := fs.Args()[maxPositional:] - problems = append(problems, fmt.Sprintf("unexpected argument(s): %s", strings.Join(extra, " "))) + // Exclude tokens already named above as shadowed flags — fs.Args() + // past the positional boundary contains every leftover token + // (Parse never touched any of them), so a shadowed flag would + // otherwise be named twice as two separate-looking problems. + var extra []string + for _, a := range fs.Args()[maxPositional:] { + name := strings.TrimPrefix(strings.TrimPrefix(a, "-"), "-") + if idx := strings.Index(name, "="); idx != -1 { + name = name[:idx] + } + if shadowedName[name] { + continue + } + extra = append(extra, a) + } + if len(extra) > 0 { + problems = append(problems, fmt.Sprintf("unexpected argument(s): %s", strings.Join(extra, " "))) + } } if len(problems) == 0 { return nil @@ -280,8 +302,14 @@ func shadowedFlags(fs *flag.FlagSet, rawArgs []string) []string { shadowed = append(shadowed, "-"+name) seen[name] = true } - if valueFlag[name] && !hasInlineValue && i+1 < len(rawArgs) { - i++ // skip this flag's value token — it is not a positional + // Only skip a value-flag's value token before the positional + // boundary — that's the one region where a value token is real + // (Go's flag.Parse actually consumes it there). Once past the + // boundary, Parse never looks at any of these tokens at all, so a + // registered flag name immediately following another one is + // independently shadowed, not "consumed" as the first flag's value. + if !seenPositional && valueFlag[name] && !hasInlineValue && i+1 < len(rawArgs) { + i++ } } return shadowed