Skip to content

(CAT-1698) Slim .rubocop.yml to the NewCops: enable deep-merge model and validate clean scaffolds#647

Draft
david22swan wants to merge 13 commits into
mainfrom
CAT-1698-rubocop-templates
Draft

(CAT-1698) Slim .rubocop.yml to the NewCops: enable deep-merge model and validate clean scaffolds#647
david22swan wants to merge 13 commits into
mainfrom
CAT-1698-rubocop-templates

Conversation

@david22swan

@david22swan david22swan commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

This PR replaces the pdk-templates RuboCop template with a slim deep-merge model
and makes a freshly-scaffolded module validate clean under it.

The generated .rubocop.yml now uses AllCops.NewCops: enable to govern which
cops run, instead of an enumerated per-cop Enabled: true/false list. Only
intentionally-styled cops (EnforcedStyle and option tunings) are written out. The
result is a shorter, self-maintaining file that stays current as RuboCop adds new
cops.

On top of the slim model, this PR tunes the genuine PDK / rspec-puppet convention
cops and fixes the object templates so a module scaffolded from these templates
passes pdk validate with zero offences under NewCops: enable — taking the
empirical offence count on a fresh full-object-type scaffold from 44 down to 0,
with CI green end-to-end.


What changed

  • Slim the model — rewrite config_defaults.yml to a two-profile model
    ('on' = NewCops: enable, 'off' = DisabledByDefault/NewCops: disable,
    with cleanups_only/strict/hardcore as backward-compatible aliases of
    'on'), drop the per-cop enumeration and the cleanup_cops anchor, and add
    cop_overrides as the authoritative per-module override key.
  • Remove obsolete machinery — delete rubocop/defaults-1.50.0.yml,
    cleanup_cops.yml, profile_builder.rb, and rubocop/README.md.
  • Rewrite the render templatemoduleroot/.rubocop.yml.erb now layers
    config via deep_merge (defaults -> profile configs -> cop_overrides), guards
    the selected_profile lookup against invalid/boolean-coerced values, and uses a
    single --- knockout prefix consistent with the docs.
  • Tune convention cops — add PDK / rspec-puppet convention tunings to the
    default_configs anchor (trailing-comma style, rspec-puppet describe/context/
    double idioms, etc.), each with an inline Description: rationale.
  • Fix the object templates — emit clean, idiomatic Ruby from the object
    templates (squiggly heredocs and aligned hashes in the resource-API type
    templates, idiomatic param name and single-quoted symbol in the v4 function
    template, a single-resource SimpleProvider get with a matching spec, and the
    corrected Naming/ClassAndModuleCamelCase namespace in the transport device
    disable directive), with unchanged runtime behaviour.
  • Docs + CI — update README.md and CLAUDE.md for the deep-merge model and
    the convention tunings, and update .ci/test_script.sh (and its fixture) to
    assert the slim model.

Verification

Fresh-scaffold gate (PDK 3.7.0, Ruby 3.2.11, Puppet 8.18.0). A module
scaffolded from these templates covering every CI object type (class, defined
type, fact, native + v4 function, provider, task, transport):

  • pdk validate — exit 0, 0 RuboCop offences (down from 44), no
    .rubocop_todo.yml generated or relied on.
  • pdk test unit51 examples, 0 failures.
  • the full .ci/test_script.sh (new-module flow + both convert flows) runs
    green end-to-end, with the CI script and fixture left unchanged.

The PR's CI pipeline is green, confirming the above on CI infrastructure.

Migration impact on existing modules was verified against two live puppetlabs
modules:

  • puppetlabs-apache (main): pdk validate exit 2 (one pre-existing puppet-lint
    warning, 0 RuboCop offences; apache's up-to-date .rubocop_todo.yml suppresses
    all current findings).
  • puppetlabs-motd (main): pdk validate exit 1 (11 RuboCop CONVENTION offences
    surfaced by NewCops: enable across RSpec/DescribeClass,
    RSpec/MultipleExpectations, Style/MixinUsage, and
    Style/TrailingCommaInHashLiteral).

