Add cop ConceptCycle that reports the reference that closes a concept loop #1742

Closed
opened 2026-11-12 21:52:07 +00:00 by jared · 4 comments
Owner

Problem

Concepts can depend on each other in a loop. A concept is the first namespace segment below the library root. A loop makes all its concepts one unit of change: a change to one member can break each member, and no member can move out alone. Lint::ConceptCycleCheck finds a loop, prints it, and always exits 0. Nothing fails when a new reference closes a loop, and the report does not name the reference that closed it.

Observed

  • On 2026-11-12, nine concepts form one loop: CLI, Dispatch, Gate, Implementation, Lint, MutationTest, Project, StepOutcome, Tracker. The loop has 27 edges and 93 reference sites.
  • All 93 sites were read against the source. Each is a true reference. The resolver had 0 errors.
  • The smallest set of edges that breaks the loop is 7 edges with 14 sites. 5 of the 7 edges are wiring: a default collaborator or an entry object inside a concept that other concepts use.
  • lib/os_sdlc/lint/concept_cycle_check.rb:7-12 prints cycle: ... and returns 0.
  • Full analysis: docs/research/lint-lessons/2026-11-12-domain-boundary-cops/smell-2-domain-cycle.md.

Reproduce

n/a: feature ticket.

Approach

Add the cop Sdlc/Structural/ConceptCycle and its lint lesson. The cop reports the reference, at its line, that is an edge of a loop.

The value rule:

concept = the first segment below the root namespace of the library
edge A->B = a constant reference in a library file, written in a scope of concept A, that resolves by the
            lexical rules of Ruby to a constant that the library defines in concept B, where B != A
counted   = each such reference: error classes (raise, rescue, superclass), value constants, superclass
            references, keyword defaults, and entry objects (no list of exempt concepts)
not counted = a reference in a test file; a class name in a string; a dynamic name
FIRE on a reference that is an edge A->B when B reaches A through the edges of the tree

Consequences of the rule, with no special case in the code:

  • An entry concept that names many concepts, and that no concept names, is not on a loop. The cop is silent there.
  • A one-way edge does not fire.

Constraints:

  • Get the references and the name resolution from Lint::ConceptCycleCheck::References (lib/os_sdlc/lint/concept_cycle_check/references.rb). Give that class a public message for the resolution if the cop needs one. Do not write a second resolver and do not add a new parser call in the cop.
  • Do not hard-code OsSdlc. Sdlc cops run in every target project (ADR-0082). Get the root namespace and the library directory from the loader of the target, as lib/os_sdlc/detector/cycle_edges/signals.rb:12 does. When no library is found for the file, report nothing.
  • The graph is a fact of the full tree. Build it one time for each run, and return a digest of the library files from external_dependency_checksum, so the RuboCop result cache does not keep an old answer.
  • The offense message contains no line number.

Offense message:

`%<constant>s` (concept %<target>s) is used from concept %<source>s, but %<target>s already reaches %<source>s through %<path>s. Concepts must not form a loop: cut one edge of it.

Lesson file lint-lessons/sdlc-structural-concept-cycle.yml (fields as in lib/os_sdlc/lesson.rb:4; model lint-lessons/sdlc-structural-inward-dependency.yml):

key: Sdlc/Structural/ConceptCycle
signal: >-
  A reference from one top-level namespace to another closes a loop: the other namespace
  already reaches this one, directly or through other namespaces.
sign_of: >-
  Wiring in the wrong place. The code that picks a concrete collaborator, an entry object, or
  a default sits inside a namespace that lower code depends on. Or one aggregate is split
  across two namespaces that call each other.
why: >-
  A loop makes all its namespaces one unit of change. A change to any member can break every
  member, a test of one member loads all of them, and no member can move out alone.
decision: >-
  Find the edge of the loop that does wiring, and cut it. A default collaborator, a keyword
  default that names a class, or an object that builds an entry: the entry passes it in.
  A base class or helper that shares a namespace with code that drives the whole system:
  move the driver to its own namespace, which nothing references. An error class or value
  constant: move it to the namespace that raises or owns it, and rescue at the caller. A
  procedure that only the other namespace calls: move it there. Two models of one aggregate:
  keep one direction (the child knows the parent), and let the parent reach its children
  through the association, not through the constant of the child.
correction: >-
  Cut or move until the loop is gone. Do not move the whole loop into one namespace to hide
  it. When the reported reference is not on a loop (the name resolves elsewhere in Ruby),
  file a false-positive ticket with the snippet. It becomes a must-pass fixture.
