Add cop LibraryOutsideOwner for an external library named outside its owner namespace #1744

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

Problem

Agents that do the same job independently call the same external library. Each agent writes its own copy of the job, and the copies are spelled differently, so a clone detector does not find them. The library name is the one sign that the copies share. No cop reports a file that names a library outside the namespace that owns it, so each new copy passes the gate.

Observed

  • On 2026-11-12, 58 library files in 6 concepts name Prism, and 26 test files parse their input with it. All 84 files were read and are true offenses. The final rule had 0 false hits.
  • Lexical name resolution is written 6 times. A table of constant definitions is written 6 times. Prism.parse and Prism.parse_file have 20 call sites in 19 files. The copies do not agree: one copy handles a leading :: and another copy does not.
  • Ruling 145 (docs/decisions/rulings.tsv) names one owner for this work: OsSdlc::CodeShape.
  • Sdlc/Dry/OwnedMessage cannot express the rule. It matches a send by its message name. It cannot match a constant, a superclass, a type check, a pattern, or a require.
  • No value rule separates a library that needs an owner from a library that all code can use (File, Set, JSON). Thus the cop reads a configured map of owner to libraries. A library that is not in the map is not checked.
  • Full analysis: docs/research/lint-lessons/2026-11-12-domain-boundary-cops/smell-3-library-owner.md.

Reproduce

n/a: feature ticket.

Approach

Add the cop Sdlc/Dry/LibraryOutsideOwner and its lint lesson. Model the configuration and the "inside the owner" test on Sdlc/Dry/OwnedMessage (lib/os_sdlc/cops/dry/owned_message.rb:9, Owners read at :32-36, inside? at :38-42).

Configuration:

Sdlc/Dry/LibraryOutsideOwner:
  Enabled: true
  Owners:
    OsSdlc::CodeShape: [Prism]

The value rule:

library     = a top constant name in the Owners map (Lib)
owner       = the namespace that the map gives for that library
owner root  = the first segment of the owner namespace
counted     = a constant reference whose outermost segment is the library (Lib, ::Lib, Lib::X), in each position:
              receiver, superclass, argument of is_a? / kind_of? / grep, `when` and `in` patterns, rescue class
counted     = require "<snake_case of the library constant>"
not counted = a string, a symbol, or a heredoc that contains the name
not counted = a reference inside a body that defines a constant with the same name (module Lib ... end)
scope       = a node whose enclosing namespace starts with the owner root
scope       = a require at file level, outside each class and module body, has the namespace of the first class or module that the file defines
FIRE on a counted reference in scope whose enclosing namespace is not the owner namespace and is not below it

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

  • A library that is not in the map (File, Set, JSON) is not checked.
  • Code under a different root namespace (a cop under RuboCop::Cop) is out of scope. The cop is silent there.
  • A test file whose classes are under the owner root is in scope.

Constraints:

  • The Owners map changes only when an ownership ruling changes. A new caller never adds an entry to clear a report. Use no Exclude list.
  • The offense message contains no line number.

Offense message:

`%<reference>s` names %<library>s outside its owner %<owner>s: ask %<owner>s for the answer, or move this code into %<owner>s.

Lesson file lint-lessons/sdlc-dry-library-outside-owner.yml (fields as in lib/os_sdlc/lesson.rb:4; model lint-lessons/sdlc-dry-owned-message.yml):

key: Sdlc/Dry/LibraryOutsideOwner
signal: >-
  Code outside the owner namespace names an external library that the configuration gives to
  one owner: as a constant, a superclass, a type check, a pattern, a rescue class, or a require.
sign_of: >-
  The same job done again. A copy that is spelled another way is invisible to a clone detector,
  but the library name shows it.
why: >-
  When one copy is corrected, the other copies still give an answer, but a different one, with
  no error. A change in the library touches each copy.
decision: >-
  Find the question that this code asks the library. If the owner answers it, ask the owner. If
  the owner does not answer it, add the answer to the owner. If this code is the job of the
  owner, move it into the owner namespace. Never add a second owner to the configuration to
  clear the report.
