Add cop NestedConstantOutsideConcept for a reference to a nested constant of another concept #1743

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

Problem

A file in one concept can name an object that is nested inside a different concept (B::X). A concept is the first namespace segment below the library root. The caller then depends on how the other concept is built, and nothing in that concept shows the outside caller. Lens A2 defect (2) and lens A15 already state this rule, but no cop checks it. Agents add new reaches because nothing stops them.

Observed

  • A full-tree probe on 2026-11-12 found 198 library sites that name a nested constant of a different concept. All 198 were read and are true offenses. The final rule had 0 false hits.
  • 28 nested constants have callers in 2 or more other concepts (132 of the 198 sites). 48 sites name a nested error class.
  • A rule that uses syntax only is not possible. It gave 2,621 hits, and 2,421 were false: test files, bin/ and hooks/ scripts, and names from gems.
  • Full analysis: docs/research/lint-lessons/2026-11-12-domain-boundary-cops/smell-1-cross-domain-reach.md.

Reproduce

n/a: feature ticket.

Approach

Add the cop Sdlc/Structural/NestedConstantOutsideConcept and its lint lesson. The cop gets its facts from the Zeitwerk loader, as Sdlc/Structural/InwardDependency does (lib/os_sdlc/cops/structural/inward_dependency.rb:9, lib/os_sdlc/cops/structural/layer_ledger/layer_map.rb:11). It uses no hand list and no Exclude list.

The value rule:

library   = the constants that a Zeitwerk loader expects from its files (all_expected_cpaths, with the namespace dirs)
root      = the namespace that the loader pushes for its lib dir
source    = a file that the loader maps to a constant S; source concept = the first segment of S below the root
            (use the constant of the file, not the lexical scope)
reference = an outermost constant node; resolve its head in the innermost lexical scope where it is a library constant
            (a path that starts with the root name starts at the root)
named     = the longest prefix of the resolved path that is a library constant
FIRE when   named is 2 or more segments below the root (it is a constant below a concept root) AND its first segment != source concept

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

  • A file that the loader does not map to a constant (a test, a bin/ script, a hook) is not a source. The cop is silent there.
  • A concept root named alone (B), or a value constant directly on a root (B::LIMIT, so named is B), is 1 segment below the root. The cop is silent.
  • B::X::LIMIT fires one time, and the offense names B::X.
  • rescue B::X, raise B::X, and class Y < B::X fire.
  • A name from a gem or the standard library is not a library constant. The cop is silent.

Constraints:

  • Do not call const_get and do not autoload a constant. Some library constants are Sequel models that need a database connection.
  • Do not hard-code OsSdlc. Sdlc cops run in every target project (ADR-0082, ADR-0298). Use the loader whose expected constants contain the file. When no loader contains the file, report nothing.
  • Return a digest of the expected-constant list from external_dependency_checksum, so the RuboCop result cache does not keep an old answer after a file moves.
  • The offense message contains no line number.

Offense message:

%<named>s is inside concept %<concept>s, and this file is in concept %<source>s. Name only the root of another concept: if several concepts use this object, make it top-level; if only this concept uses it, give the root a message that does the work.

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

key: Sdlc/Structural/NestedConstantOutsideConcept
signal: >-
  A file in one concept names a constant that is nested below the root of a different concept
  (`B::X`, `B::X::Y`, `B::X::LIMIT`).
sign_of: >-
  The caller knows how the other concept is built, not only what it does. Nothing in that
  concept shows the outside caller.
why: >-
  A change that the other concept makes for its own reasons reaches the caller, and no test of
  the caller has to fail. A reader cannot find the public names of a concept without a search of
  the whole tree. A value directly on the root (`B::LIMIT`) is interface, and it is permitted.
decision: >-
  Count the concepts that use the nested object. Find out if the root is an object that you can
  send a message to, or only a namespace.
correction: >-
  Several concepts use the object: it is a shared object in the wrong place, so make it
  top-level. Only this concept uses it and its own concept does not: move it into this concept.
  Only this concept and its own concept use it: add a message to the root that does the work. Do
  not add a root method that only forwards. The root is only a namespace, or the site is a
  rescue clause: make the object top-level, or let the root handle the failure and return a
  value. A value below a nested object: move it to the root. When you find no smell at the
  reported site, do not disable the cop: file a false-positive ticket with the snippet. It
  becomes a must-pass fixture.
example:
  before: |-
    class Invoice
      def tax(region)
        Billing::TaxTable.new(rates).rate_for(region)
      end
    end
  after: |-
    class Invoice
      def tax(region)
        Billing.tax_rate(region)
      end
    end