Both modules converted and validated without a hard process failure.


Migration / breaking-class change

Three concrete migration points for module maintainers:

1. selected_profile: 'off' mechanism changed

Previously, 'off' emitted an enumerated list of per-cop Enabled: false entries
sourced from rubocop/defaults-1.50.0.yml. That file has been removed.

The new 'off' profile uses RuboCop's native switch:

AllCops:
  DisabledByDefault: true
  NewCops: disable

This achieves the same effect (all cops disabled) through a single idiomatic
mechanism. Modules using selected_profile: 'off' will get this new form on
pdk update — no action required unless you relied on the enumerated list for
non-PDK tooling.

2. NewCops: enable surfaces more offences in modules with older .rubocop_todo.yml files

Switching from the old curated enumeration to NewCops: enable activates cops that
were either not in the previous strict enable-list or not covered by an older
.rubocop_todo.yml. This can surface new CONVENTION-level RuboCop offences on
pdk validate for modules whose .rubocop_todo.yml was generated against an older
RuboCop version (for example, motd's .rubocop_todo.yml from RuboCop 1.48.1 in 2023
did not suppress the 11 offences that appeared in the verification run).

To triage new offences, module maintainers should:

  • Set include_todos: true in .sync.yml, then run pdk update to ensure
    .rubocop.yml inherits from .rubocop_todo.yml.
  • Regenerate .rubocop_todo.yml using bundle exec rubocop --auto-gen-config or
    pdk validate --auto-correct to baseline the new findings.
  • Alternatively, address each offence directly or disable individual cops via
    cop_overrides in .sync.yml.

3. cleanups_only, strict, and hardcore are backward-compatible aliases for 'on'

All three legacy profile names resolve to the same output as selected_profile: 'on'
(i.e. AllCops.NewCops: enable with the base default_configs EnforcedStyle tunings).
Existing .sync.yml files that set any of these names continue to work with zero
migration required.


Durable reference

See the updated README.md "Manage Rubocop rules" section and the .rubocop.yml
config-key table for the authoritative key reference (selected_profile, profiles,
cop_overrides, include_todos, TargetRubyVersion, default_configs) and usage
examples under the new model. CLAUDE.md documents the deep-merge model, the
convention tunings (and their inline Description: rationale), and the ---
knockout prefix.


Out of scope

Release cut, version bump, and tag order are not part of this PR.

🤖 Generated with Claude Code

@david22swan david22swan requested a review from a team as a code owner June 18, 2026 16:33
@david22swan david22swan changed the title Cat 1698 rubocop templates (CAT-1698) Slim .rubocop.yml to NewCops: enable deep-merge model (behaviour change) Jun 18, 2026
@david22swan david22swan marked this pull request as draft June 18, 2026 17:50
@david22swan david22swan force-pushed the CAT-1698-rubocop-templates branch from 68a3598 to 6e95d7c Compare June 23, 2026 10:32
david22swan and others added 13 commits June 23, 2026 18:16
Replace the enumeration-based RuboCop configuration with the slim model:

- Reduce the profiles to `'on'` (canonical; NewCops: enable) and `'off'`
  (DisabledByDefault: true, NewCops: disable), with `cleanups_only`, `strict`,
  and `hardcore` kept as backward-compatible aliases that resolve to `'on'`.
- Remove the cleanup_cops anchor and the long per-cop `Enabled: true/false`
  enumeration; cops are now governed implicitly by AllCops.NewCops: enable.
- Keep the `default_configs` anchor of deliberate EnforcedStyle tunings, reused
  by the `'on'` profile.
- Add `cop_overrides` as the authoritative per-module override key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delete the per-cop enumeration and profile-builder tooling that the slim
NewCops: enable deep-merge model replaces (CLEANUP-01):