correction: >-
  Replace the library call or the type check with a call to the owner. Give the owner source
  text and get back an answer, not a value of the library. In a test, pass source text to the
  owner or to the unit. When you find no duplicated job 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 Report
      def owner_of(path, scope, name)
        tree = Parser.parse_file(path)
        scope.size.downto(0).map { |n| [*scope.first(n), name].join("::") }.find { |c| known?(tree, c) }
      end
    end
  after: |-
    class Report
      def owner_of(path, scope, name)
        Syntax::LexicalScope.at(path, scope).resolve(name)
      end
    end
related: [Sdlc/Dry/OwnedMessage]

Enable the cop in the sweep tier only (.rubocop-sweep.yml, with no Exclude). The tree has 84 true offenses, and OsSdlc::CodeShape does not hold the moved code yet, so the cop does not go in .rubocop.yml in this ticket. The sweep count is the measure of the migration to OsSdlc::CodeShape.

Required work:

  1. Add the lesson file — check: present Sdlc/Dry/LibraryOutsideOwner in plugins/os-sdlc/lint-lessons/sdlc-dry-library-outside-owner.yml
  2. Enable the cop in the sweep configuration with the owner map — check: present OsSdlc::CodeShape in plugins/os-sdlc/.rubocop-sweep.yml

Orchestrator work: none.

Rejected: new Owners entries in Sdlc/Dry/OwnedMessage — it catches 19 of 58 files, and it is a gate cop, so the entries would fail each gate run now. Rejected: today's concepts as configured owners — that is an allowlist with a different name. Rejected: a rule with no map — core classes such as File and Set would be false hits.

Acceptance criteria

  1. Given Owners that maps Lib to Root::Owner, and a file whose class is Root::Other::Thing and names Lib::Node, when the cop runs, then it reports one offense on the reference, and the message names Lib::Node, Lib, and Root::Owner.
  2. Given the same Owners, and a file whose class is Root::Owner::Thing and names Lib::Node, when the cop runs, then it reports no offense.
  3. Given the same Owners, and a file whose class is Root::Other::Thing and has require "lib", when the cop runs, then it reports one offense on the require.
  4. Given the same Owners, and a file whose class is Root::Other::Thing, with Lib::Visitor as its superclass, is_a?(Lib::Node), an in Lib::Node pattern, and rescue Lib::Error, when the cop runs, then it reports one offense for each of the four references.
  5. Given the same Owners, and a file whose class is Root::Other::Thing and names ::Lib::Node, when the cop runs, then it reports one offense.
  6. Given the same Owners, and a file whose class is Root::Other::Thing and names OtherLib::Node, when the cop runs, then it reports no offense.
  7. Given the same Owners, and a file whose class is Root::Other::Thing and contains the name Lib only in a string, a symbol, and a heredoc, when the cop runs, then it reports no offense.
  8. Given the same Owners, and a file whose class is Elsewhere::Thing and names Lib::Node, when the cop runs, then it reports no offense.
  9. Given the same Owners, and a file that defines module Root; module Other; module Lib; class Node; end; end; end; end and names Lib::Node inside that body, when the cop runs, then it reports no offense.
  10. Given no Owners in the configuration, when the cop runs on a file that names Lib::Node, then it reports no offense.
  11. Given the same Owners, and a file that has require "lib" at file level, before module Root; module Other; class Thing; end; end; end, and no other reference to Lib, when the cop runs, then it reports one offense on the require.
  12. Given the same Owners, and a file that has require "lib" at file level, before module Elsewhere; class Thing; end; end, when the cop runs, then it reports no offense.
  13. Given the same Owners, and a file that has require "lib" at file level, before module Root; module Owner; class Thing; end; end; end, when the cop runs, then it reports no offense.

Owner sketch

class RuboCop::Cop::Sdlc::Dry::LibraryOutsideOwner < RecordingCop
  def on_const(node)
  def on_send(node)
end

Non-goals

  • The move of the 84 files to OsSdlc::CodeShape. That work belongs to #1718 and its child tickets.
  • The move of the cop from the sweep tier to .rubocop.yml.
  • A second library in the map. YAML and Open3 each need their own ruling.
  • A duck-typed copy that does not name the library.

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: lexical name resolution is written 6 times ← each author called the parser library directly, and no check names an owner for it ← DESIGN (ruling 145 in docs/decisions/rulings.tsv names the owner; no cop reads it)
  • Root candidate: none found; searched Prism, CodeShape, SymbolIndex, 1718, OwnedMessage, library owner, Owners, external library, LibraryOutsideOwner, outside its owner, duplicate, resolver
  • Where: plugins/os-sdlc/lib/os_sdlc/cops/dry/
  • 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 for a library named outside its owner; the #1718 children migrate callers and add no cop.