related: [Sdlc/Structural/InwardDependency, Sdlc/Structural/ReachThroughChain]

Enable the cop in the sweep tier only (.rubocop-sweep.yml, as Sdlc/Structural/InwardDependency at :7, with no Exclude). The tree has 198 true offenses, so the cop does not go in .rubocop.yml in this ticket.

Required work:

  1. Add the lesson file — check: present Sdlc/Structural/NestedConstantOutsideConcept in plugins/os-sdlc/lint-lessons/sdlc-structural-nested-constant-outside-concept.yml
  2. Enable the cop in the sweep configuration — check: present Sdlc/Structural/NestedConstantOutsideConcept in plugins/os-sdlc/.rubocop-sweep.yml
  3. Key the RuboCop result cache on the expected-constant list — check: present external_dependency_checksum in plugins/os-sdlc/lib/os_sdlc/cops/structural/nested_constant_outside_concept.rb

Orchestrator work: none.

Rejected: a cop that uses syntax only — 2,421 false hits of 2,621. Rejected: an exemption list for root values or for test files — the loader rule gives the same result with no list. Rejected: must-pass in .rubocop.yml now — 198 true offenses exist; the move to must-pass is a later ticket.

Acceptance criteria

  1. Given a library file in concept A that names B::X, where B::X has its own file in the same loader, when the cop runs on that file, then it reports one offense on the reference, and the message names B::X, concept B, and concept A.
  2. Given a library file in concept A that names only the root B, when the cop runs, then it reports no offense.
  3. Given a library file in concept A that names B::LIMIT, where LIMIT is a value in the file of B and has no file of its own, when the cop runs, then it reports no offense.
  4. Given a library file in concept A that names A::X, A::X::Y, and a sibling by its bare name, when the cop runs, then it reports no offense.
  5. Given a library file in concept A that names B::X::LIMIT, when the cop runs, then it reports one offense, and the message names B::X.
  6. Given a library file in concept A that has rescue B::X and a class with the superclass B::X, when the cop runs, then it reports one offense for each of the two references.
  7. Given a file that the loader does not map to a constant, with the same source text as criterion 1, when the cop runs, then it reports no offense.
  8. Given a library file in concept A that names a qualified constant that no loader file defines (Gem::Thing), when the cop runs, then it reports no offense.
  9. Given a file of concept A whose code names B::X at file level, outside each class and module body, when the cop runs, then it reports one offense, because the source concept comes from the constant of the file.
  10. Given two library trees whose expected-constant lists differ, when the cop computes its external dependency checksum for each tree, then the two values differ.

Owner sketch

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

Non-goals

  • The repair of the 198 existing sites.
  • The move of the cop from the sweep tier to .rubocop.yml.
  • References from test files, bin/ scripts, and hooks.
  • A loader option for codebases whose loader is not in the RuboCop process.

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: 198 library sites name a nested constant of a different concept ← no cop checks lens A2 defect (2) or lens A15 ← DESIGN (plugins/os-sdlc/skills/lens-architecture/SKILL.md A2, A15)
  • Root candidate: none found; searched NestedConstant, nested constant, cross-concept, another concept, different concept, A2, A15, InwardDependency, reach, boundary, privacy, ConceptCycle, LibraryOutsideOwner, ReachThroughChain
  • 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 proposes a cop for a library file that names a nested constant of another concept.