- rubocop/defaults-1.50.0.yml
- rubocop/cleanup_cops.yml
- rubocop/profile_builder.rb
- rubocop/README.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render the final .rubocop.yml by layering configuration with deep_merge in the
order `defaults -> profile['configs'] -> cop_overrides`, replacing the old
enumerated render path. Resolve the active profile from `selected_profile` and
guard the lookup against invalid or boolean-coerced values (e.g. an unquoted
`on`/`off` coerced to true/false in YAML) so a malformed selection falls back
safely instead of raising.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `/.planning/` to .gitignore so the local GSD planning documents are kept
local and not tracked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the documentation in line with the slim model:

- Rewrite the README "Manage Rubocop rules" section around cop_overrides
  .sync.yml patterns; refresh the config-key table (add cop_overrides and
  TargetRubyVersion, drop cleanup_cops, update selected_profile/profiles/
  default_configs) and attribute Puppet 9 security cops to AllCops.NewCops.
- Rewrite the CLAUDE.md RuboCop Profile System section for the deep_merge model
  and remove the stale rubocop/ directory references.
- Fix doc accuracy findings (CR-01, WR-01): default_configs is a config_defaults.yml
  YAML anchor consumed by the 'on' profile, not a render-time @configs key; and
  config_defaults.yml ships `selected_profile: strict`, not 'on'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
.ci/test_script.sh asserted the old enumerated model: that the default-rendered
.rubocop.yml writes `Performance/CaseWhenSplat: Enabled: true`, and that a
`.sync.yml` override under `default_configs` flips it to false. Both are stale:

- The slim template no longer enumerates per-cop `Enabled: true` entries, so the
  "template applied" check now asserts `NewCops` is present instead.
- `default_configs` is a config_defaults.yml YAML anchor, not a render-time
  override surface; the fixture now uses `cop_overrides`, the authoritative
  per-module override key, which still renders
  `Performance/CaseWhenSplat: Enabled: false` after `pdk update`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add nine cop tunings to the config_defaults.yml `default_configs` anchor (reused
by the 'on' profile) so a freshly-scaffolded module's generated specs and
boilerplate validate clean under the slim `AllCops.NewCops: enable` model,
without re-introducing per-cop enumeration:

- Style/TrailingCommaInHashLiteral: EnforcedStyleForMultiline: comma (PDK emits
  trailing commas; matches the existing Arguments/ArrayLiteral convention)
- RSpec/DescribeClass: Enabled: false (rspec-puppet describes subjects by string;
  the only sanctioned wholesale disable)
- RSpec/MessageSpies, RSpec/ExampleLength, RSpec/MultipleExpectations,
  RSpec/VerifiedDoubleReference: scoped via Exclude to the provider/transport
  spec paths only, where resource-API specs use string doubles and multi-line
  examples
- RSpec/ContextWording: add the 'on' prefix (single-quoted to avoid YAML
  coercing it to boolean true) for rspec-puppet-facts `context "on #{os}"`
- Style/MixinUsage: Exclude spec/spec_helper.rb
- Lint/EmptyClass: Exclude the network_device namespace shim

These are genuine PDK / rspec-puppet conventions we do not control, so they are
tuned in config rather than fought in the templates. Each entry carries an inline
Description: rationale so the intent is not lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…type templates

Fix the Ruby emitted by the provider_type and transport_type object templates so
a freshly-scaffolded module validates clean, fixing the template rather than
silencing the cop:

- Convert the plain `<<-EOS` heredocs to squiggly heredocs with meaningful
  delimiter names (`<<~DOCS` in provider_type, `<<~DESC` in transport_type) and
  re-indent the bodies. Clears Layout/HeredocIndentation,
  Layout/ClosingHeredocIndentation, and Naming/HeredocDelimiterNaming.
- Drop the aligned-value padding from the transport_type `password` hash so it
  matches the single-space style of its sibling hashes. Clears
  Layout/HashAlignment.

Rendered output is behavior-preserving: the heredoc string contents are
byte-identical and only the delimiter token and indentation change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…4 function template

Fix two style offences in the Ruby emitted by the v4 function template:

- Rename the illustrative parameter `x` to `value`. Clears
  Naming/MethodParameterName.