## Problem Agents that do the same job independently call the same external library. Each agent writes its own copy of the job, and the copies are spelled differently, so a clone detector does not find them. The library name is the one sign that the copies share. No cop reports a file that names a library outside the namespace that owns it, so each new copy passes the gate. ## Observed - On 2026-11-12, 58 library files in 6 concepts name `Prism`, and 26 test files parse their input with it. All 84 files were read and are true offenses. The final rule had 0 false hits. - Lexical name resolution is written 6 times. A table of constant definitions is written 6 times. `Prism.parse` and `Prism.parse_file` have 20 call sites in 19 files. The copies do not agree: one copy handles a leading `::` and another copy does not. - Ruling 145 (`docs/decisions/rulings.tsv`) names one owner for this work: `OsSdlc::CodeShape`. - `Sdlc/Dry/OwnedMessage` cannot express the rule. It matches a send by its message name. It cannot match a constant, a superclass, a type check, a pattern, or a `require`. - No value rule separates a library that needs an owner from a library that all code can use (`File`, `Set`, `JSON`). Thus the cop reads a configured map of owner to libraries. A library that is not in the map is not checked. - Full analysis: `docs/research/lint-lessons/2026-11-12-domain-boundary-cops/smell-3-library-owner.md`. ## Reproduce n/a: feature ticket. ## Approach Add the cop `Sdlc/Dry/LibraryOutsideOwner` and its lint lesson. Model the configuration and the "inside the owner" test on `Sdlc/Dry/OwnedMessage` (`lib/os_sdlc/cops/dry/owned_message.rb:9`, `Owners` read at `:32-36`, `inside?` at `:38-42`). Configuration: ```yaml Sdlc/Dry/LibraryOutsideOwner: Enabled: true Owners: OsSdlc::CodeShape: [Prism] ``` The value rule: ``` library = a top constant name in the Owners map (Lib) owner = the namespace that the map gives for that library owner root = the first segment of the owner namespace counted = a constant reference whose outermost segment is the library (Lib, ::Lib, Lib::X), in each position: receiver, superclass, argument of is_a? / kind_of? / grep, `when` and `in` patterns, rescue class counted = require "<snake_case of the library constant>" not counted = a string, a symbol, or a heredoc that contains the name not counted = a reference inside a body that defines a constant with the same name (module Lib ... end) scope = a node whose enclosing namespace starts with the owner root scope = a require at file level, outside each class and module body, has the namespace of the first class or module that the file defines FIRE on a counted reference in scope whose enclosing namespace is not the owner namespace and is not below it ``` Consequences of the rule, with no special case in the code: - A library that is not in the map (`File`, `Set`, `JSON`) is not checked. - Code under a different root namespace (a cop under `RuboCop::Cop`) is out of scope. The cop is silent there. - A test file whose classes are under the owner root is in scope. Constraints: - The `Owners` map changes only when an ownership ruling changes. A new caller never adds an entry to clear a report. Use no `Exclude` list. - The offense message contains no line number. Offense message: ``` `%<reference>s` names %<library>s outside its owner %<owner>s: ask %<owner>s for the answer, or move this code into %<owner>s. ``` Lesson file `lint-lessons/sdlc-dry-library-outside-owner.yml` (fields as in `lib/os_sdlc/lesson.rb:4`; model `lint-lessons/sdlc-dry-owned-message.yml`): ```yaml key: Sdlc/Dry/LibraryOutsideOwner signal: >- Code outside the owner namespace names an external library that the configuration gives to one owner: as a constant, a superclass, a type check, a pattern, a rescue class, or a require. sign_of: >- The same job done again. A copy that is spelled another way is invisible to a clone detector, but the library name shows it. why: >- When one copy is corrected, the other copies still give an answer, but a different one, with no error. A change in the library touches each copy. decision: >- Find the question that this code asks the library. If the owner answers it, ask the owner. If the owner does not answer it, add the answer to the owner. If this code is the job of the owner, move it into the owner namespace. Never add a second owner to the configuration to clear the report. correction: >- Replace the library call or the type check with a call to the owner. Give the owner source text and get back an answer, not a value of the library. In a test, pass source text to the owner or to the unit. When you find no duplicated job 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 Report def owner_of(path, scope, name) tree = Parser.parse_file(path) scope.size.downto(0).map { |n| [*scope.first(n), name].join("::") }.find { |c| known?(tree, c) } end end after: |- class Report def owner_of(path, scope, name) Syntax::LexicalScope.at(path, scope).resolve(name) end end related: [Sdlc/Dry/OwnedMessage] ``` Enable the cop in the sweep tier only (`.rubocop-sweep.yml`, with no `Exclude`). The tree has 84 true offenses, and `OsSdlc::CodeShape` does not hold the moved code yet, so the cop does not go in `.rubocop.yml` in this ticket. The sweep count is the measure of the migration to `OsSdlc::CodeShape`. Required work: 1. Add the lesson file — check: present `Sdlc/Dry/LibraryOutsideOwner` in `plugins/os-sdlc/lint-lessons/sdlc-dry-library-outside-owner.yml` 2. Enable the cop in the sweep configuration with the owner map — check: present `OsSdlc::CodeShape` in `plugins/os-sdlc/.rubocop-sweep.yml` Orchestrator work: none. Rejected: new `Owners` entries in `Sdlc/Dry/OwnedMessage` — it catches 19 of 58 files, and it is a gate cop, so the entries would fail each gate run now. Rejected: today's concepts as configured owners — that is an allowlist with a different name. Rejected: a rule with no map — core classes such as `File` and `Set` would be false hits. ## Acceptance criteria 1. Given `Owners` that maps `Lib` to `Root::Owner`, and a file whose class is `Root::Other::Thing` and names `Lib::Node`, when the cop runs, then it reports one offense on the reference, and the message names `Lib::Node`, `Lib`, and `Root::Owner`. 2. Given the same `Owners`, and a file whose class is `Root::Owner::Thing` and names `Lib::Node`, when the cop runs, then it reports no offense. 3. Given the same `Owners`, and a file whose class is `Root::Other::Thing` and has `require "lib"`, when the cop runs, then it reports one offense on the `require`. 4. Given the same `Owners`, and a file whose class is `Root::Other::Thing`, with `Lib::Visitor` as its superclass, `is_a?(Lib::Node)`, an `in Lib::Node` pattern, and `rescue Lib::Error`, when the cop runs, then it reports one offense for each of the four references. 5. Given the same `Owners`, and a file whose class is `Root::Other::Thing` and names `::Lib::Node`, when the cop runs, then it reports one offense. 6. Given the same `Owners`, and a file whose class is `Root::Other::Thing` and names `OtherLib::Node`, when the cop runs, then it reports no offense. 7. Given the same `Owners`, and a file whose class is `Root::Other::Thing` and contains the name `Lib` only in a string, a symbol, and a heredoc, when the cop runs, then it reports no offense. 8. Given the same `Owners`, and a file whose class is `Elsewhere::Thing` and names `Lib::Node`, when the cop runs, then it reports no offense. 9. Given the same `Owners`, and a file that defines `module Root; module Other; module Lib; class Node; end; end; end; end` and names `Lib::Node` inside that body, when the cop runs, then it reports no offense. 10. Given no `Owners` in the configuration, when the cop runs on a file that names `Lib::Node`, then it reports no offense. 11. Given the same `Owners`, and a file that has `require "lib"` at file level, before `module Root; module Other; class Thing; end; end; end`, and no other reference to `Lib`, when the cop runs, then it reports one offense on the `require`. 12. Given the same `Owners`, and a file that has `require "lib"` at file level, before `module Elsewhere; class Thing; end; end`, when the cop runs, then it reports no offense. 13. Given the same `Owners`, and a file that has `require "lib"` at file level, before `module Root; module Owner; class Thing; end; end; end`, when the cop runs, then it reports no offense. ## Owner sketch ```ruby class RuboCop::Cop::Sdlc::Dry::LibraryOutsideOwner < RecordingCop def on_const(node) def on_send(node) end ``` ## Non-goals - The move of the 84 files to `OsSdlc::CodeShape`. That work belongs to #1718 and its child tickets. - The move of the cop from the sweep tier to `.rubocop.yml`. - A second library in the map. `YAML` and `Open3` each need their own ruling. - A duck-typed copy that does not name the library. ## 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: lexical name resolution is written 6 times ← each author called the parser library directly, and no check names an owner for it ← DESIGN (ruling 145 in `docs/decisions/rulings.tsv` names the owner; no cop reads it) - Root candidate: none found; searched Prism, CodeShape, SymbolIndex, 1718, OwnedMessage, library owner, Owners, external library, LibraryOutsideOwner, outside its owner, duplicate, resolver - Where: plugins/os-sdlc/lib/os_sdlc/cops/dry/ - 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 for a library named outside its owner; the #1718 children migrate callers and add no cop.
Author
Owner

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

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

