os-sdlc cop Sdlc/Structural/DomainEffectInEdge: an edge turns what it read into a verdict, a selection of work, or a next step (A19 R3) #1511
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#1511
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) turns what it read into a domain outcome: a verdict (refuse, fail, report a problem), a selection of work (ready, unseen, open items), or a next step of our process. The outcome is our rule. Because it is inside the adapter, its test needs the resource. An edge that only reports what the resource holds, or that the resource failed, is translation.
Evidence:
docs/system-design-format/proof/2026-11-02-smell-analysis-decision-in-adapter.md(rule R3, section 7, cop E) anddocs/system-design-format/proof/2026-11-02-detector-a19/facts/rule-v3-notes.md(clauses E1 to E4). On 147 labeled sites, the v3 definition of E fires on 12 TRUE sites and 0 FALSE sites.No cop finds this shape.
Sdlc/Structural/EffectInModelis disabled in this repo because noLedgerPathis set (#1507).This cop is the third of three. It needs the shared objects from the P ticket (
Sdlc/Structural/PrecedenceInEdge:EdgeScope,EffectReach). Build P first.Reproduce
n/a: new cop, nothing to reproduce
Approach
Add
RuboCop::Cop::Sdlc::Structural::DomainEffectInEdge < 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).Required widening. F120's class reads its files only through
Prism.parse_file. TodayEffectCalltreats onlyYAMLandJSON*_filemethods as file reads, and these two constants are hard-coded. Add aFileFormConstantskey toEffectCall, with the defaultYAML JSON, so thatEffectInModeldoes not change.EdgeScopepassesYAML JSON Prism. This widens the role scope for P and O too, so run the P and O whole-tree checks again (see the AC).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/domain_effect_in_edge.rbholdsSdlc::Structural::DomainEffectInEdge.Terms.
nil?,empty?,any?,none?) on a local that the method assigns directly from a same-file method call, on that same-file method, on an ivar, or on a message with no arguments to an ivar. The value must reach no effect call through same-file methods (EffectReach);%,tally,count,size,max).row.fetch("job"), orrow.fetch(c)whereciterates a literal list) are fields of a parsed row, not caller input;initializeparameter.raise(X.new(...))included);if.Value rule (R3, effect). The cop reports a site when one of these clauses holds:
E1 (verdict choice). A ternary or
a || b. Each candidate is a same-file method call or a plain (not interpolated) string or symbol literal. Fora || b, each candidate is a same-file method call. The condition is ours. E1 does not apply the caller-input test, because v3 E1 allows ivars.E2 (our computation gives a verdict). The condition is ours, it is not caller input, and the branch has a verdict effect.
E3 (a reader's return selects or starts a step). Either form:
select,reject, orfindover a same-file collection. Its block tests the presence (any?,empty?,none?,nil?) of an argument-free message to the block element.nextorreturn.In both forms, the test is not caller input.
E4 (a parsed field gives a verdict). A presence test on a literal-key slot read that is not caller input, with verdict effect (c) only.
The terms ours, caller input, and verdict effect are new facts. No existing helper computes them. They live in the objects in the owner sketch. The cop records
clause:(e1toe4) androle:throughRecordingCop#report.Shared helpers (after #1509 and #1512).
EdgeScope#role(node, values)gives:entryonly to code outside every class and module body (#1512). A class inside an entry file takes the adapter role when its body holds an effect call, otherwise no edge role.EdgeScope#input?tests only that the values come from parameters,ARGV, or literals. The cop tests the second entry condition (the skipped path calls no effect) itself, withEffectReach#labels.valuestoEdgeScope#role, not the whole node. PASS-44 depends on this.QueryMethodReachesWrite::SameFileDefs, asprecedence_in_edge.rbdoes. UsePrecedenceInEdge::EFFECT_CONSTANTS; do not copy the list.PrecedenceInEdgebuilds oneeffect_confighash for bothEffectReachandEdgeScope(precedence_in_edge.rb:15-19). AddFileFormConstantsonly to the config thatEdgeScopegives toEffectCall.EffectReachkeeps theYAML JSONdefault, so only the role scope widens.Required work:
RuboCop::Cop::Sdlc::Structural::DomainEffectInEdge < RecordingCop— check: presentclass DomainEffectInEdgeinplugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge.rbDomainEffectInEdge::Condition— check: presentclass Conditioninplugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge/condition.rbDomainEffectInEdge::CallerInput— check: presentclass CallerInputinplugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge/caller_input.rbDomainEffectInEdge::Effect— check: presentclass Effectinplugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge/effect.rbEffectInModel::EffectCallreadFileFormConstantsfromcop_config, with the defaultYAML JSON— check: presentFileFormConstantsinplugins/os-sdlc/lib/os_sdlc/cops/structural/effect_in_model/effect_call.rbEdgeScopepassYAML JSON PrismasFileFormConstants— check: presentPrisminplugins/os-sdlc/lib/os_sdlc/cops/structural/edge_scope.rbExcludekey — check: presentSdlc/Structural/DomainEffectInEdge:inplugins/os-sdlc/.rubocop.ymlDomainEffectInEdgeinplugins/os-sdlc/tests/cops/domain_effect_in_edge_test.rbkey: Sdlc/Structural/DomainEffectInEdgeinplugins/os-sdlc/lint-lessons/sdlc-structural-domain-effect-in-edge.ymlPrecedenceInEdge::EFFECT_CONSTANTSinplugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge.rbFileFormConstantsonly insideEdgeScope, not to the sharedeffect_confighash — check: absentFileFormConstantsinplugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge.rbOrchestrator 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. After theFileFormConstantswidening, runSdlc/Structural/PrecedenceInEdgeandSdlc/Structural/DomainOperandInEdgeoverplugins/again; neither run may have a new false hit.Lesson
The shared lesson core comes from the P ticket (
Sdlc/Structural/PrecedenceInEdge). Thewhyandcorrectionvalues and the opening ofdecisionbelow are copied word for word from it.Acceptance criteria
Sdlc/Structural/DomainEffectInEdgeis registered fromplugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge.rbasRuboCop::Cop::Sdlc::Structural::DomainEffectInEdge < RecordingCopand loading passes (ADR-0090).An edge turns what it read into a verdict, a selection of work, or a next step. Return the values it read; a decision object or the value that owns the data decides.and the test asserts it verbatim.plugins/os-sdlc/.rubocop.yml, when it is read, then it hasSdlc/Structural/DomainEffectInEdge: Enabled: truewith noExcludekey (ADR-0298).plugins/os-sdlc/tests/cops/domain_effect_in_edge_test.rb, when it runs, then every fixture goes throughCopHelpers#offenses_for.Excludelist, disable comment, or site list is added.EffectCallwith noFileFormConstantssetting, when theEffectInModeltests run, then they pass with no change to them.plugins/os-sdlc/lint-lessons/sdlc-structural-domain-effect-in-edge.ymlandsdlc-structural-precedence-in-edge.yml, when thewhyvalue, the first two sentences ofdecision, and thecorrectionvalue are diffed, then the diff is empty and the PR shows it.The cop covers the whole R3 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 (12). These are all the R3 TRUE sites that the v3 definition catches:
crc/shares/verdict_split.rb:18implementation/mutation_check.rb:17implementation/mutation_check.rb:46crc/census/source_row.rb:47:def mismatch = @expected || @file.support ? "" : "unloaded"db.rb:109system_model/tracer.rb:80implementation/mutation_check.rb:53os-me/lib/os_me/blind_marker/reminder.rb:19: the same fixture as O ticket FIRE-2 (TEXT if (total % EVERY).zero?)system_model/overlay_check.rb:34crc/shares/verdict_split.rb:24:def gaps(row) = %w[job concept].select { |c| row.fetch(c).empty? }.map { |c| "#{row.fetch("path")}: empty #{c}" }system_model/extractor/scripts.rb:16. In this fixture, the class reads only throughPrism.parse_file.os-backlog/bin/wakeup-poll:91. This fixture is an entry.Must-pass (44). This list has every FALSE site that has an E syntax shape, and the role cases:
problems = packing.oversized(groups)followed byproblems.empty? ? write(packing.call(groups)) : refused(problems)worklist = Worklist.new(JSON.parse(File.read(path)))followed byworklist.clear? ? 0 : 1def call(argv)holdingresult = sweep_for(argv.first).sweepandputs result.report.empty? ? "passed" : result.report{ title: @options[:title] || fail_soft("requires --title") }socket || raise(Unavailable)File.symlink?(path) ? [:skipped, entry("symlink")] : verdict(File.binread(path))def verdict(bytes)holdingtext = bytes.force_encoding(Encoding::UTF_8)andreturn [:problems, entry("not valid UTF-8")] unless text.valid_encoding?_o, err, status = Shell.capture3(cmd)followed byfailure("patch does not apply: #{err}") unless status.success?report = build_report(tokens)followed byraise Thor::Error, "every token is PHANTOM" if report.all_phantom?result = run_gate(mode)followed byreturn report_failure(result) unless result.passed?status = Report.run(@path, argv)followed byreturn status unless status.nil?def run(mode)holdingreturn checkpointed(mode) if modepath = file_path(token)followed byreturn { units: units_in(path) } if path, wherefile_pathcallsFile.exist?warn result.err unless result.err.empty?def report(result)holding@session.fail_soft("bounced") unless result.claimed?def dispatch(tracker)holdingreturn puts "no tracker" unless tracker && Tracker.valid?(tracker)def render(labels)holdingif labels.empty? then puts "no scope labels" else ... endstamp = @options.slice(:row_id, :tier)followed byraise Error, "--row-id needs --tier" if stamp[:row_id] && stamp.size < 3def validate(options)holdingmissing = FLAGS.select { |k| options[k].nil? }andraise OptionParser::MissingArgument, missing.join(", ") unless missing.empty?def score(exchanges)holdingraise "no exchanges" if exchanges.empty?rows.filter_map { |row| row["session_id"] if row["verdict"] }rows.reject { |row| row["new_path"].to_s.empty? }.to_h { |row| [row["path"], row["new_path"]] }@exclude.reject { |p| files.any? { |f| f.start_with?(p) } }.map { |p| { "problem" => "exclude matches no file", "prefix" => p } }File.foreach(@file) { |line| in_history = line.chomp == @heading if line.start_with?("## "); yield line unless in_history }raw.reject { |p| excluded?(p) }entries.select { |e| under_plugin?(e) }.filter_map { |e| finding(e) }classes.select { |d| lineage.error?(d) }found.reject { |_cop, paths| paths.empty? }sections.select { !_1.empty? }numbered.empty? ? "No code line." : "```ruby\n#{numbered.join("\n")}\n```"path.include?("/hooks/") ? "hook" : "bin"tests.empty? ? NONE : tests.join(",")run(argv) || 0existing = candidates.select { |d| File.directory?(d) }followed bylines << "Candidates: #{existing.join(", ")}" if existing.any?return Null.new unless check.configured?next resolution unless resolution.status == :missingtier = Wakeup.tier(labels)followed byif tier == :notify then append(line); next; endreturn none unless info && %i[forgejo github].include?(info[:kind])actual = File.open(path, &:gets).to_s.split("\t"), thenreturn [] if actual == COLUMNS, then[{ "problem" => "header differs" }]fresh = incoming.select { |r| known.add?(r.id) }required.reject { |p| File.exist?(File.join(@root, p)) }missing = deleted.reject { exist?(_1) }followed byraise HarnessError, "path does not exist: #{missing.join(", ")}" unless missing.empty?, whereexist?callsFile.exist?def call(argv)holdingraise Usage, "needs a path" if argv.empty?Owner sketch
DomainEffectInEdgecops/structural/domain_effect_in_edge.rbif/unless, ternaries,or, andselect/reject/findblocks. AsksEdgeScope(from P), then the clause objects. Callsreportwithclause:androle:.DomainEffectInEdge::Conditiondomain_effect_in_edge/condition.rbSameFileDefsandEffectReach(from P).DomainEffectInEdge::CallerInputdomain_effect_in_edge/caller_input.rbinitializesets from its parameters.DomainEffectInEdge::Effectdomain_effect_in_edge/effect.rbEffectInModel::EffectCall(changed)cops/structural/effect_in_model/effect_call.rbFileFormConstantsfromcop_config, with the defaultYAML JSON.Non-goals
The cop does not find these shapes (from smell analysis section 7 and the rule-v3 misses):
row.fetch("commit", "").empty? || row.fetch("new_path").empty?(TRUE). It is a label pair with F143 (FALSE), and their facts are the same. This needs a ruling (smell analysis follow-up 5).ENV, Sequel, or a network client do not make a class an adapter, unless the class also holds anEffectCall.Blocked by
#1510 (its orchestrator step reruns DomainOperandInEdge, and both tickets touch EdgeScope; #1509 is closed)
Origin
VERDICT: CREATE — no open ticket covers a domain effect decided inside an edge; nearest #1507 and #1351 cover EffectInModel.
Work started on branch ticket-1511 via /os-sdlc:implement (session a5c3e206).
Resolution
Done: Cop Sdlc/Structural/DomainEffectInEdge (A19 R3) with lint lesson: reports an edge (adapter by effect call, entry by EntryPoint) that turns what it read into a verdict, a selection, or a next step; EffectCall gains FileFormConstants (default YAML JSON; EdgeScope passes YAML JSON Prism); registered in plugins/os-sdlc/.rubocop.yml only.
Evidence: Runner implementation 221 implementation_complete; 19 dispatches, 2 retries; suite-check pass (suite 50 s); contract-auditor pass (opus); committed by the Runner on branch ticket-1511.
Follow-ups: One LENS line (A2: PrecedenceInEdge::Candidate.unwrap now called from two cop concepts) is being challenged; if JUSTIFIED it becomes a 'Lens proposal:' ticket via /os-backlog:capture. No other follow-ups.
Follow-up from the close comment resolved: the A2 LENS line was challenged (verdict: code defect found, JUSTIFIED overturned). Filed #1523 (Lens proposal: lift cross-concept nested cop helpers to top level). Signal recorded in lens-signals/A2.jsonl (main
ef991fc4).