example:
  before: |-
    module Billing
      class Collaborators
        def mailer = @mailer ||= Mailing::Smtp.new
      end
    end
    module Mailing
      class Receipt
        def total = Billing::Invoice.find(@id).total
      end
    end
  after: |-
    module Billing
      class Collaborators
        def initialize(mailer:) = @mailer = mailer
        attr_reader :mailer
      end
    end
    module Entry
      class Run
        def collaborators = Billing::Collaborators.new(mailer: Mailing::Smtp.new)
      end
    end
related: [Sdlc/Structural/InwardDependency, detector/cycle-edges]

Enable the cop in the sweep tier only (.rubocop-sweep.yml, as Sdlc/Structural/InwardDependency at :7, with no Exclude). One loop exists today, so the cop does not go in .rubocop.yml in this ticket.

Required work:

  1. Add the lesson file — check: present Sdlc/Structural/ConceptCycle in plugins/os-sdlc/lint-lessons/sdlc-structural-concept-cycle.yml
  2. Enable the cop in the sweep configuration — check: present Sdlc/Structural/ConceptCycle in plugins/os-sdlc/.rubocop-sweep.yml
  3. Key the RuboCop result cache on the library files — check: present external_dependency_checksum in plugins/os-sdlc/lib/os_sdlc/cops/structural/concept_cycle.rb

Orchestrator work: none.

Rejected: bin/lint-concept-cycles exits 1 and a new gate step runs it — it fails each run until the existing loop is cut, and it does not name the reference. Rejected: a recorded loop size — a recorded number is a baseline. Rejected: a list of exempt entry concepts — an entry that nothing names is never on a loop.

Acceptance criteria

  1. Given a library tree where a file of concept A names a constant of concept B, and a file of concept B names a constant of concept A, when the cop runs on the file of A, then it reports one offense on the reference to B, and the message names the constant, concept B, and concept A.
  2. Given a library tree with the edges A to B, B to C, and C to A, when the cop runs on the file of A, then it reports one offense on the reference to B, and the message names C in the path.
  3. Given a library tree where A names B and B does not reach A, when the cop runs on each file, then it reports no offense.
  4. Given a library tree where an entry concept names A and B, A names B, and no concept names the entry concept, when the cop runs on each file, then it reports no offense.
  5. Given a library tree where a class of concept A has a superclass in concept B, and concept B names a constant of concept A, when the cop runs on the file of A, then it reports one offense on the superclass reference.
  6. Given a library tree with the one-way edge A to B, and a test file outside the library that names constants of A and B, when the cop runs on the test file, then it reports no offense.
  7. Given a library tree with the loop of criterion 1, when the cop runs on a file of A that names only a qualified constant that the library does not define, then it reports no offense on that reference.
  8. Given two library trees that differ in one library file, when the cop computes its external dependency checksum for each tree, then the two values differ.

Owner sketch

class RuboCop::Cop::Sdlc::Structural::ConceptCycle < RecordingCop
  def on_const(node)
  def external_dependency_checksum
end

class OsSdlc::Lint::ConceptCycleCheck::References
  def resolve(scope, name)
end

Non-goals

  • The cut of the nine-concept loop that exists today.
  • The move of the cop from the sweep tier to .rubocop.yml.
  • A change to the exit status of bin/lint-concept-cycles.
  • Sequel association class names in strings.

Origin

  • Trigger: design session on concept boundaries, 2026-11-12; Jared asked for this cop, its smell analysis, and its implementation
  • Improvised this session: none
  • Chain: nine concepts form one loop ← Lint::ConceptCycleCheck prints a loop and returns 0, and no gate reads it ← DESIGN (plugins/os-sdlc/lib/os_sdlc/lint/concept_cycle_check.rb:7-12)
  • Root candidate: none found; searched cycle, concept cycle, ConceptCycleCheck, lint-concept-cycles, cycle-edges, loop, circular, acyclic, PipelineScript, Structural/ConceptCycle
  • Where: plugins/os-sdlc/lib/os_sdlc/cops/structural/
  • Session: 4ee81bfb-d577-49b1-a9cf-617e4d2dae5f
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os-plugins-os-sdlc/4ee81bfb-d577-49b1-a9cf-617e4d2dae5f.jsonl

VERDICT: CREATE (ticket-skeptic, 2026-11-12, backlog search only; Jared asked for this ticket): no open ticket adds a cop that fires on a loop-closing reference.

## Problem Concepts can depend on each other in a loop. A concept is the first namespace segment below the library root. A loop makes all its concepts one unit of change: a change to one member can break each member, and no member can move out alone. `Lint::ConceptCycleCheck` finds a loop, prints it, and always exits 0. Nothing fails when a new reference closes a loop, and the report does not name the reference that closed it. ## Observed - On 2026-11-12, nine concepts form one loop: `CLI`, `Dispatch`, `Gate`, `Implementation`, `Lint`, `MutationTest`, `Project`, `StepOutcome`, `Tracker`. The loop has 27 edges and 93 reference sites. - All 93 sites were read against the source. Each is a true reference. The resolver had 0 errors. - The smallest set of edges that breaks the loop is 7 edges with 14 sites. 5 of the 7 edges are wiring: a default collaborator or an entry object inside a concept that other concepts use. - `lib/os_sdlc/lint/concept_cycle_check.rb:7-12` prints `cycle: ...` and returns 0. - Full analysis: `docs/research/lint-lessons/2026-11-12-domain-boundary-cops/smell-2-domain-cycle.md`. ## Reproduce n/a: feature ticket. ## Approach Add the cop `Sdlc/Structural/ConceptCycle` and its lint lesson. The cop reports the reference, at its line, that is an edge of a loop. The value rule: ``` concept = the first segment below the root namespace of the library edge A->B = a constant reference in a library file, written in a scope of concept A, that resolves by the lexical rules of Ruby to a constant that the library defines in concept B, where B != A counted = each such reference: error classes (raise, rescue, superclass), value constants, superclass references, keyword defaults, and entry objects (no list of exempt concepts) not counted = a reference in a test file; a class name in a string; a dynamic name FIRE on a reference that is an edge A->B when B reaches A through the edges of the tree ``` Consequences of the rule, with no special case in the code: - An entry concept that names many concepts, and that no concept names, is not on a loop. The cop is silent there. - A one-way edge does not fire. Constraints: - Get the references and the name resolution from `Lint::ConceptCycleCheck::References` (`lib/os_sdlc/lint/concept_cycle_check/references.rb`). Give that class a public message for the resolution if the cop needs one. Do not write a second resolver and do not add a new parser call in the cop. - Do not hard-code `OsSdlc`. Sdlc cops run in every target project (ADR-0082). Get the root namespace and the library directory from the loader of the target, as `lib/os_sdlc/detector/cycle_edges/signals.rb:12` does. When no library is found for the file, report nothing. - The graph is a fact of the full tree. Build it one time for each run, and return a digest of the library files from `external_dependency_checksum`, so the RuboCop result cache does not keep an old answer. - The offense message contains no line number. Offense message: ``` `%<constant>s` (concept %<target>s) is used from concept %<source>s, but %<target>s already reaches %<source>s through %<path>s. Concepts must not form a loop: cut one edge of it. ``` Lesson file `lint-lessons/sdlc-structural-concept-cycle.yml` (fields as in `lib/os_sdlc/lesson.rb:4`; model `lint-lessons/sdlc-structural-inward-dependency.yml`): ```yaml key: Sdlc/Structural/ConceptCycle signal: >- A reference from one top-level namespace to another closes a loop: the other namespace already reaches this one, directly or through other namespaces. sign_of: >- Wiring in the wrong place. The code that picks a concrete collaborator, an entry object, or a default sits inside a namespace that lower code depends on. Or one aggregate is split across two namespaces that call each other. why: >- A loop makes all its namespaces one unit of change. A change to any member can break every member, a test of one member loads all of them, and no member can move out alone. decision: >- Find the edge of the loop that does wiring, and cut it. A default collaborator, a keyword default that names a class, or an object that builds an entry: the entry passes it in. A base class or helper that shares a namespace with code that drives the whole system: move the driver to its own namespace, which nothing references. An error class or value constant: move it to the namespace that raises or owns it, and rescue at the caller. A procedure that only the other namespace calls: move it there. Two models of one aggregate: keep one direction (the child knows the parent), and let the parent reach its children through the association, not through the constant of the child. correction: >- Cut or move until the loop is gone. Do not move the whole loop into one namespace to hide it. When the reported reference is not on a loop (the name resolves elsewhere in Ruby), file a false-positive ticket with the snippet. It becomes a must-pass fixture. example: before: |- module Billing class Collaborators def mailer = @mailer ||= Mailing::Smtp.new end end module Mailing class Receipt def total = Billing::Invoice.find(@id).total end end after: |- module Billing class Collaborators def initialize(mailer:) = @mailer = mailer attr_reader :mailer end end module Entry class Run def collaborators = Billing::Collaborators.new(mailer: Mailing::Smtp.new) end end related: [Sdlc/Structural/InwardDependency, detector/cycle-edges] ``` Enable the cop in the sweep tier only (`.rubocop-sweep.yml`, as `Sdlc/Structural/InwardDependency` at `:7`, with no `Exclude`). One loop exists today, so the cop does not go in `.rubocop.yml` in this ticket. Required work: 1. Add the lesson file — check: present `Sdlc/Structural/ConceptCycle` in `plugins/os-sdlc/lint-lessons/sdlc-structural-concept-cycle.yml` 2. Enable the cop in the sweep configuration — check: present `Sdlc/Structural/ConceptCycle` in `plugins/os-sdlc/.rubocop-sweep.yml` 3. Key the RuboCop result cache on the library files — check: present `external_dependency_checksum` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/concept_cycle.rb` Orchestrator work: none. Rejected: `bin/lint-concept-cycles` exits 1 and a new gate step runs it — it fails each run until the existing loop is cut, and it does not name the reference. Rejected: a recorded loop size — a recorded number is a baseline. Rejected: a list of exempt entry concepts — an entry that nothing names is never on a loop. ## Acceptance criteria 1. Given a library tree where a file of concept A names a constant of concept B, and a file of concept B names a constant of concept A, when the cop runs on the file of A, then it reports one offense on the reference to B, and the message names the constant, concept `B`, and concept `A`. 2. Given a library tree with the edges A to B, B to C, and C to A, when the cop runs on the file of A, then it reports one offense on the reference to B, and the message names `C` in the path. 3. Given a library tree where A names B and B does not reach A, when the cop runs on each file, then it reports no offense. 4. Given a library tree where an entry concept names A and B, A names B, and no concept names the entry concept, when the cop runs on each file, then it reports no offense. 5. Given a library tree where a class of concept A has a superclass in concept B, and concept B names a constant of concept A, when the cop runs on the file of A, then it reports one offense on the superclass reference. 6. Given a library tree with the one-way edge A to B, and a test file outside the library that names constants of A and B, when the cop runs on the test file, then it reports no offense. 7. Given a library tree with the loop of criterion 1, when the cop runs on a file of A that names only a qualified constant that the library does not define, then it reports no offense on that reference. 8. Given two library trees that differ in one library file, when the cop computes its external dependency checksum for each tree, then the two values differ. ## Owner sketch ```ruby class RuboCop::Cop::Sdlc::Structural::ConceptCycle < RecordingCop def on_const(node) def external_dependency_checksum end class OsSdlc::Lint::ConceptCycleCheck::References def resolve(scope, name) end ``` ## Non-goals - The cut of the nine-concept loop that exists today. - The move of the cop from the sweep tier to `.rubocop.yml`. - A change to the exit status of `bin/lint-concept-cycles`. - Sequel association class names in strings. ## Origin - Trigger: design session on concept boundaries, 2026-11-12; Jared asked for this cop, its smell analysis, and its implementation - Improvised this session: none - Chain: nine concepts form one loop ← `Lint::ConceptCycleCheck` prints a loop and returns 0, and no gate reads it ← DESIGN (`plugins/os-sdlc/lib/os_sdlc/lint/concept_cycle_check.rb:7-12`) - Root candidate: none found; searched cycle, concept cycle, ConceptCycleCheck, lint-concept-cycles, cycle-edges, loop, circular, acyclic, PipelineScript, Structural/ConceptCycle - Where: plugins/os-sdlc/lib/os_sdlc/cops/structural/ - Session: 4ee81bfb-d577-49b1-a9cf-617e4d2dae5f - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os-plugins-os-sdlc/4ee81bfb-d577-49b1-a9cf-617e4d2dae5f.jsonl VERDICT: CREATE (ticket-skeptic, 2026-11-12, backlog search only; Jared asked for this ticket): no open ticket adds a cop that fires on a loop-closing reference.
Author
Owner

Work started on branch ticket-1742 through the os-sdlc implement pipeline.

Work started on branch ticket-1742 through the os-sdlc implement pipeline.
Author
Owner

Removed Required work item 4 (absent Prism in the cop file). implementation-open refuses an absent check whose glob matches no file, and the cop file does not exist before the build. The constraint stays in the Approach text; the orchestrator checks it by hand after the build.

Removed Required work item 4 (absent `Prism` in the cop file). implementation-open refuses an absent check whose glob matches no file, and the cop file does not exist before the build. The constraint stays in the Approach text; the orchestrator checks it by hand after the build.
Author
Owner

Follow-up dropped (ticket-skeptic, DROP, failed the need-to-exist test): 'A cop that needs a gem the cops loader does not load passes its tests and fails in RuboCop'. This is one occurrence, corrected by hand in 2f58591f4 (require "os_git" in cops/structural/concept_cycle/library.rb). The sibling cops of #1743 and #1744 did not have the defect. Note for a later cop: run the new cop with bundle exec rubocop -d --only <Cop> on the real tree before sign-off, and read stderr. File a ticket only if a second cop fails the same way.

Follow-up dropped (ticket-skeptic, DROP, failed the need-to-exist test): 'A cop that needs a gem the cops loader does not load passes its tests and fails in RuboCop'. This is one occurrence, corrected by hand in 2f58591f4 (`require "os_git"` in `cops/structural/concept_cycle/library.rb`). The sibling cops of #1743 and #1744 did not have the defect. Note for a later cop: run the new cop with `bundle exec rubocop -d --only <Cop>` on the real tree before sign-off, and read stderr. File a ticket only if a second cop fails the same way.
Author
Owner