Check of the cop on the real tree (branch ticket-1744): 224 offenses in 80 files. The cop missed 4 library files that have only a file-level require of the library (crc/census/requirers.rb, detector/constructor_graph.rb, system_model/extractor/body.rb, system_model/extractor/process_exit.rb). ticket-skeptic verdict on a follow-up ticket: DROP, Better-seam test: this is a defect in the #1744 deliverable; fix it inside #1744. Acceptance criteria 11 to 13 and one scope line are added to the body. A second implementation run on the same branch builds them.

Check of the cop on the real tree (branch ticket-1744): 224 offenses in 80 files. The cop missed 4 library files that have only a file-level require of the library (crc/census/requirers.rb, detector/constructor_graph.rb, system_model/extractor/body.rb, system_model/extractor/process_exit.rb). ticket-skeptic verdict on a follow-up ticket: DROP, Better-seam test: this is a defect in the #1744 deliverable; fix it inside #1744. Acceptance criteria 11 to 13 and one scope line are added to the body. A second implementation run on the same branch builds them.
Author
Owner

Resolution

Done: Cop Sdlc/Dry/LibraryOutsideOwner, its lint lesson (lint-lessons/sdlc-dry-library-outside-owner.yml), and its sweep configuration with the owner map OsSdlc::CodeShape: [Prism]. The cop is in the sweep tier only. Acceptance criteria 1 to 13 are built in two runs (implementations 287 and 288).

