os-sdlc cop Sdlc/Structural/PrecedenceInEdge: an edge picks which of two sources wins for one fact (A19 R1) #1509
Labels
No labels
P0
P1
P2
P3
bug
category/code-change
category/cross-repo
category/eval-run
category/investigation
category/no-test-target
category/owner-ruling
category/prose-artifact
category/recurring-chore
category/structure
category/umbrella
crc-refactor
create
delete
enhancement
filed-by/agent
filed-by/user
frozen
lint-rule
needs-info
needs-triage
next
plugin/cc-architect
plugin/os
plugin/os-adr
plugin/os-aidd-lint
plugin/os-backlog
plugin/os-context
plugin/os-doc-hygiene
plugin/os-sdlc
plugin/os-vault
project/cc-os
ready-for-agent
ready-for-human
recurring
review
rule-proposal
update
waiting
wayfinder:grilling
wayfinder:map
wayfinder:map
wayfinder:research
wayfinder:task
wayfinder:task
wontfix
worklist/deviations
worklist/lint-rule
worklist/new-implement-build
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
jared/cc-os#1509
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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) anddocs/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/EffectInModelis the nearest cop, and it is disabled inplugins/os-sdlc/.rubocop.yml(#1507). It gets its role fromLayerLedger. With noLedgerPath, 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:
classormodulebody holds at least one call thatEffectInModel::EffectCall#labelnames. For code outside all classes, use the file's top level. The cop givesEffectCallits ownEffectConstantsdefault:File Dir IO FileUtils YAML JSON Open3 Process Kernel. This is theEffectCalldefault plusFileUtils.EntryPoint.file?(path)is true. That is, the first line starts with#!, or the file is in a folder namedbin.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:EffectInModelreports nothing whenLedgerPathis not set (#1507). WhenLedgerPathnames a missing file,LayerLedger::Ledger#role_by_constraisesRuboCop::Error. The ledger cannot give roles for this cop, becauseLayerMap.cpath_by_pathmaps only files under os-sdlc's ownlib/throughOsSdlc.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.
plugins/os-sdlc/.rubocop.yml. os-sdlc layers this config at run time and installs nothing into target projects.cops/structural/precedence_in_edge.rbholdsSdlc::Structural::PrecedenceInEdge.Value rule (R1, order). A site is one of three choice shapes:
a || bused as a value, not as a condition, where neither operand is a predicate (a?method or a comparison).c ? c : d,c.nil?,c.empty?,c.to_s.empty?,!c, orx.key?(:k)when the candidate isx[:k].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:
x["k"],x[:k],x.fetch("k"),.to_sallowed). 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).@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).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
EffectCalllabel (for example bothDir.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.readandFile.writeare 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, andChoice. The cop usesQueryMethodReachesWrite::SameFileDefsto resolve same-file calls. It recordsclause:(p_a,p_b,p_guard) androle:(adapter,entry) throughRecordingCop#report.Required work:
RuboCop::Cop::Sdlc::Structural::PrecedenceInEdge < RecordingCop— check: presentclass PrecedenceInEdgeinplugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge.rbEdgeScope, shared with O and E — check: presentclass EdgeScopeinplugins/os-sdlc/lib/os_sdlc/cops/structural/edge_scope.rbEffectReach, shared — check: presentclass EffectReachinplugins/os-sdlc/lib/os_sdlc/cops/structural/effect_reach.rbPrecedenceInEdge::Candidate— check: presentclass Candidateinplugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge/candidate.rbPrecedenceInEdge::Choice— check: presentclass Choiceinplugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge/choice.rbExcludekey — check: presentSdlc/Structural/PrecedenceInEdge:inplugins/os-sdlc/.rubocop.ymlPrecedenceInEdgeinplugins/os-sdlc/tests/cops/precedence_in_edge_test.rbkey: Sdlc/Structural/PrecedenceInEdgeinplugins/os-sdlc/lint-lessons/sdlc-structural-precedence-in-edge.ymlOrchestrator work: Whole-tree run: run the cop alone with the os-sdlc config over every Ruby file under
plugins/, includingbin/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
Shared lesson core. The O and E tickets copy these three items word for word:
why: the fullwhyvalue 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 fullcorrectionvalue above.Acceptance criteria
Sdlc/Structural/PrecedenceInEdgeis registered fromplugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge.rbasRuboCop::Cop::Sdlc::Structural::PrecedenceInEdge < RecordingCopand loading passes (ADR-0090).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.plugins/os-sdlc/.rubocop.yml, when it is read, then it hasSdlc/Structural/PrecedenceInEdge: Enabled: truewith noExcludekey (ADR-0298).plugins/os-sdlc/tests/cops/precedence_in_edge_test.rb, when it runs, then every fixture goes throughCopHelpers#offenses_for.Excludelist, disable comment, or site list is added.plugins/os-sdlc/lint-lessons/sdlc-structural-precedence-in-edge.yml, when it loads, then it holds the text in the Lesson subsection and thewhyvalue, the first two sentences ofdecision, and thecorrectionvalue are byte-identical to the shared core.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 isbin/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:
crc/progress_table.rb:101project/config/document.rb:66:def tracker(name) = section(name)["tracker"] || @data["tracker"]tracker/issue_source/tracker_lookup.rb:28tracker/issue_source/tracker_lookup.rb:42cops/structural/layer_ledger/ledger.rb:25:def constant(row) = row["new_constant"].to_s.empty? ? row["constant"].to_s : row["new_constant"]project/config_command.rb:29project/config/document.rb:76os-backlog/bin/wakeup-poll:118. This fixture is an entry.Must-pass (35). This list has every FALSE site that has a P syntax shape, and the role cases:
def nodes = data["nodes"] || {}hit.dig("range", "start") || {}(index_of(line) || 0) + 1def socket_or_fail = socket || raise(Unavailable), wheresocketcallsFile.open{ title: @options[:title] || fail_soft("requires --title") }, wheredef fail_soft(m) = Exit.fail_soft(m)def locate = direct || (owning_root&.then { |r| Locator.new(r).path_if_exists }), wheredirect = Locator.new(@start).path_if_existsandowning_rootcallsFile.file?def locate_for_write = direct || Locator.new(owning_root || @start).pathhits = matches(CLASS_RE, path)followed byhits = matches(MODULE_RE, path) if hits.empty?, wherematchescallsOpen3.capture3lib_root ? Dir.glob(File.join(lib_root, "**/*.rb")) : Dir.glob(File.join(@dir, "**/*.rb"))run(root: config_root(root) || dirs.first), wheredirs = Array(project_dir)andconfig_rootcallsFile.exist?def repo(slug, client) = (client || default_client).repo(slug), wheredefault_clientcallsFile.read(TOKEN)def body(positional) = option_body || positional, whereoption_bodycallsFile.read(@options[:body_file])default = %w[test spec].find { |d| File.directory?(d) }followed bydefault ? flat.merge(test_path: default) : flatreturn Null.new unless check.configured?followed byLive.new(File.read(@path))problems = packing.oversized(groups)followed byproblems.empty? ? write(packing.call(groups)) : refused(problems)def run(argv) = (status = Report.run(@path, argv)).nil? ? lint(argv) : status, and also its guard formreturn status unless status.nil?found = paths.find { |p| File.exist?(p) }followed byreturn found if foundandraise NotFoundfound = Dir.glob(pattern)followed byreturn found.first if found.one?path = file_path(token)followed byreturn { units: units_in(path) } if pathreturn checkpointed(mode) if modefollowed byplain_runreturn [] if source.empty?followed bysource.linesrow["id"] && row["session_id"] ? [row["id"], row["session_id"]] : Object.newrows.filter_map { |row| row["session_id"] if row["verdict"] }base = name == :require ? test_dir : File.dirname(@file)numbered.empty? ? "No code line." : "```ruby\n#{numbered.join("\n")}\n```"def call(argv)that setsnumber = argv.firstandresult = sweep(number), thenputs result.report.empty? ? "##{number}: passed" : result.reporttests.empty? ? NONE : tests.join(",")out, status = Open3.capture2(cmd)followed bystatus.success? ? JSON.parse(out) : []File.symlink?(path) ? [:skipped, entry("symlink")] : verdict(File.binread(path))path.include?("/hooks/") ? "hook" : "bin"known.fetch(entry, 0),governed.fetch(id, []),prior.fetch(name, "-"),data&.fetch("paths", nil),analyses.fetch(sub) { -> { report(sub) } }next resolution unless resolution.status == :missingdef call(argv) = argv[0].to_s.empty? ? argv[1] : argv[0]Owner sketch
PrecedenceInEdgecops/structural/precedence_in_edge.rbor, ternaryif, and guardreturnnodes. AsksEdgeScopeandChoice. Callsreportwithclause:androle:.EdgeScope(new, shared with O and E)cops/structural/edge_scope.rbadapter,entry, or none. UsesEffectInModel::EffectCallandEntryPoint. Also holds the entry-input test.EffectReach(new, shared)cops/structural/effect_reach.rbSameFileDefs, finds the reader identities it reaches:EffectCalllabels, plus project classes reached byConst.new(...).morConst.m. Uses a breadth-first walk, asWriteChaindoes.PrecedenceInEdge::Candidateprecedence_in_edge/candidate.rbliteral,error,bare,param,slot,held, orslot_built.PrecedenceInEdge::Choiceprecedence_in_edge/choice.rbp_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):
Hash#mergeorder, for examplesliced(@data).merge(sliced(section))(project/config/document.rb:65).@expected || definitions.primary(...). This needs a fact that tells a collaborator object from a data value. F056client || self.client(FALSE) has the same shape.renames.fetch(path, path)(TRUE). This needs the fact thatrenamesholds stored ledger fields. The brief namedfetch(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.x = afollowed byx = b if x.empty?(F022 shape), and@x ||= ...memos.ENV, Sequel, or a network client. These are notEffectCalllabels.Origin
VERDICT: CREATE — no open ticket proposes a precedence-in-edge cop; nearest #1507 and #1040 are different faults.
jared referenced this issue2026-11-02 21:49:06 +00:00
Work started on branch ticket-1509 (worktree .claude/worktrees/ticket-1509) through /os-sdlc:implement.
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 signal575edd44(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).