Skip to content

feat(config): ruleset-only ct.dynamicGroup for groups ct no longer owns - #203

Merged
2000game merged 2 commits into
mainfrom
feat/dynamic-group-202
Oct 16, 2026
Merged

2000game merged 2 commits into
mainfrom
feat/dynamic-group-202

Conversation

@2000game

Copy link
Copy Markdown
Member

Closes #202

What

ct.dynamicGroup({ group, dynamic }): a ruleset-only declaration for groups ct no longer owns (e.g. OpenTofu's churchtools_group).

  • dynamic takes the three ct.group({ dynamic }) forms (reuses desugarDynamic).
  • The group id resolves from ct's state, then the id map (as fix(resolve): resolve a group_role's group through the id map #201 does for group_role). Neither: plan-time error. Group gone from ChurchTools: plan-time error, never a create.
  • Writes no state entry, never appears in ct state list, has no delete path. Dropping it plans as a no-op and writes nothing.
  • Refused at config load: same key as a ct.group (either order), or declared twice.
  • Plan items are an update/no-op on the group with only the dynamic field; fetch-failed degradation, portability and stereotype warnings, status-only flips and apply --refresh are unchanged.

Cutover items

  1. Bare-string group scopes: the issue allows the error-with-hint variant, so a bare string that only the id map knows now fails with a hint to write { group: "<key>" } (which resolves through the id map). ct.dynamicGroup keys do not count as same-config declared groups for scopes.
  2. ct state rm: a group key the id map resolves is not state-only; a ct.dynamicGroup is not a declaration that would recreate the group.
  3. ct refresh --group and ct adopt group --with-dynamic accept id-map groups; adopt then emits a ct.dynamicGroup snippet and writes no state entry. --all is unchanged (state only).
  4. ct coverage labels id-map groups "owned elsewhere (id map)".

ct.group({ dynamic }) is unchanged. Handbook pages updated and re-signed.

Tests

tests/dynamic-group.test.ts pins the rendered plan (No changes; one dynamic update; dropped declaration; unknown key; vanished group; fetch-failed), apply writing no state, the scope hint, the state rm guard, refresh and coverage. format:check, lint, typecheck, test, build and the docs staleness check pass locally.

ct.dynamicGroup({ group, dynamic }) manages only the dynamic status and ruleset
of an existing group, found in ct's state or the OpenTofu id map. It writes no
state entry and has no delete path. Refused at load alongside ct.group for the
same key or when declared twice.

Also: ct state rm treats id-map groups as not state-only, ct refresh --group
and ct adopt group --with-dynamic accept id-map groups (adopt emits a
ct.dynamicGroup snippet), ct coverage labels them owned elsewhere, and a bare
string group scope that only the id map knows fails with a hint to use
{ group }.
- state rm: read the id map softly so an unreadable map makes the guard
  stricter instead of skipping it
- plan: refuse a ct.dynamicGroup whose key ct's state holds as another type
- adopt: no empty block or state write when every id-map group is skipped;
  say no state was written; warn that --key/--with-member-fields do not apply
- coverage: show the owned-elsewhere count per type
- share virtualGroupEntry between plan and refresh; apply's event id
  fallback only for ruleset-only items
@2000game

Copy link
Copy Markdown
Member Author

Review pass (/code-review high), each finding checked against the code and, where it claimed runtime behaviour, a test or a live run. Fixed in 2ade7f1:

  • ct state rm failed open on a bad id map. loadIdMap ran inside the catch that degrades to CONFIG_UNREADABLE … removing anyway. It is now read softly before that block, so an unreadable map makes the guard stricter (mapped groups count as state-only), with an ID_MAP_UNREADABLE warning. Regression test added.
  • Adopt of id-map groups. When every selected group was skipped, adopt printed a header-only // group block and saved unchanged state. The CLI also reported "Updated group #… → state" for a group whose state it never wrote. Both are fixed, and --key / --with-member-fields now warn instead of being dropped silently. Added tests for the mapped adopt path, which had none.
  • Key collision. A ct.dynamicGroup whose key ct's state holds as another type silently shadowed that entry in the plan. It is now a plan-time error. Regression test added.
  • Coverage. The per-type table has an elsewhere column, shown only when the id map owns any group.
  • Cleanups: virtualGroupEntry is shared by plan and refresh, the adopt loop uses a Set, and apply's event-id fallback is limited to ruleset-only items.

Not changed:

  • coverage/refresh load the id map unconditionally: ct plan already fails hard on a malformed or foreign-host map (plan.ts), so this is consistent, not a new failure mode.
  • parents naming a ct.dynamicGroup-only key fails at apply, not plan: same on main for any parent key not in state. Nothing validates parent keys at plan time, and this PR only turns the already-linked case into a no-op.
  • rulesetOnly stamped after computePlan: a design preference with no failing case.

Verified: format:check, lint, typecheck, test (1308 passed), build, docs staleness (two pages re-read and re-signed). Live on dev, read-only, from a scratch project with an empty state and the dev id map: ct plan resolves two ct.dynamicGroups through the id map and reports no changes, ct adopt group <id> --with-dynamic --key x --dry-run emits the snippet with the --key warning, and ct coverage renders the new column. State stayed empty throughout.

@2000game
2000game merged commit 078d8b5 into main Oct 16, 2026
3 checks passed
@2000game
2000game deleted the feat/dynamic-group-202 branch October 16, 2026 17:42
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.

feat(config): ruleset-only ct.dynamicGroup for groups ct no longer owns

1 participant