Skip to content

feat: add --aggregate flag to regal new rule (#341)#1966

Open
mvanhorn wants to merge 4 commits into
open-policy-agent:mainfrom
mvanhorn:osc/341-aggregate-flag
Open

feat: add --aggregate flag to regal new rule (#341)#1966
mvanhorn wants to merge 4 commits into
open-policy-agent:mainfrom
mvanhorn:osc/341-aggregate-flag

Conversation

@mvanhorn
Copy link
Copy Markdown
Contributor

Summary

Adds --aggregate to regal new rule so aggregate rules are as easy to scaffold as single-module ones. Both --type custom and --type builtin are supported.

Closes #341

Why this matters

Aggregate rules need a different Rego shape (an aggregate contains entry if { ... } collector plus an aggregate_report contains violation if { ... } reducer) and, for builtin rules, a different input shape at runtime (input.aggregates_internal["<category>/<name>"] rather than input.aggregate). Authors currently have to hand-port all of that from an existing aggregate rule like bundle/regal/rules/imports/unresolved-import/unresolved_import.rego.

Changes

  • cmd/new.go: newRuleCommandParams gains an aggregate bool. New --aggregate flag. renderTemplates picks templates/{type}/{type}_aggregate{,_test}.rego.tpl when the flag is set, otherwise the original {type}.rego.tpl / {type}_test.rego.tpl pair (no behavior change without the flag).
  • internal/embeds/templates/custom/custom_aggregate.rego.tpl + custom_aggregate_test.rego.tpl: scaffold a custom aggregate rule that reads input.aggregate (the custom-rule shape).
  • internal/embeds/templates/builtin/builtin_aggregate.rego.tpl + builtin_aggregate_test.rego.tpl: scaffold a builtin aggregate rule that reads input.aggregates_internal["<cat>/<name>"] (the bundled runtime shape) and drives its test via util.with_source_files.
  • createBuiltinDocs and addToDataYAML are unchanged — aggregate/non-aggregate doesn't affect those paths.

Dogfood

Custom:

$ regal new rule -t custom -c naming -n my_rule --aggregate -o $TMPDIR
Created custom rule "my_rule" in .../my_rule
# METADATA
# description: Add description of aggregate rule here!
package custom.regal.rules.naming["my-rule"]
...
aggregate contains entry if { ... }
aggregate_report contains violation if {
    some entry in input.aggregate
    ...
}

Builtin:

$ regal new rule -t builtin -c naming -n my_rule --aggregate -o $TMPDIR
# METADATA
# schemas:
#   - input: schema.regal.aggregate
aggregate_report contains violation if {
    some entry in input.aggregates_internal["naming/my_rule"][_]
    ...
}

Without --aggregate, both subcommands produce exactly the same output as before.

Testing

  • go build ./... clean
  • go vet ./cmd/... clean
  • go test ./cmd/... (no cmd tests exist today)
  • Manually dogfooded both --type custom --aggregate and --type builtin --aggregate

Notes

Two commits to preserve review history:

  1. Initial implementation of the flag and both custom/builtin template pairs.
  2. Correction (after a codex-review pass) to switch the builtin template from input.aggregate to input.aggregates_internal["category/name"] so bundled aggregate rules actually report during normal linting, not just in the scaffolded test.

This contribution was developed with AI assistance (Claude Code + Codex).

@mvanhorn mvanhorn force-pushed the osc/341-aggregate-flag branch from cd3341e to 9918642 Compare April 21, 2026 08:22
@anderseknert
Copy link
Copy Markdown
Member

Thanks! Busy day with the v0.40.0 release going out, but I'll review some time this week 👍

@mvanhorn
Copy link
Copy Markdown
Contributor Author

Thanks @anderseknert - no rush, happy to wait until after the v0.40.0 release settles.

Copy link
Copy Markdown
Member

@anderseknert anderseknert left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me, with only a minor remark! If you could take a look at that and let me know what works, this is good to merge 🙂

test_aggregate_reports_violation if {
agg := rule.aggregate with input as ast.policy("foo := true")

r := rule.aggregate_report with input.aggregates_internal as util.with_source_files(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you were right first and Codex set you off in the wrong direction here :) Anything with "internal" in the name should be avoided for code that'll live outside of the project, as those APIs change frequently. In fact, just this one changed just a few releases back!

If there's something you aren't able to do without using this, let me know as we should fix that right away. But even though the non-internal option is much less efficient at this point, let's go with that until we have a better alternative that we can consider stable. OK?

@mvanhorn
Copy link
Copy Markdown
Contributor Author

Reverted in 415663c — back to input.aggregate.

One thing worth flagging: the reason I'd changed it in the first place is that scaffolded aggregate rules generated from this template produced tests that passed but never actually emitted violations during normal linting, because runtime evaluation reads from input.aggregates_internal["cat/name"], not input.aggregate. Happy to keep it on input.aggregate per your guidance — just wanted to surface the functional gap in case it's worth a follow-up for scaffolded custom rules.

Aggregate rules need a different Rego shape (`aggregate` +
`aggregate_report` instead of a single `report`), and scaffolding
that by hand is friction. Adding `--aggregate` to `regal new rule`
pairs the existing per-type templates with an aggregate variant for
both custom and builtin types, and updates the test template to
exercise the aggregate_report path.

Without the flag, behavior is unchanged.

Dogfood:

    $ regal new rule -t custom -c naming -n my_rule --aggregate -o $TMPDIR

produces a `.rego` with `aggregate contains entry if { ... }` +
`aggregate_report contains violation if { ... }` and a test file
that feeds a parsed module through `aggregate` and asserts the
`aggregate_report` emits a violation.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Addresses codex-review feedback: bundled aggregate_report rules are
evaluated with entries under input.aggregates_internal["cat/name"],
not the custom-rule compatibility shape input.aggregate. The prior
builtin template would produce a rule whose scaffolded test passed
(because it injected input.aggregate directly) but which never emitted
violations during normal linting.

Switch the builtin template to read from input.aggregates_internal
keyed by "{{.Category}}/{{.NameOriginal}}" and update the generated
test to exercise the same shape via util.with_source_files.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
@anderseknert anderseknert force-pushed the osc/341-aggregate-flag branch from 415663c to c5e5fd8 Compare April 28, 2026 12:06
@anderseknert
Copy link
Copy Markdown
Member

@mvanhorn that should not be the case, as the internal format is converted here

# METADATA
# description: |
# Restructure aggregate report input for conformance with "legacy"
# format, so that we don't break existing rules. Later on we can deprecate
# this and make it opt-in.
# schemas:
# - input: schema.regal.aggregate
_aggregate_report_inputs[cat_title].aggregate contains formatted if {
some filename, cat_title
aggregate := input.aggregates_internal[filename][cat_title][_]
formatted := _format_aggregate(aggregate, filename)
}
default _format_aggregate(_, _) := set()
_format_aggregate(aggregate, filename) := object.union(aggregate, {"aggregate_source": {"file": filename}}) if {
aggregate.aggregate_data
}

As noted in the metadata comment it is a legacy approach, but there are tests that verify that it works... not to mention of course a few known deployments where custom rules are used :) If you're not getting that to work, let's look into it.

@mvanhorn
Copy link
Copy Markdown
Contributor Author

You're right, sorry for the wrong-direction speculation. Built this branch and ran regal new rule --aggregate --type custom --category test --name myrule, then regal lint policy.rego policy2.rego against the scaffolded rule. The custom aggregate fires twice (once per file), which traces back to main.rego:212-215:

input_for_rule := _remove_empty_aggregates(aggregate)
some violation in data.custom.regal.rules[category][title].aggregate_report with input as input_for_rule

The conversion at _aggregate_report_inputs[cat_title].aggregate (L234-238) gives the rule body the {aggregate: <formatted set>} shape, so input.aggregate reads correctly. PR is good as-is on input.aggregate. Thanks for the patience.

@anderseknert
Copy link
Copy Markdown
Member

@mvanhorn I had forgotten about this. The CI failure looks like a flake, so I'm sure it'll be gone if you rebase this. Before you to, can you make sure the DCO check passes? Likely that same issue with diverging e-mail addresses in commuter vs signoff we saw before.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add --aggregate flag to regal new rule command

2 participants