Evidence: Branch ticket-1744, commits 16558bafe and the run 288 commit. Both runs ended implementation_complete with suite and survivor gates green. Check on the real tree: the sweep reports 84 files (58 lib, 26 test), the same set as the smell analysis probe; 0 files under lib/os_sdlc/cops; the gate configuration (.rubocop.yml) reports no offense.

Follow-ups: 1. File-level require miss: ticket-skeptic verdict DROP (Better-seam test); built inside #1744 as acceptance criteria 11 to 13. 2. Fifth copy of the enclosing-namespace rule (contract-auditor LENS A12): ticket-skeptic verdict OCCURRENCE 1734; recorded as a comment on #1734. 3. The move of the cop to .rubocop.yml after the sweep count is 0: dropped as a ticket now; it depends on the #1718 migration and is listed in the session handoff.

## Resolution **Done:** Cop Sdlc/Dry/LibraryOutsideOwner, its lint lesson (lint-lessons/sdlc-dry-library-outside-owner.yml), and its sweep configuration with the owner map OsSdlc::CodeShape: [Prism]. The cop is in the sweep tier only. Acceptance criteria 1 to 13 are built in two runs (implementations 287 and 288). **Evidence:** Branch ticket-1744, commits 16558bafe and the run 288 commit. Both runs ended implementation_complete with suite and survivor gates green. Check on the real tree: the sweep reports 84 files (58 lib, 26 test), the same set as the smell analysis probe; 0 files under lib/os_sdlc/cops; the gate configuration (.rubocop.yml) reports no offense. **Follow-ups:** 1. File-level require miss: ticket-skeptic verdict DROP (Better-seam test); built inside #1744 as acceptance criteria 11 to 13. 2. Fifth copy of the enclosing-namespace rule (contract-auditor LENS A12): ticket-skeptic verdict OCCURRENCE 1734; recorded as a comment on #1734. 3. The move of the cop to .rubocop.yml after the sweep count is 0: dropped as a ticket now; it depends on the #1718 migration and is listed in the session handoff. <!-- os-backlog kind: resolution -->
jared closed this issue 2026-11-12 22:11:15 +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#1744
No description provided.