- Emit the dispatch symbol as `:'<%= name %>'` instead of
  `name.to_sym.inspect`. For a namespaced name like `foo::bar` this now renders
  as a single-quoted `:'foo::bar'` rather than a double-quoted `:"foo::bar"`,
  clearing Style/QuotedSymbols. The `dispatch_name` line is left unchanged: it
  never carries a `::`, so it always renders as a bare unquoted symbol.

Behavior of the generated function is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SimpleProvider `get` method in the provider template returned two example
resources (`foo`, `bar`), tripping Metrics/MethodLength (11/10) on a fresh
scaffold. Trim it to a single example resource to clear the offence, and update
the provider spec template to expect the single resource so `pdk test unit`
stays green (51 examples, 0 failures).

Keeping the two templates in lockstep avoids the regression where the provider
returns one resource while the generated spec still asserts two.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The inline `rubocop:disable` in the transport device template referenced
`Style/ClassAndModuleCamelCase`, which RuboCop has moved to the `Naming`
department. Under NewCops: enable this emitted a "has the wrong namespace"
deprecation warning. Update the directive to `Naming/ClassAndModuleCamelCase`.

The directive remains conditional on the transport name containing an
underscore, where the capitalised constant genuinely raises the cop, so it does
not become a redundant disable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The internal `defaults -> profile -> cop_overrides` deep-merge in
moduleroot/.rubocop.yml.erb used `knockout_prefix: "--"`, while CLAUDE.md tells
maintainers to use `---` to remove a cop_override. A maintainer following the
docs got a silent no-op. Align the template to `---` so the documented and
active knockout prefix match.

Only the removal-marker string changes; merge semantics for non-knockout values
are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the RuboCop Profile System section of CLAUDE.md to match the Phase 4
behaviour:

- Note that the `defaults -> profile -> cop_overrides` deep-merge and PDK's
  `.sync.yml` overlay now share one `---` knockout prefix everywhere.
- Note that `default_configs` also tunes several PDK / rspec-puppet-convention
  cops so freshly-scaffolded modules validate clean under NewCops: enable, each
  carrying an inline `Description:` rationale that must not be removed.
- Add a future-improvements note that object types not exercised by
  `.ci/test_script.sh` (plan, legacy V1 function) were intentionally left
  untouched, as candidates for future template-validation coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@david22swan david22swan force-pushed the CAT-1698-rubocop-templates branch from ad25564 to 0639643 Compare June 23, 2026 17:21
@david22swan david22swan changed the title (CAT-1698) Slim .rubocop.yml to NewCops: enable deep-merge model (behaviour change) (CAT-1698) Slim .rubocop.yml to the NewCops: enable deep-merge model and validate clean scaffolds (behaviour change) Jun 23, 2026
@david22swan david22swan marked this pull request as ready for review June 23, 2026 17:25
@david22swan david22swan marked this pull request as draft June 24, 2026 08:35

@gavindidrichsen gavindidrichsen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

/pedev:pr-review surfaced five implementation edge cases worth addressing before or shortly after merge. Comments below are on specific lines.

enabled_cops = cop_configs.keys
else
enabled_cops = []
if @configs['cop_overrides'] && !@configs['cop_overrides'].empty?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cop_overrides type guard is incomplete — should confirm it is a Hash.

The guard @configs['cop_overrides'] && !@configs['cop_overrides'].empty? checks presence but not type. A misconfigured .sync.yml like cop_overrides: "not a hash" passes the guard and causes DeepMerge::deep_merge! to raise a cryptic library error with no hint that the problem is in the module's .sync.yml.

Suggestion:

if @configs['cop_overrides'].is_a?(Hash) && !@configs['cop_overrides'].empty?

Or raise explicitly before the guard:

raise "cop_overrides must be a Hash, got #{@configs['cop_overrides'].class}" unless @configs['cop_overrides'].nil? || @configs['cop_overrides'].is_a?(Hash)

