os-sdlc cop Sdlc/Structural/PrecedenceInEdge: an edge picks which of two sources wins for one fact (A19 R1) #1509

Closed
opened 2026-11-02 21:48:48 +00:00 by jared · 2 comments
Owner

Problem

An edge object (an adapter or an entry) reads two sources that hold one fact, and its code fixes which source wins. "Configured beats inferred" is a rule of ours, not of the resource. When the rule is inside the adapter, a test of the rule needs a temporary directory, a config file, and a fake repository. No such test exists today. If you swap the order at plugins/os-sdlc/lib/os_sdlc/tracker/issue_source/tracker_lookup.rb:42, all 9 tracker tests stay green (smell analysis, step 4).

Evidence: docs/system-design-format/proof/2026-11-02-smell-analysis-decision-in-adapter.md (rule R1, section 7, cop P) and docs/system-design-format/proof/2026-11-02-detector-a19/facts/rule-v3-notes.md (clauses P1, P2). On 147 labeled sites, the v3 data-flow definition of P fires on 6 TRUE sites and 0 FALSE sites. With the fact fixes in rule-v3-notes, it fires on 8.

No cop finds this shape. Sdlc/Structural/EffectInModel is the nearest cop, and it is disabled in plugins/os-sdlc/.rubocop.yml (#1507). It gets its role from LayerLedger. With no LedgerPath, it reports nothing.

This is the first of three cops. They share one lesson core, and there is one cop for each origin of a domain rule: P (order, R1), O (operand, R2), E (effect, R3). Build P first. P works alone.

Reproduce

n/a: new cop, nothing to reproduce

Approach

Add RuboCop::Cop::Sdlc::Structural::PrecedenceInEdge < RecordingCop.

Site role (same text in the P, O, and E tickets). The cop reports only in an edge, and it gets the role from the code, not from a ledger:

  • adapter: the nearest enclosing class or module body holds at least one call that EffectInModel::EffectCall#label names. For code outside all classes, use the file's top level. The cop gives EffectCall its own EffectConstants default: File Dir IO FileUtils YAML JSON Open3 Process Kernel. This is the EffectCall default plus FileUtils.
  • entry: EntryPoint.file?(path) is true. That is, the first line starts with #!, or the file is in a folder named bin.
  • In an entry, if a test's values come only from the enclosing method's parameters, ARGV, or literals, and its skipped path calls no effect, the test is input, not a decision (A19 NOT).

No ledger. The cop does not read LayerLedger. Existing behavior: EffectInModel reports nothing when LedgerPath is not set (#1507). When LedgerPath names a missing file, LayerLedger::Ledger#role_by_const raises RuboCop::Error. The ledger cannot give roles for this cop, because LayerMap.cpath_by_path maps only files under os-sdlc's own lib/ through OsSdlc.loader. It cannot map os-me, os-backlog, bin/ scripts, or a foreign project. Because the role comes from the code, the cop has no "not configured" state. Thus it never does nothing silently.

ADR constraints.

  • ADR-0298: register the cop only in plugins/os-sdlc/.rubocop.yml. os-sdlc layers this config at run time and installs nothing into target projects.
  • ADR-0082: Sdlc cops apply to every project through the layered config. The cop must give correct results on foreign codebases with no ledger and no per-project setup. The code-derived role above satisfies this.
  • ADR-0090: Zeitwerk. File names must match constant paths, for example cops/structural/precedence_in_edge.rb holds Sdlc::Structural::PrecedenceInEdge.

Value rule (R1, order). A site is one of three choice shapes:

  1. a || b used as a value, not as a condition, where neither operand is a predicate (a ? method or a comparison).
  2. A ternary whose condition tests the presence of one of its own candidates: c ? c : d, c.nil?, c.empty?, c.to_s.empty?, !c, or x.key?(:k) when the candidate is x[:k].
  3. A guard return v if/unless <presence test> with more statements after it in the same method.

The cop reports the site when one of these clauses holds:

  • P-a (two stored slots). Both candidates are literal-key slot reads (x["k"], x[:k], x.fetch("k"), .to_s allowed). They read either one key from two receivers or two keys from one receiver. One candidate can also be an interpolated string whose every interpolation is a literal-key slot read (a value built now from other stored fields).
  • P-b (held value against a read). In shape 1 or 2, each candidate is a same-file method call, a message to an ivar (@project.tracker), or a literal-key slot read of an ivar. At least one candidate reaches an effect call through same-file methods. Neither candidate is a literal, an empty collection, a constant, raise/fail, a bare local or parameter, or a value from a method parameter. The candidates do not share a reader (see "lookup" below).
  • P-guard (held value skips detection). In shape 3, the guard returns a value whose presence it tests: the value itself if it is a literal-key slot read, or a variable whose literal-key slot the condition tests (flat.key?(:test_path)). The statements after the guard reach an effect call through same-file methods.

Lookup (translation, never reported). The two candidates share a reader when they call one same-file method with different arguments, reach one EffectCall label (for example both Dir.glob), or send messages to one project class (Locator.new(x).m, Locator.m). Then they are lookups for one key in two places. File.read and File.write are different labels, so they do not share a reader.

The facts that existing helpers do not compute are the new objects in the owner sketch: EdgeScope, EffectReach, Candidate, and Choice. The cop uses QueryMethodReachesWrite::SameFileDefs to resolve same-file calls. It records clause: (p_a, p_b, p_guard) and role: (adapter, entry) through RecordingCop#report.

Required work:

  1. Add the cop class RuboCop::Cop::Sdlc::Structural::PrecedenceInEdge < RecordingCop — check: present class PrecedenceInEdge in plugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge.rb
  2. Add EdgeScope, shared with O and E — check: present class EdgeScope in plugins/os-sdlc/lib/os_sdlc/cops/structural/edge_scope.rb
  3. Add EffectReach, shared — check: present class EffectReach in plugins/os-sdlc/lib/os_sdlc/cops/structural/effect_reach.rb
  4. Add PrecedenceInEdge::Candidate — check: present class Candidate in plugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge/candidate.rb
  5. Add PrecedenceInEdge::Choice — check: present class Choice in plugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge/choice.rb
  6. Register the cop with no Exclude key — check: present Sdlc/Structural/PrecedenceInEdge: in plugins/os-sdlc/.rubocop.yml
  7. Add the cop test with the must-fire and must-pass fixtures below — check: present PrecedenceInEdge in plugins/os-sdlc/tests/cops/precedence_in_edge_test.rb
  8. Add the lesson YAML with the text in the Lesson subsection — check: present key: Sdlc/Structural/PrecedenceInEdge in plugins/os-sdlc/lint-lessons/sdlc-structural-precedence-in-edge.yml

Orchestrator work: Whole-tree run: run the cop alone with the os-sdlc config over every Ruby file under plugins/, including bin/ scripts and the os-me and os-backlog plugins; put a table in the PR with one row for each hit (site, clause, TRUE or FALSE, and the reason by the swap test); the run must have zero false hits; for each false hit, either make the value rule tighter and add the hit as a must-pass fixture, or stop and file a ticket with /os-backlog:capture; also list each must-fire site that the real-tree run does not report, with the fact that blocks it. This is the ticket's whole-tree acceptance gate.

Lesson

key: Sdlc/Structural/PrecedenceInEdge
signal: >-
  An edge holds one fact from two sources (a stored value and an inferred one, two stored slots, or a
  stored value and the caller's value), and its code fixes which one wins.
sign_of: >-
  A precedence rule of the domain inside the object that reads the resource: "configured beats
  inferred", "the new field beats the old field".
why: >-
  Lens A19: a domain rule inside an edge needs the resource to run, so its test cannot use literal
  inputs. Lens principle 9: "Code that decides takes values and returns values." Source: Gary
  Bernhardt, "Functional Core, Imperative Shell" (2012). An edit that inverts the rule then fails no test.
decision: >-
  Replace the resource with another one that holds the same facts. If the rule survives, it is a
  domain rule. Here, ask: if the config moved to a database and the detection moved to an API,
  would the same source still win? If yes, the order is ours. If you find no smell at this site
  (the candidates are lookups for one key the caller gave, or the last one is a default or an
  error), file a false-positive ticket with the site and one must-fire fixture from this cop's
  tests. Do not change the code to silence the cop.
correction: >-
  The adapter returns the values it read, in the resource's terms. A decision object, or the value
  that owns the data, picks. Test the rule with literal inputs.
example:
  before: |-
    class ChannelLookup
      def channel = configured_channel || detected_channel

      private

      def configured_channel = File.exist?(@config) ? File.read(@config).strip : nil
      def detected_channel = Dir.glob(File.join(@root, "channels", "*")).first
    end
  after: |-
    ChannelSources = Data.define(:configured, :detected) do
      def current = configured || detected
    end

    class ChannelLookup
      def sources = ChannelSources.new(configured: configured_channel, detected: detected_channel)
    end

Shared lesson core. The O and E tickets copy these three items word for word:

  • why: the full why value above.
  • decision, opening: "Replace the resource with another one that holds the same facts. If the rule survives, it is a domain rule."
  • correction: the full correction value above.

Acceptance criteria

  1. Given the repository after the change, when eager load runs, then Sdlc/Structural/PrecedenceInEdge is registered from plugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge.rb as RuboCop::Cop::Sdlc::Structural::PrecedenceInEdge < RecordingCop and loading passes (ADR-0090).
  2. Given any fixture that makes the cop report, when it reports, then the offense message is exactly An edge picks which of two sources wins for one fact. Return both values in the resource's terms; a decision object or the value that owns the data picks. and the test asserts it verbatim.
  3. Given plugins/os-sdlc/.rubocop.yml, when it is read, then it has Sdlc/Structural/PrecedenceInEdge: Enabled: true with no Exclude key (ADR-0298).
  4. Given plugins/os-sdlc/tests/cops/precedence_in_edge_test.rb, when it runs, then every fixture goes through CopHelpers#offenses_for.
  5. Given each of the 8 must-fire fixtures FIRE-1 to FIRE-8 listed below, when the cop runs, then it reports exactly one offense.
  6. Given each of the 35 must-pass fixtures PASS-1 to PASS-35 listed below, when the cop runs, then it reports no offense.
  7. Given a fixture that the cop wrongly reports, when the cop is made tighter, then the change is to the value rule and no Exclude list, disable comment, or site list is added.
  8. Given plugins/os-sdlc/lint-lessons/sdlc-structural-precedence-in-edge.yml, when it loads, then it holds the text in the Lesson subsection and the why value, the first two sentences of decision, and the correction value are byte-identical to the shared core.
  9. Given the full suite and the os-sdlc rubocop config, when they run on the new files, then the suite is green and the new files pass.

The cop covers the whole R1 shape set above, not only the sample sites. In all fixtures, "adapter class" means that the class body also holds def raw = File.read(@path). "Entry" means that the fixture path is bin/tool.

Must-fire (8). These are all the R1 TRUE sites that the v3 definition catches, including the two that fire after the fact fixes:

  • FIRE-1: F045 crc/progress_table.rb:101
    def baseline = stored || snapshot
    def stored = (JSON.parse(File.read(@base)) if File.exist?(@base))
    def snapshot
      File.write(@base, JSON.generate(now))
      now
    end
    def now = Dir.glob("#{@dir}/*.tsv").size
    
  • FIRE-2: F055 project/config/document.rb:66: def tracker(name) = section(name)["tracker"] || @data["tracker"]
  • FIRE-3: F057 tracker/issue_source/tracker_lookup.rb:28
    def call = @project.tracker || persisted
    def persisted = resolved.tap { |t| @project.save(t) if t }
    def resolved = File.exist?(@config) ? File.read(@config)[/tracker=(.+)/, 1] : nil
    
  • FIRE-4: F058 tracker/issue_source/tracker_lookup.rb:42
    def resolve = from_config || from_remote
    def from_config = File.readlines(@config).grep(/tracker=/).first
    def from_remote = @repo.remote_url("origin")
    
  • FIRE-5: F112 cops/structural/layer_ledger/ledger.rb:25: def constant(row) = row["new_constant"].to_s.empty? ? row["constant"].to_s : row["new_constant"]
  • FIRE-6: F119 project/config_command.rb:29
    def with_test_path(options)
      return options if options[:test_path]
      found = %w[test spec].find { |dir| File.directory?(File.join(@cwd, dir)) }
      found ? options.merge(test_path: found) : options
    end
    
  • FIRE-7: F118 project/config/document.rb:76
    def with_default(flat)
      return flat if flat.key?(:test_path) || @root.nil?
      default = default_path
      default ? flat.merge(test_path: default) : flat
    end
    def default_path = %w[test spec].find { |dir| File.directory?(File.join(@root, dir)) }
    
  • FIRE-8: F122 os-backlog/bin/wakeup-poll:118. This fixture is an entry.
    issues.each do |issue|
      puts(issue["url"].empty? ? "#{project[:kind]}:#{project[:slug]}##{issue['index']}" : issue["url"])
    end
    

Must-pass (35). This list has every FALSE site that has a P syntax shape, and the role cases:

  • PASS-1: F053, F054: def nodes = data["nodes"] || {}
  • PASS-2: F047: hit.dig("range", "start") || {}
  • PASS-3: F049, F052: (index_of(line) || 0) + 1
  • PASS-4: F046, F048: def socket_or_fail = socket || raise(Unavailable), where socket calls File.open
  • PASS-5: F127: { title: @options[:title] || fail_soft("requires --title") }, where def fail_soft(m) = Exit.fail_soft(m)
  • PASS-6: F128: def locate = direct || (owning_root&.then { |r| Locator.new(r).path_if_exists }), where direct = Locator.new(@start).path_if_exists and owning_root calls File.file?
  • PASS-7: F129: def locate_for_write = direct || Locator.new(owning_root || @start).path
  • PASS-8: F022: hits = matches(CLASS_RE, path) followed by hits = matches(MODULE_RE, path) if hits.empty?, where matches calls Open3.capture3
  • PASS-9: F003: lib_root ? Dir.glob(File.join(lib_root, "**/*.rb")) : Dir.glob(File.join(@dir, "**/*.rb"))
  • PASS-10: F051: run(root: config_root(root) || dirs.first), where dirs = Array(project_dir) and config_root calls File.exist?
  • PASS-11: F056: def repo(slug, client) = (client || default_client).repo(slug), where default_client calls File.read(TOKEN)
  • PASS-12: F126: def body(positional) = option_body || positional, where option_body calls File.read(@options[:body_file])
  • PASS-13: F036
    def with_detected(options)
      candidate = %w[test spec].find { |d| File.directory?(d) }
      return options unless candidate
      options.merge(test_path: candidate)
    end
    
  • PASS-14: F037: default = %w[test spec].find { |d| File.directory?(d) } followed by default ? flat.merge(test_path: default) : flat
  • PASS-15: F042: return Null.new unless check.configured? followed by Live.new(File.read(@path))
  • PASS-16: F011: problems = packing.oversized(groups) followed by problems.empty? ? write(packing.call(groups)) : refused(problems)
  • PASS-17: F029 (entry): def run(argv) = (status = Report.run(@path, argv)).nil? ? lint(argv) : status, and also its guard form return status unless status.nil?
  • PASS-18: F043: found = paths.find { |p| File.exist?(p) } followed by return found if found and raise NotFound
  • PASS-19: F034, F038: found = Dir.glob(pattern) followed by return found.first if found.one?
  • PASS-20: F021: path = file_path(token) followed by return { units: units_in(path) } if path
  • PASS-21: F030: return checkpointed(mode) if mode followed by plain_run
  • PASS-22: F018, F040: return [] if source.empty? followed by source.lines
  • PASS-23: F141: row["id"] && row["session_id"] ? [row["id"], row["session_id"]] : Object.new
  • PASS-24: F143: rows.filter_map { |row| row["session_id"] if row["verdict"] }
  • PASS-25: F113: base = name == :require ? test_dir : File.dirname(@file)
  • PASS-26: F017: numbered.empty? ? "No code line." : "```ruby\n#{numbered.join("\n")}\n```"
  • PASS-27: F130 (entry): def call(argv) that sets number = argv.first and result = sweep(number), then puts result.report.empty? ? "##{number}: passed" : result.report
  • PASS-28: F004: tests.empty? ? NONE : tests.join(",")
  • PASS-29: F020, F023, F024: out, status = Open3.capture2(cmd) followed by status.success? ? JSON.parse(out) : []
  • PASS-30: F012: File.symlink?(path) ? [:skipped, entry("symlink")] : verdict(File.binread(path))
  • PASS-31: F039: path.include?("/hooks/") ? "hook" : "bin"
  • PASS-32: F059, F060, F062, F063, F064, F065: known.fetch(entry, 0), governed.fetch(id, []), prior.fetch(name, "-"), data&.fetch("paths", nil), analyses.fetch(sub) { -> { report(sub) } }
  • PASS-33: F009: next resolution unless resolution.status == :missing
  • PASS-34: Role, pure: the F058 fixture in a class whose body holds no effect call
  • PASS-35: Role, entry input (from the entry-scope rows F135 and F136 in the rule-v3 load-bearing table), with a fixture that is an entry: def call(argv) = argv[0].to_s.empty? ? argv[1] : argv[0]

Owner sketch

Object File Owns
PrecedenceInEdge cops/structural/precedence_in_edge.rb Visits or, ternary if, and guard return nodes. Asks EdgeScope and Choice. Calls report with clause: and role:.
EdgeScope (new, shared with O and E) cops/structural/edge_scope.rb Gets the role of a node from its enclosing scope: adapter, entry, or none. Uses EffectInModel::EffectCall and EntryPoint. Also holds the entry-input test.
EffectReach (new, shared) cops/structural/effect_reach.rb From an expression and SameFileDefs, finds the reader identities it reaches: EffectCall labels, plus project classes reached by Const.new(...).m or Const.m. Uses a breadth-first walk, as WriteChain does.
PrecedenceInEdge::Candidate precedence_in_edge/candidate.rb Gets the kind of one candidate: literal, error, bare, param, slot, held, or slot_built.
PrecedenceInEdge::Choice precedence_in_edge/choice.rb Holds the two candidates, or the guard and its continuation. Gives the clause (p_a, p_b, p_guard) or nil.

Non-goals

The cop does not find these shapes (from smell analysis section 7 and the rule-v3 misses):

  • Precedence by Hash#merge order, for example sliced(@data).merge(sliced(section)) (project/config/document.rb:65).
  • A source behind another class or more than two hops away.
  • F044 @expected || definitions.primary(...). This needs a fact that tells a collaborator object from a data value. F056 client || self.client (FALSE) has the same shape.
  • F061 renames.fetch(path, path) (TRUE). This needs the fact that renames holds stored ledger fields. The brief named fetch(key, key) as a FALSE shape, but the only labeled instance, F061, is TRUE. Thus it is neither a must-fire nor a must-pass fixture here.
  • The reassignment form x = a followed by x = b if x.empty? (F022 shape), and @x ||= ... memos.
  • Reads through ENV, Sequel, or a network client. These are not EffectCall labels.
  • Verdict choices (F010, F026, F050, F114) belong to cop E. Comparisons with our operands belong to cop O.
  • No autocorrect: the fix needs a judgment about where the decision object goes (tier c).

Origin

  • Trigger: 2026-11-02 data-flow fact rounds over 147 labeled decision sites; smell analysis docs/system-design-format/proof/2026-11-02-smell-analysis-decision-in-adapter.md
  • Improvised this session: none
  • Chain: syntax detector overfit (15% precision on unseen code) ← A19 line is drawn on data origin, not syntax ← DESIGN docs/system-design-format/proof/2026-11-02-smell-analysis-decision-in-adapter.md
  • Root candidate: none found; searched PrecedenceInEdge, EdgeScope, EffectReach, EffectInModel, A19, "configured beats inferred", "two sources", tracker_lookup, io_reach, EntryPoint, LayerLedger
  • Where: plugins/os-sdlc/lib/os_sdlc/system_model/class_facts/io_reach.rb
  • Session: 394c0068-e8aa-449e-95d1-dad679bda47d
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/394c0068-e8aa-449e-95d1-dad679bda47d.jsonl

VERDICT: CREATE — no open ticket proposes a precedence-in-edge cop; nearest #1507 and #1040 are different faults.

## Problem An edge object (an adapter or an entry) reads two sources that hold one fact, and its code fixes which source wins. "Configured beats inferred" is a rule of ours, not of the resource. When the rule is inside the adapter, a test of the rule needs a temporary directory, a config file, and a fake repository. No such test exists today. If you swap the order at `plugins/os-sdlc/lib/os_sdlc/tracker/issue_source/tracker_lookup.rb:42`, all 9 tracker tests stay green (smell analysis, step 4). Evidence: `docs/system-design-format/proof/2026-11-02-smell-analysis-decision-in-adapter.md` (rule R1, section 7, cop P) and `docs/system-design-format/proof/2026-11-02-detector-a19/facts/rule-v3-notes.md` (clauses P1, P2). On 147 labeled sites, the v3 data-flow definition of P fires on 6 TRUE sites and 0 FALSE sites. With the fact fixes in rule-v3-notes, it fires on 8. No cop finds this shape. `Sdlc/Structural/EffectInModel` is the nearest cop, and it is disabled in `plugins/os-sdlc/.rubocop.yml` (#1507). It gets its role from `LayerLedger`. With no `LedgerPath`, it reports nothing. This is the first of three cops. They share one lesson core, and there is one cop for each origin of a domain rule: P (order, R1), O (operand, R2), E (effect, R3). Build P first. P works alone. ## Reproduce n/a: new cop, nothing to reproduce ## Approach Add `RuboCop::Cop::Sdlc::Structural::PrecedenceInEdge < RecordingCop`. **Site role (same text in the P, O, and E tickets).** The cop reports only in an edge, and it gets the role from the code, not from a ledger: - adapter: the nearest enclosing `class` or `module` body holds at least one call that `EffectInModel::EffectCall#label` names. For code outside all classes, use the file's top level. The cop gives `EffectCall` its own `EffectConstants` default: `File Dir IO FileUtils YAML JSON Open3 Process Kernel`. This is the `EffectCall` default plus `FileUtils`. - entry: `EntryPoint.file?(path)` is true. That is, the first line starts with `#!`, or the file is in a folder named `bin`. - In an entry, if a test's values come only from the enclosing method's parameters, `ARGV`, or literals, and its skipped path calls no effect, the test is input, not a decision (A19 NOT). **No ledger.** The cop does not read `LayerLedger`. Existing behavior: `EffectInModel` reports nothing when `LedgerPath` is not set (#1507). When `LedgerPath` names a missing file, `LayerLedger::Ledger#role_by_const` raises `RuboCop::Error`. The ledger cannot give roles for this cop, because `LayerMap.cpath_by_path` maps only files under os-sdlc's own `lib/` through `OsSdlc.loader`. It cannot map os-me, os-backlog, `bin/` scripts, or a foreign project. Because the role comes from the code, the cop has no "not configured" state. Thus it never does nothing silently. **ADR constraints.** - ADR-0298: register the cop only in `plugins/os-sdlc/.rubocop.yml`. os-sdlc layers this config at run time and installs nothing into target projects. - ADR-0082: Sdlc cops apply to every project through the layered config. The cop must give correct results on foreign codebases with no ledger and no per-project setup. The code-derived role above satisfies this. - ADR-0090: Zeitwerk. File names must match constant paths, for example `cops/structural/precedence_in_edge.rb` holds `Sdlc::Structural::PrecedenceInEdge`. **Value rule (R1, order).** A site is one of three choice shapes: 1. `a || b` used as a value, not as a condition, where neither operand is a predicate (a `?` method or a comparison). 2. A ternary whose condition tests the presence of one of its own candidates: `c ? c : d`, `c.nil?`, `c.empty?`, `c.to_s.empty?`, `!c`, or `x.key?(:k)` when the candidate is `x[:k]`. 3. A guard `return v if/unless <presence test>` with more statements after it in the same method. The cop reports the site when one of these clauses holds: - **P-a (two stored slots).** Both candidates are literal-key slot reads (`x["k"]`, `x[:k]`, `x.fetch("k")`, `.to_s` allowed). They read either one key from two receivers or two keys from one receiver. One candidate can also be an interpolated string whose every interpolation is a literal-key slot read (a value built now from other stored fields). - **P-b (held value against a read).** In shape 1 or 2, each candidate is a same-file method call, a message to an ivar (`@project.tracker`), or a literal-key slot read of an ivar. At least one candidate reaches an effect call through same-file methods. Neither candidate is a literal, an empty collection, a constant, `raise`/`fail`, a bare local or parameter, or a value from a method parameter. The candidates do not share a reader (see "lookup" below). - **P-guard (held value skips detection).** In shape 3, the guard returns a value whose presence it tests: the value itself if it is a literal-key slot read, or a variable whose literal-key slot the condition tests (`flat.key?(:test_path)`). The statements after the guard reach an effect call through same-file methods. **Lookup (translation, never reported).** The two candidates share a reader when they call one same-file method with different arguments, reach one `EffectCall` label (for example both `Dir.glob`), or send messages to one project class (`Locator.new(x).m`, `Locator.m`). Then they are lookups for one key in two places. `File.read` and `File.write` are different labels, so they do not share a reader. The facts that existing helpers do not compute are the new objects in the owner sketch: `EdgeScope`, `EffectReach`, `Candidate`, and `Choice`. The cop uses `QueryMethodReachesWrite::SameFileDefs` to resolve same-file calls. It records `clause:` (`p_a`, `p_b`, `p_guard`) and `role:` (`adapter`, `entry`) through `RecordingCop#report`. Required work: 1. Add the cop class `RuboCop::Cop::Sdlc::Structural::PrecedenceInEdge < RecordingCop` — check: present `class PrecedenceInEdge` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge.rb` 2. Add `EdgeScope`, shared with O and E — check: present `class EdgeScope` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/edge_scope.rb` 3. Add `EffectReach`, shared — check: present `class EffectReach` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/effect_reach.rb` 4. Add `PrecedenceInEdge::Candidate` — check: present `class Candidate` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge/candidate.rb` 5. Add `PrecedenceInEdge::Choice` — check: present `class Choice` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge/choice.rb` 6. Register the cop with no `Exclude` key — check: present `Sdlc/Structural/PrecedenceInEdge:` in `plugins/os-sdlc/.rubocop.yml` 7. Add the cop test with the must-fire and must-pass fixtures below — check: present `PrecedenceInEdge` in `plugins/os-sdlc/tests/cops/precedence_in_edge_test.rb` 8. Add the lesson YAML with the text in the Lesson subsection — check: present `key: Sdlc/Structural/PrecedenceInEdge` in `plugins/os-sdlc/lint-lessons/sdlc-structural-precedence-in-edge.yml` Orchestrator work: Whole-tree run: run the cop alone with the os-sdlc config over every Ruby file under `plugins/`, including `bin/` scripts and the os-me and os-backlog plugins; put a table in the PR with one row for each hit (site, clause, TRUE or FALSE, and the reason by the swap test); the run must have zero false hits; for each false hit, either make the value rule tighter and add the hit as a must-pass fixture, or stop and file a ticket with `/os-backlog:capture`; also list each must-fire site that the real-tree run does not report, with the fact that blocks it. This is the ticket's whole-tree acceptance gate. ### Lesson ```yaml key: Sdlc/Structural/PrecedenceInEdge signal: >- An edge holds one fact from two sources (a stored value and an inferred one, two stored slots, or a stored value and the caller's value), and its code fixes which one wins. sign_of: >- A precedence rule of the domain inside the object that reads the resource: "configured beats inferred", "the new field beats the old field". why: >- Lens A19: a domain rule inside an edge needs the resource to run, so its test cannot use literal inputs. Lens principle 9: "Code that decides takes values and returns values." Source: Gary Bernhardt, "Functional Core, Imperative Shell" (2012). An edit that inverts the rule then fails no test. decision: >- Replace the resource with another one that holds the same facts. If the rule survives, it is a domain rule. Here, ask: if the config moved to a database and the detection moved to an API, would the same source still win? If yes, the order is ours. If you find no smell at this site (the candidates are lookups for one key the caller gave, or the last one is a default or an error), file a false-positive ticket with the site and one must-fire fixture from this cop's tests. Do not change the code to silence the cop. correction: >- The adapter returns the values it read, in the resource's terms. A decision object, or the value that owns the data, picks. Test the rule with literal inputs. example: before: |- class ChannelLookup def channel = configured_channel || detected_channel private def configured_channel = File.exist?(@config) ? File.read(@config).strip : nil def detected_channel = Dir.glob(File.join(@root, "channels", "*")).first end after: |- ChannelSources = Data.define(:configured, :detected) do def current = configured || detected end class ChannelLookup def sources = ChannelSources.new(configured: configured_channel, detected: detected_channel) end ``` **Shared lesson core.** The O and E tickets copy these three items word for word: - `why`: the full `why` value above. - `decision`, opening: "Replace the resource with another one that holds the same facts. If the rule survives, it is a domain rule." - `correction`: the full `correction` value above. ## Acceptance criteria 1. Given the repository after the change, when eager load runs, then `Sdlc/Structural/PrecedenceInEdge` is registered from `plugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge.rb` as `RuboCop::Cop::Sdlc::Structural::PrecedenceInEdge < RecordingCop` and loading passes (ADR-0090). 2. Given any fixture that makes the cop report, when it reports, then the offense message is exactly `An edge picks which of two sources wins for one fact. Return both values in the resource's terms; a decision object or the value that owns the data picks.` and the test asserts it verbatim. 3. Given `plugins/os-sdlc/.rubocop.yml`, when it is read, then it has `Sdlc/Structural/PrecedenceInEdge: Enabled: true` with no `Exclude` key (ADR-0298). 4. Given `plugins/os-sdlc/tests/cops/precedence_in_edge_test.rb`, when it runs, then every fixture goes through `CopHelpers#offenses_for`. 5. Given each of the 8 must-fire fixtures FIRE-1 to FIRE-8 listed below, when the cop runs, then it reports exactly one offense. 6. Given each of the 35 must-pass fixtures PASS-1 to PASS-35 listed below, when the cop runs, then it reports no offense. 7. Given a fixture that the cop wrongly reports, when the cop is made tighter, then the change is to the value rule and no `Exclude` list, disable comment, or site list is added. 8. Given `plugins/os-sdlc/lint-lessons/sdlc-structural-precedence-in-edge.yml`, when it loads, then it holds the text in the Lesson subsection and the `why` value, the first two sentences of `decision`, and the `correction` value are byte-identical to the shared core. 9. Given the full suite and the os-sdlc rubocop config, when they run on the new files, then the suite is green and the new files pass. The cop covers the whole R1 shape set above, not only the sample sites. In all fixtures, "adapter class" means that the class body also holds `def raw = File.read(@path)`. "Entry" means that the fixture path is `bin/tool`. **Must-fire (8). These are all the R1 TRUE sites that the v3 definition catches, including the two that fire after the fact fixes:** - FIRE-1: F045 `crc/progress_table.rb:101` ```ruby def baseline = stored || snapshot def stored = (JSON.parse(File.read(@base)) if File.exist?(@base)) def snapshot File.write(@base, JSON.generate(now)) now end def now = Dir.glob("#{@dir}/*.tsv").size ``` - FIRE-2: F055 `project/config/document.rb:66`: `def tracker(name) = section(name)["tracker"] || @data["tracker"]` - FIRE-3: F057 `tracker/issue_source/tracker_lookup.rb:28` ```ruby def call = @project.tracker || persisted def persisted = resolved.tap { |t| @project.save(t) if t } def resolved = File.exist?(@config) ? File.read(@config)[/tracker=(.+)/, 1] : nil ``` - FIRE-4: F058 `tracker/issue_source/tracker_lookup.rb:42` ```ruby def resolve = from_config || from_remote def from_config = File.readlines(@config).grep(/tracker=/).first def from_remote = @repo.remote_url("origin") ``` - FIRE-5: F112 `cops/structural/layer_ledger/ledger.rb:25`: `def constant(row) = row["new_constant"].to_s.empty? ? row["constant"].to_s : row["new_constant"]` - FIRE-6: F119 `project/config_command.rb:29` ```ruby def with_test_path(options) return options if options[:test_path] found = %w[test spec].find { |dir| File.directory?(File.join(@cwd, dir)) } found ? options.merge(test_path: found) : options end ``` - FIRE-7: F118 `project/config/document.rb:76` ```ruby def with_default(flat) return flat if flat.key?(:test_path) || @root.nil? default = default_path default ? flat.merge(test_path: default) : flat end def default_path = %w[test spec].find { |dir| File.directory?(File.join(@root, dir)) } ``` - FIRE-8: F122 `os-backlog/bin/wakeup-poll:118`. This fixture is an entry. ```ruby issues.each do |issue| puts(issue["url"].empty? ? "#{project[:kind]}:#{project[:slug]}##{issue['index']}" : issue["url"]) end ``` **Must-pass (35). This list has every FALSE site that has a P syntax shape, and the role cases:** - PASS-1: F053, F054: `def nodes = data["nodes"] || {}` - PASS-2: F047: `hit.dig("range", "start") || {}` - PASS-3: F049, F052: `(index_of(line) || 0) + 1` - PASS-4: F046, F048: `def socket_or_fail = socket || raise(Unavailable)`, where `socket` calls `File.open` - PASS-5: F127: `{ title: @options[:title] || fail_soft("requires --title") }`, where `def fail_soft(m) = Exit.fail_soft(m)` - PASS-6: F128: `def locate = direct || (owning_root&.then { |r| Locator.new(r).path_if_exists })`, where `direct = Locator.new(@start).path_if_exists` and `owning_root` calls `File.file?` - PASS-7: F129: `def locate_for_write = direct || Locator.new(owning_root || @start).path` - PASS-8: F022: `hits = matches(CLASS_RE, path)` followed by `hits = matches(MODULE_RE, path) if hits.empty?`, where `matches` calls `Open3.capture3` - PASS-9: F003: `lib_root ? Dir.glob(File.join(lib_root, "**/*.rb")) : Dir.glob(File.join(@dir, "**/*.rb"))` - PASS-10: F051: `run(root: config_root(root) || dirs.first)`, where `dirs = Array(project_dir)` and `config_root` calls `File.exist?` - PASS-11: F056: `def repo(slug, client) = (client || default_client).repo(slug)`, where `default_client` calls `File.read(TOKEN)` - PASS-12: F126: `def body(positional) = option_body || positional`, where `option_body` calls `File.read(@options[:body_file])` - PASS-13: F036 ```ruby def with_detected(options) candidate = %w[test spec].find { |d| File.directory?(d) } return options unless candidate options.merge(test_path: candidate) end ``` - PASS-14: F037: `default = %w[test spec].find { |d| File.directory?(d) }` followed by `default ? flat.merge(test_path: default) : flat` - PASS-15: F042: `return Null.new unless check.configured?` followed by `Live.new(File.read(@path))` - PASS-16: F011: `problems = packing.oversized(groups)` followed by `problems.empty? ? write(packing.call(groups)) : refused(problems)` - PASS-17: F029 (entry): `def run(argv) = (status = Report.run(@path, argv)).nil? ? lint(argv) : status`, and also its guard form `return status unless status.nil?` - PASS-18: F043: `found = paths.find { |p| File.exist?(p) }` followed by `return found if found` and `raise NotFound` - PASS-19: F034, F038: `found = Dir.glob(pattern)` followed by `return found.first if found.one?` - PASS-20: F021: `path = file_path(token)` followed by `return { units: units_in(path) } if path` - PASS-21: F030: `return checkpointed(mode) if mode` followed by `plain_run` - PASS-22: F018, F040: `return [] if source.empty?` followed by `source.lines` - PASS-23: F141: `row["id"] && row["session_id"] ? [row["id"], row["session_id"]] : Object.new` - PASS-24: F143: `rows.filter_map { |row| row["session_id"] if row["verdict"] }` - PASS-25: F113: `base = name == :require ? test_dir : File.dirname(@file)` - PASS-26: F017: ``numbered.empty? ? "No code line." : "```ruby\n#{numbered.join("\n")}\n```"`` - PASS-27: F130 (entry): `def call(argv)` that sets `number = argv.first` and `result = sweep(number)`, then `puts result.report.empty? ? "##{number}: passed" : result.report` - PASS-28: F004: `tests.empty? ? NONE : tests.join(",")` - PASS-29: F020, F023, F024: `out, status = Open3.capture2(cmd)` followed by `status.success? ? JSON.parse(out) : []` - PASS-30: F012: `File.symlink?(path) ? [:skipped, entry("symlink")] : verdict(File.binread(path))` - PASS-31: F039: `path.include?("/hooks/") ? "hook" : "bin"` - PASS-32: F059, F060, F062, F063, F064, F065: `known.fetch(entry, 0)`, `governed.fetch(id, [])`, `prior.fetch(name, "-")`, `data&.fetch("paths", nil)`, `analyses.fetch(sub) { -> { report(sub) } }` - PASS-33: F009: `next resolution unless resolution.status == :missing` - PASS-34: Role, pure: the F058 fixture in a class whose body holds no effect call - PASS-35: Role, entry input (from the entry-scope rows F135 and F136 in the rule-v3 load-bearing table), with a fixture that is an entry: `def call(argv) = argv[0].to_s.empty? ? argv[1] : argv[0]` ## Owner sketch | Object | File | Owns | | --- | --- | --- | | `PrecedenceInEdge` | `cops/structural/precedence_in_edge.rb` | Visits `or`, ternary `if`, and guard `return` nodes. Asks `EdgeScope` and `Choice`. Calls `report` with `clause:` and `role:`. | | `EdgeScope` (new, shared with O and E) | `cops/structural/edge_scope.rb` | Gets the role of a node from its enclosing scope: `adapter`, `entry`, or none. Uses `EffectInModel::EffectCall` and `EntryPoint`. Also holds the entry-input test. | | `EffectReach` (new, shared) | `cops/structural/effect_reach.rb` | From an expression and `SameFileDefs`, finds the reader identities it reaches: `EffectCall` labels, plus project classes reached by `Const.new(...).m` or `Const.m`. Uses a breadth-first walk, as `WriteChain` does. | | `PrecedenceInEdge::Candidate` | `precedence_in_edge/candidate.rb` | Gets the kind of one candidate: `literal`, `error`, `bare`, `param`, `slot`, `held`, or `slot_built`. | | `PrecedenceInEdge::Choice` | `precedence_in_edge/choice.rb` | Holds the two candidates, or the guard and its continuation. Gives the clause (`p_a`, `p_b`, `p_guard`) or nil. | ## Non-goals The cop does not find these shapes (from smell analysis section 7 and the rule-v3 misses): - Precedence by `Hash#merge` order, for example `sliced(@data).merge(sliced(section))` (`project/config/document.rb:65`). - A source behind another class or more than two hops away. - F044 `@expected || definitions.primary(...)`. This needs a fact that tells a collaborator object from a data value. F056 `client || self.client` (FALSE) has the same shape. - F061 `renames.fetch(path, path)` (TRUE). This needs the fact that `renames` holds stored ledger fields. The brief named `fetch(key, key)` as a FALSE shape, but the only labeled instance, F061, is TRUE. Thus it is neither a must-fire nor a must-pass fixture here. - The reassignment form `x = a` followed by `x = b if x.empty?` (F022 shape), and `@x ||= ...` memos. - Reads through `ENV`, Sequel, or a network client. These are not `EffectCall` labels. - Verdict choices (F010, F026, F050, F114) belong to cop E. Comparisons with our operands belong to cop O. - No autocorrect: the fix needs a judgment about where the decision object goes (tier c). ## Origin - Trigger: 2026-11-02 data-flow fact rounds over 147 labeled decision sites; smell analysis docs/system-design-format/proof/2026-11-02-smell-analysis-decision-in-adapter.md - Improvised this session: none - Chain: syntax detector overfit (15% precision on unseen code) ← A19 line is drawn on data origin, not syntax ← DESIGN docs/system-design-format/proof/2026-11-02-smell-analysis-decision-in-adapter.md - Root candidate: none found; searched PrecedenceInEdge, EdgeScope, EffectReach, EffectInModel, A19, "configured beats inferred", "two sources", tracker_lookup, io_reach, EntryPoint, LayerLedger - Where: plugins/os-sdlc/lib/os_sdlc/system_model/class_facts/io_reach.rb - Session: 394c0068-e8aa-449e-95d1-dad679bda47d - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/394c0068-e8aa-449e-95d1-dad679bda47d.jsonl VERDICT: CREATE — no open ticket proposes a precedence-in-edge cop; nearest #1507 and #1040 are different faults.
Author
Owner

Work started on branch ticket-1509 (worktree .claude/worktrees/ticket-1509) through /os-sdlc:implement.

Work started on branch ticket-1509 (worktree .claude/worktrees/ticket-1509) through /os-sdlc:implement.
Author
Owner

Resolution

Done: New cop Sdlc/Structural/PrecedenceInEdge (A19 R1): an edge (adapter or entry) that fixes which of two sources wins for one fact. Cop, helpers Presence/Candidate/Choice/Guard, shared EdgeScope and EffectReach (for #1510 #1511), Minitest fixtures (8 must-fire, 35 must-pass, plus #1512 fixtures), lesson sdlc-structural-precedence-in-edge.yml, .rubocop.yml entry. Commits 2cf5c621, #1512 fix, lens signal 575edd44 (A2 deferred).

Evidence: Pipeline implementation 217 complete (12 dispatches, suite pass, contract-auditor pass). Orchestrator whole-tree run over plugins/ (2874 files incl. bin scripts and all plugins): 8/8 must-fire sites reported; first run 1 false hit (os-context/audit/bin/extract:74) fixed by #1512; rerun 10 hits, all TRUE: tracker_lookup.rb:28 :42, ledger.rb:25, document.rb:66 :76, config_command.rb:29, progress_table.rb:101, wakeup-poll:118, plus 2 unlabeled sites predicted TRUE (os-context/eval/bin/check:383 ENV override beats discovered file; cc-architect plugin_key_resolver.rb:14 installed_plugins.json beats settings.json).

Follow-ups: #1510 DomainOperandInEdge and #1511 DomainEffectInEdge already filed and blocked by this ticket. No fix tickets filed for the 10 TRUE sites (fixtures only in this track, per handoff 2026-11-02-1505).

## Resolution **Done:** New cop Sdlc/Structural/PrecedenceInEdge (A19 R1): an edge (adapter or entry) that fixes which of two sources wins for one fact. Cop, helpers Presence/Candidate/Choice/Guard, shared EdgeScope and EffectReach (for #1510 #1511), Minitest fixtures (8 must-fire, 35 must-pass, plus #1512 fixtures), lesson sdlc-structural-precedence-in-edge.yml, .rubocop.yml entry. Commits 2cf5c621, #1512 fix, lens signal 575edd44 (A2 deferred). **Evidence:** Pipeline implementation 217 complete (12 dispatches, suite pass, contract-auditor pass). Orchestrator whole-tree run over plugins/ (2874 files incl. bin scripts and all plugins): 8/8 must-fire sites reported; first run 1 false hit (os-context/audit/bin/extract:74) fixed by #1512; rerun 10 hits, all TRUE: tracker_lookup.rb:28 :42, ledger.rb:25, document.rb:66 :76, config_command.rb:29, progress_table.rb:101, wakeup-poll:118, plus 2 unlabeled sites predicted TRUE (os-context/eval/bin/check:383 ENV override beats discovered file; cc-architect plugin_key_resolver.rb:14 installed_plugins.json beats settings.json). **Follow-ups:** #1510 DomainOperandInEdge and #1511 DomainEffectInEdge already filed and blocked by this ticket. No fix tickets filed for the 10 TRUE sites (fixtures only in this track, per handoff 2026-11-02-1505).
jared closed this issue 2026-11-02 22:25:38 +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#1509
No description provided.