Resolution

Done: Added the cop Sdlc/Structural/ConceptCycle, its collaborators (Reference, Library), its lint lesson, and its tests. Lint::ConceptCycleCheck::References has a public resolve message, and ConceptCycleCheck has route, concept, and concept_edge?. The cop reports each reference that is an edge of a concept loop and names the path back. It is enabled in the sweep tier only.

Evidence: Implementation 290 ended implementation_complete; commits 5d827b7d3 and 2f58591f4 on branch ticket-1742 (not merged yet). Full suite after the last commit: 5390 runs, 0 failures. Sweep run on the real tree: 93 offenses in 58 lib files on 27 edges in 9 concepts, the same counts as the probe; none in test, bin, or hooks; 0 cop errors on stderr. The cop files do not contain Prism. rubocop --show-cops under .rubocop.yml shows Enabled: false. Deviations: Required work item 4 removed from the ticket (absent check on a file that did not exist); two no-work pieces removed by hand at escalation 130, then a reroute; require os_git added by hand after the run ended, because the cop raised NameError in a real RuboCop run.

Follow-ups: #1745 (repair the offenses and move the three boundary cops to the gate; verdict CREATE). #1746 (superclass reference source in ConceptCycleCheck::References; verdict CREATE). Dropped by ticket-skeptic: cop load-state gap, reason in the comment above. bin/lint-concept-cycles with no arguments raises TypeError: not captured, the script needs two arguments; named in the session handoff.

## Resolution **Done:** Added the cop Sdlc/Structural/ConceptCycle, its collaborators (Reference, Library), its lint lesson, and its tests. Lint::ConceptCycleCheck::References has a public resolve message, and ConceptCycleCheck has route, concept, and concept_edge?. The cop reports each reference that is an edge of a concept loop and names the path back. It is enabled in the sweep tier only. **Evidence:** Implementation 290 ended implementation_complete; commits 5d827b7d3 and 2f58591f4 on branch ticket-1742 (not merged yet). Full suite after the last commit: 5390 runs, 0 failures. Sweep run on the real tree: 93 offenses in 58 lib files on 27 edges in 9 concepts, the same counts as the probe; none in test, bin, or hooks; 0 cop errors on stderr. The cop files do not contain Prism. rubocop --show-cops under .rubocop.yml shows Enabled: false. Deviations: Required work item 4 removed from the ticket (absent check on a file that did not exist); two no-work pieces removed by hand at escalation 130, then a reroute; require os_git added by hand after the run ended, because the cop raised NameError in a real RuboCop run. **Follow-ups:** #1745 (repair the offenses and move the three boundary cops to the gate; verdict CREATE). #1746 (superclass reference source in ConceptCycleCheck::References; verdict CREATE). Dropped by ticket-skeptic: cop load-state gap, reason in the comment above. bin/lint-concept-cycles with no arguments raises TypeError: not captured, the script needs two arguments; named in the session handoff. <!-- os-backlog kind: resolution -->
jared closed this issue 2026-11-12 22:56:37 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jared/cc-os#1742
No description provided.