# YAML 1.1 may coerce an unquoted on/off profile name to true/false; map it back.
selected = { true => 'on', false => 'off' }.fetch(selected, selected)
profile = @configs['profiles'] && @configs['profiles'][selected]
raise "Unknown rubocop selected_profile #{selected.inspect}; valid: #{(@configs['profiles'] || {}).keys.join(', ')}" unless profile.is_a?(Hash) && profile['configs']

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

profile['configs'] is checked for truthiness but not confirmed to be a Hash.

The raise guard unless profile.is_a?(Hash) && profile['configs'] checks that profile['configs'] is truthy, but not that it is itself a Hash. A custom profile with configs: "not a hash" would pass this guard and cause DeepMerge::deep_merge! to fail opaquely on the next line.

Suggestion:

raise "..." unless profile.is_a?(Hash) && profile['configs'].is_a?(Hash)

selected = { true => 'on', false => 'off' }.fetch(selected, selected)
profile = @configs['profiles'] && @configs['profiles'][selected]
raise "Unknown rubocop selected_profile #{selected.inspect}; valid: #{(@configs['profiles'] || {}).keys.join(', ')}" unless profile.is_a?(Hash) && profile['configs']
configs = defaults.dup

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

defaults.dup produces a shallow copy — nested hashes are shared.

Hash#dup copies the top-level hash but not nested objects. After dup, configs['AllCops'] and defaults['AllCops'] point to the same object. DeepMerge::deep_merge! mutates the nested hash in place, so defaults is also mutated alongside configs. No current bug (the template renders once per Ruby process invocation), but this is a latent hazard if the template is ever invoked more than once in one process.

Suggestion: use Marshal.load(Marshal.dump(defaults)) for a deep copy, or add a comment documenting the single-render assumption so future maintainers know why dup is safe here.

Comment thread .ci/test_script.sh
grep template < metadata.json
cp "$TEMPLATE_PR_DIR/.ci/fixtures/new_provider_sync.yml" ./.sync.yml
grep -A 1 "Performance/CaseWhenSplat" ./.rubocop.yml | grep -q "true" # Ensure that the template is applied
grep -A 5 "AllCops" ./.rubocop.yml | grep -q "NewCops" # Ensure the slim template is applied (NewCops governs cops, not per-cop enumeration)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pre-update CI assertion is weaker than it looks.

grep -A 5 "AllCops" ./.rubocop.yml | grep -q "NewCops" would pass even if the profile deep-merge were completely broken, because NewCops is hardcoded in the defaults hash unconditionally — it appears in the rendered file regardless of whether profile['configs'] contributed anything.

The old assertion (grep -A 1 "Performance/CaseWhenSplat" | grep -q "true") at least verified that the profile merge contributed a specific cop setting. Consider adding an assertion that checks a tuning from default_configs actually appears — e.g. checking that Style/TrailingCommaInHashLiteral or RSpec/DescribeClass appears in the rendered file with the expected value.

Comment thread config_defaults.yml
Performance/Squeeze:
Performance/StringInclude:
Performance/Sum:
RSpec/MessageSpies:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

RSpec/MessageSpies EnforcedStyle: receive was silently dropped when strict was collapsed into an alias for on.

The old strict profile explicitly set:

RSpec/MessageSpies:
  EnforcedStyle: receive

The new default_configs block scopes RSpec/MessageSpies to an Exclude list for provider/transport spec paths but does not carry forward the EnforcedStyle: receive setting for all other spec files. RuboCop's default for this cop is have_received (spy style), which differs from the receive (expectation style) that modules were previously validated against under strict.

Existing modules using expectation-style doubles outside the excluded paths may start failing pdk validate after updating. Worth verifying against a real module before merge, and documenting the behaviour change in the PR description if the drop is intentional.

@david22swan david22swan changed the title (CAT-1698) Slim .rubocop.yml to the NewCops: enable deep-merge model and validate clean scaffolds (behaviour change) (CAT-1698) Slim .rubocop.yml to the NewCops: enable deep-merge model and validate clean scaffolds Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants