Protect canonical operand prefixes from aliases

This commit is contained in:
2026-10-20 00:20:40 +00:00
parent c225a66c97
commit ccf8acd861
3 changed files with 48 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
# Alias implementation review remediation
Status: open; R-1 through R-8 require follow-up.
Status: in progress; completed issues are marked individually below.
## Review scope and evidence
@@ -27,7 +27,7 @@ or private representations unnecessarily.
## R-1: Protect prefixes of canonical operand space during installation
Status: open. Priority: P1. Category: canonical command reachability.
Status: complete. Priority: P1. Category: canonical command reachability.
**Evidence:** [traceCanonicalPath](../../registry.go#L318) uses
[acceptsOperandCount](../../registry.go#L337), which requires the source's entire
@@ -58,6 +58,12 @@ side is added later; preserve the preexisting registry on rejection. Include
multi-required and repeated operands and a valid distinct child/shortcut case
so the correction does not reserve every route beneath any runnable ancestor.
**Completed:** Installation now protects operand prefixes that can be completed
by later arguments without requiring the minimum count immediately. New
regressions failed before the correction and pass for fixed/repeated operands,
construction, both later registration orders, and rollback. Existing child and
longer-shortcut cases and the complete offline root suite passed on Go 1.26.8.
## R-2: Select canonical routing context before validating operand counts
Status: open. Priority: P2. Category: unintended parsing restriction and identity.

View File

@@ -326,7 +326,7 @@ func traceCanonicalPath(roots map[string]*canonicalNode, path commandpath.Path)
continue
}
operandCount := len(path) - index
if current != nil && acceptsOperandCount(current.command, operandCount) {
if current != nil && acceptsOperandPrefix(current.command, operandCount) {
return commandAliasTrace{operandOwner: current}
}
return commandAliasTrace{}
@@ -334,14 +334,15 @@ func traceCanonicalPath(roots map[string]*canonicalNode, path commandpath.Path)
return commandAliasTrace{exact: current}
}
func acceptsOperandCount(command Command, count int) bool {
// acceptsOperandPrefix reports whether more operands could complete this prefix.
// A source need not already satisfy the command's minimum to intercept it.
func acceptsOperandPrefix(command Command, count int) bool {
if command.record == nil || command.record.payload == nil || !command.record.payload.hasRun() {
return false
}
arguments := command.record.payload.bridgeArguments()
minimum, maximum := 0, 0
maximum := 0
for _, argument := range arguments {
minimum += argument.Min
if maximum >= 0 {
if argument.Max < 0 {
maximum = -1
@@ -350,5 +351,5 @@ func acceptsOperandCount(command Command, count int) bool {
}
}
}
return count >= minimum && (maximum < 0 || count <= maximum)
return maximum < 0 || count <= maximum
}

View File

@@ -203,6 +203,40 @@ func TestCommandAliasesRejectCanonicalRoutesAndOperands(t *testing.T) {
}
}
func TestCommandAliasesProtectIncompleteCanonicalOperandPrefixes(t *testing.T) {
for _, bounds := range []struct{ min, max int }{{2, 2}, {3, -1}} {
t.Run(fmt.Sprintf("%d_%d", bounds.min, bounds.max), func(t *testing.T) {
run := runnableWithOperands("run", bounds.min, bounds.max)
mapping := Alias("run first", "inspect")
if _, err := New(Identity{Name: "Example"}, run, simpleRunnable("inspect"), mapping); err == nil {
t.Fatal("construction accepted an alias intercepting an operand prefix")
}
for _, aliasFirst := range []bool{false, true} {
app, err := New(Identity{Name: "Example"}, simpleRunnable("inspect"))
if err != nil {
t.Fatal(err)
}
var first, second Declaration = run, mapping
if aliasFirst {
first, second = second, first
}
if err := app.Register(first); err != nil {
t.Fatal(err)
}
if err := app.Register(second, simpleRunnable("unrelated")); err == nil {
t.Fatal("later registration accepted an operand interception")
}
if app.commands["unrelated"] != nil || (app.commands["run"] != nil) == aliasFirst {
t.Fatal("rejected batch changed canonical registration")
}
if (len(app.routingRules) == 1) != aliasFirst {
t.Fatal("rejected batch changed alias registration")
}
}
})
}
}
func TestLongerAliasBeneathAlternateSpellingCanUseOperandWords(t *testing.T) {
app, err := New(Identity{Name: "Example"},
runnableWithOperands("run", 1, 1),