## Problem A file in one concept can name an object that is nested inside a different concept (`B::X`). A concept is the first namespace segment below the library root. The caller then depends on how the other concept is built, and nothing in that concept shows the outside caller. Lens A2 defect (2) and lens A15 already state this rule, but no cop checks it. Agents add new reaches because nothing stops them. ## Observed - A full-tree probe on 2026-11-12 found 198 library sites that name a nested constant of a different concept. All 198 were read and are true offenses. The final rule had 0 false hits. - 28 nested constants have callers in 2 or more other concepts (132 of the 198 sites). 48 sites name a nested error class. - A rule that uses syntax only is not possible. It gave 2,621 hits, and 2,421 were false: test files, `bin/` and `hooks/` scripts, and names from gems. - Full analysis: `docs/research/lint-lessons/2026-11-12-domain-boundary-cops/smell-1-cross-domain-reach.md`. ## Reproduce n/a: feature ticket. ## Approach Add the cop `Sdlc/Structural/NestedConstantOutsideConcept` and its lint lesson. The cop gets its facts from the Zeitwerk loader, as `Sdlc/Structural/InwardDependency` does (`lib/os_sdlc/cops/structural/inward_dependency.rb:9`, `lib/os_sdlc/cops/structural/layer_ledger/layer_map.rb:11`). It uses no hand list and no `Exclude` list. The value rule: ``` library = the constants that a Zeitwerk loader expects from its files (all_expected_cpaths, with the namespace dirs) root = the namespace that the loader pushes for its lib dir source = a file that the loader maps to a constant S; source concept = the first segment of S below the root (use the constant of the file, not the lexical scope) reference = an outermost constant node; resolve its head in the innermost lexical scope where it is a library constant (a path that starts with the root name starts at the root) named = the longest prefix of the resolved path that is a library constant FIRE when named is 2 or more segments below the root (it is a constant below a concept root) AND its first segment != source concept ``` Consequences of the rule, with no special case in the code: - A file that the loader does not map to a constant (a test, a `bin/` script, a hook) is not a source. The cop is silent there. - A concept root named alone (`B`), or a value constant directly on a root (`B::LIMIT`, so `named` is `B`), is 1 segment below the root. The cop is silent. - `B::X::LIMIT` fires one time, and the offense names `B::X`. - `rescue B::X`, `raise B::X`, and `class Y < B::X` fire. - A name from a gem or the standard library is not a library constant. The cop is silent. Constraints: - Do not call `const_get` and do not autoload a constant. Some library constants are Sequel models that need a database connection. - Do not hard-code `OsSdlc`. Sdlc cops run in every target project (ADR-0082, ADR-0298). Use the loader whose expected constants contain the file. When no loader contains the file, report nothing. - Return a digest of the expected-constant list from `external_dependency_checksum`, so the RuboCop result cache does not keep an old answer after a file moves. - The offense message contains no line number. Offense message: ``` %<named>s is inside concept %<concept>s, and this file is in concept %<source>s. Name only the root of another concept: if several concepts use this object, make it top-level; if only this concept uses it, give the root a message that does the work. ``` Lesson file `lint-lessons/sdlc-structural-nested-constant-outside-concept.yml` (fields as in `lib/os_sdlc/lesson.rb:4`; model `lint-lessons/sdlc-structural-inward-dependency.yml`): ```yaml key: Sdlc/Structural/NestedConstantOutsideConcept signal: >- A file in one concept names a constant that is nested below the root of a different concept (`B::X`, `B::X::Y`, `B::X::LIMIT`). sign_of: >- The caller knows how the other concept is built, not only what it does. Nothing in that concept shows the outside caller. why: >- A change that the other concept makes for its own reasons reaches the caller, and no test of the caller has to fail. A reader cannot find the public names of a concept without a search of the whole tree. A value directly on the root (`B::LIMIT`) is interface, and it is permitted. decision: >- Count the concepts that use the nested object. Find out if the root is an object that you can send a message to, or only a namespace. correction: >- Several concepts use the object: it is a shared object in the wrong place, so make it top-level. Only this concept uses it and its own concept does not: move it into this concept. Only this concept and its own concept use it: add a message to the root that does the work. Do not add a root method that only forwards. The root is only a namespace, or the site is a rescue clause: make the object top-level, or let the root handle the failure and return a value. A value below a nested object: move it to the root. When you find no smell at the reported site, do not disable the cop: file a false-positive ticket with the snippet. It becomes a must-pass fixture. example: before: |- class Invoice def tax(region) Billing::TaxTable.new(rates).rate_for(region) end end after: |- class Invoice def tax(region) Billing.tax_rate(region) end end related: [Sdlc/Structural/InwardDependency, Sdlc/Structural/ReachThroughChain] ``` Enable the cop in the sweep tier only (`.rubocop-sweep.yml`, as `Sdlc/Structural/InwardDependency` at `:7`, with no `Exclude`). The tree has 198 true offenses, so the cop does not go in `.rubocop.yml` in this ticket. Required work: 1. Add the lesson file — check: present `Sdlc/Structural/NestedConstantOutsideConcept` in `plugins/os-sdlc/lint-lessons/sdlc-structural-nested-constant-outside-concept.yml` 2. Enable the cop in the sweep configuration — check: present `Sdlc/Structural/NestedConstantOutsideConcept` in `plugins/os-sdlc/.rubocop-sweep.yml` 3. Key the RuboCop result cache on the expected-constant list — check: present `external_dependency_checksum` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/nested_constant_outside_concept.rb` Orchestrator work: none. Rejected: a cop that uses syntax only — 2,421 false hits of 2,621. Rejected: an exemption list for root values or for test files — the loader rule gives the same result with no list. Rejected: must-pass in `.rubocop.yml` now — 198 true offenses exist; the move to must-pass is a later ticket. ## Acceptance criteria 1. Given a library file in concept A that names `B::X`, where `B::X` has its own file in the same loader, when the cop runs on that file, then it reports one offense on the reference, and the message names `B::X`, concept `B`, and concept `A`. 2. Given a library file in concept A that names only the root `B`, when the cop runs, then it reports no offense. 3. Given a library file in concept A that names `B::LIMIT`, where `LIMIT` is a value in the file of `B` and has no file of its own, when the cop runs, then it reports no offense. 4. Given a library file in concept A that names `A::X`, `A::X::Y`, and a sibling by its bare name, when the cop runs, then it reports no offense. 5. Given a library file in concept A that names `B::X::LIMIT`, when the cop runs, then it reports one offense, and the message names `B::X`. 6. Given a library file in concept A that has `rescue B::X` and a class with the superclass `B::X`, when the cop runs, then it reports one offense for each of the two references. 7. Given a file that the loader does not map to a constant, with the same source text as criterion 1, when the cop runs, then it reports no offense. 8. Given a library file in concept A that names a qualified constant that no loader file defines (`Gem::Thing`), when the cop runs, then it reports no offense. 9. Given a file of concept A whose code names `B::X` at file level, outside each `class` and `module` body, when the cop runs, then it reports one offense, because the source concept comes from the constant of the file. 10. Given two library trees whose expected-constant lists differ, when the cop computes its external dependency checksum for each tree, then the two values differ. ## Owner sketch ```ruby class RuboCop::Cop::Sdlc::Structural::NestedConstantOutsideConcept < RecordingCop def on_const(node) def external_dependency_checksum end ``` ## Non-goals - The repair of the 198 existing sites. - The move of the cop from the sweep tier to `.rubocop.yml`. - References from test files, `bin/` scripts, and hooks. - A loader option for codebases whose loader is not in the RuboCop process. ## 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: 198 library sites name a nested constant of a different concept ← no cop checks lens A2 defect (2) or lens A15 ← DESIGN (`plugins/os-sdlc/skills/lens-architecture/SKILL.md` A2, A15) - Root candidate: none found; searched NestedConstant, nested constant, cross-concept, another concept, different concept, A2, A15, InwardDependency, reach, boundary, privacy, ConceptCycle, LibraryOutsideOwner, ReachThroughChain - 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 proposes a cop for a library file that names a nested constant of another concept.
Author
Owner

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

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

Resolution

Done: Added the cop Sdlc/Structural/NestedConstantOutsideConcept, its two collaborators (Library, Library::Crossing), its lint lesson, and its tests. The cop reads the Zeitwerk loader and reports a library file that names a constant nested below the root of a different concept. It is enabled in the sweep tier only.

Evidence: Implementation 289 ended implementation_complete; commit f4f00c15c on branch ticket-1743 (not merged yet). Sweep run on the real tree: 200 offenses in 115 lib files, none in test, bin, or hooks. rubocop --show-cops under .rubocop.yml shows Enabled: false. One site more than the probe count (198 + 1) is not traced.

Follow-ups: #1745 (repair the offenses and move the three boundary cops from sweep to gate; ticket-skeptic verdict CREATE). The ticket rule line was corrected during escalation 129 to 'named is 2 or more segments below the root'. No other follow-up.

## Resolution **Done:** Added the cop Sdlc/Structural/NestedConstantOutsideConcept, its two collaborators (Library, Library::Crossing), its lint lesson, and its tests. The cop reads the Zeitwerk loader and reports a library file that names a constant nested below the root of a different concept. It is enabled in the sweep tier only. **Evidence:** Implementation 289 ended implementation_complete; commit f4f00c15c on branch ticket-1743 (not merged yet). Sweep run on the real tree: 200 offenses in 115 lib files, none in test, bin, or hooks. rubocop --show-cops under .rubocop.yml shows Enabled: false. One site more than the probe count (198 + 1) is not traced. **Follow-ups:** #1745 (repair the offenses and move the three boundary cops from sweep to gate; ticket-skeptic verdict CREATE). The ticket rule line was corrected during escalation 129 to 'named is 2 or more segments below the root'. No other follow-up. <!-- os-backlog kind: resolution -->
jared closed this issue 2026-11-12 22:34:56 +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#1743
No description provided.