Sdlc/Structural/ReachThroughChain: do not report child sends with arguments or stream and RuboCop AST chains; record parameter roots in held_parameter #1378
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#1378
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
Sdlc/Structural/ReachThroughChainis a sweep-only recording cop. It records each chain of two or more named sends from an ivar or lvar root, and it reports the chain when the root is held and the chain is not noise. Its message tells the reader to "hand the child in, or let the parent answer". Three shapes get that message although no held child exists or the child is a value, and one shape records a false fact:held_ivar: "<parameter>"(site.rb:30). No ivar exists.Detector::ReachThrough::Found#heldreads that field to keep the row.@client.ask(text, [q]).fetch(:source).choice) is reported as "reaches through@clientto itsask". The child is an answer that the caller's own arguments decide, not a part the root holds. The lessondetector/reach-throughalready says: "The child is a value or needs caller-only arguments: keep."stdoutorstderr(invocation.stdout.string.strip,@env.out.puts(x)) is not noise.Noisecovers onlystdout/stderrfollowed byputs/print/write(noise.rb:27-29). The cop lesson calls a stream chain "not a smell".RuboCop, a chain of RuboCop AST node messages (node.first_argument.value,write.source_range.end_pos) is reported. The cop already treats the same shape as noise for Prism nodes (prism_message?).Found by the #1325 zero-false-positive audit (docs/plans/class-length-family/sdlc-cop-audit.md). Shapes measured on main
4a0fc41d, 2026-10-26, over everyplugins/**/*.rbfile (204 offenses) and over the #1317 sweep of os-backlog, os, os-adr, os-aidd-lint, and os-me (31 offenses):held_ivarnames a parameter; the offense itself stays (the lesson lists "a method parameter" as held)plugins/os-adr/lib/adr/record/challenge.rb:31entry.record.idplugins/os-me/lib/os_me/answer_source.rb:20@client.ask(row.text, [question]).fetch(:source).choice;plugins/os-aidd-lint/tests/support/markdown_cop_test_helper.rb:6cop_class.new(document, config).callplugins/os-adr/tests/support/command_invocations.rb:38invocation.stdout.string.strip;plugins/os-sdlc/eval/ticket-takedown/lib/takedown/controller/run_cli.rb:39@env.out.puts(report.summary)plugins/os-sdlc/lib/os_sdlc/cops/require_inside_code/call.rb:44node.first_argument.valueplugins/os-aidd-lint/lib/aidd_lint/config.rb:64document.frontmatter.metadata_value('lint-style')Two sites match two rules, so the removed shapes sum to 90 and the drop is 88.
Approach
Fix by shape in the cop matcher. Never use a config Exclude list or a test-path exclusion.
Rules:
held_parameter: <name>andheld_ivar: nil. The cop still reports it:smell?readsheld_ivar || held_parameter. This keeps ruling R2 (docs/crc/os-sdlc/collaborator-rulings.tsv:83) for every held root.Chainanswers if its child send (the first named send after the root) takes arguments. The cop records this as a new factchild_argumentsand does not report such a chain.Noisedoes not change for this rule, so the detector keeps its rows.Noise::CORE_OBJECTSgainsStringIO. A chain whose sends after the child are allStringIOor core messages is noise. This covers stream reads and IO writes under any child name.Noisegains a RuboCop rule parallel toprism_message?: in a file whose source namesRuboCop, a child that is a public instance method of aRuboCop::AST::Nodeclass (minusObject's methods) is noise.Detector::ReachThrough::Found#heldbuilds itsHeldfromheld_parameterwhen the root is local, so the detector output for parameter roots does not change.Measured effect (scratch simulation on main
4a0fc41d, the cop patched in process and in the rubocop subprocess): offenses overplugins/**/*.rbgo from 204 to 116; the 88 removed offenses are all in the three false-positive shapes above.bin/lint-sweepover the five sweep plugins goes from 31 to 21 ReachThroughChain offenses, and the other sweep cops keep their 246 offenses.plugins/os-sdlc/bin/detector reach-through --target <t>prints the same bytes before and after for all eight targets in.sdlc/project.yaml(os-sdlc has 57 lines; the others print only the header). Without theFound#heldchange it loses 13 parameter-root rows. With the argument rule inNoiseinstead of a separate fact it loses 16 rows.Required work:
plugins/os-sdlc/lib/os_sdlc/cops/structural/reach_through_chain/site.rb, record a method-parameter root asHeld.new(nil, root, nil). Inplugins/os-sdlc/lib/os_sdlc/cops/structural/reach_through_chain.rb, makesmell?true forheld_ivarorheld_parameterwhen the chain is not noise. Updatetest_a_method_parameter_root_is_held_by_the_parameter_alone(plugins/os-sdlc/tests/cops/reach_through_chain_test.rb:94-98) to expectheld_ivar: nil, held_parameter: "bound", and add an assertion that the chain is one offense.check: absent
held_ivar: "bound"inplugins/os-sdlc/tests/cops/reach_through_chain_test.rbplugins/os-sdlc/lib/os_sdlc/detector/reach_through/found.rb, build the detectorHeldasHeld.new(held_parameter, nil, nil)when the factlocalis true (the parameter goes in theivarslot, as today; putting it in theparameterslot makesParents#through_parameterresolve types and changes the parent column), so thattest_a_method_parameter_root_prints_a_row_with_the_parameter_as_parent(plugins/os-sdlc/tests/detector/reach_through_test.rb:86-93) stays green without edits.check: present
:localinplugins/os-sdlc/lib/os_sdlc/detector/reach_through/found.rbplugins/os-sdlc/lib/os_sdlc/cops/structural/reach_through_chain/chain.rb, answer if the child send takes arguments. The child send is the call thatchildnames (the first ofnamed_calls, so a bare reader root is skipped). A block pass (&:name) is an argument; a literal block alone (@target.items { 1 }.first) is not;&.is the same as.. Record it as the factchild_argumentsand do not report a chain where it is true. Update the exact facts hash intest_a_held_ivar_chain_records_its_chain_facts(reach_through_chain_test.rb:27-33) withchild_arguments: false. Add must-pass fixtures (@client.ask(text).fetch(:source).choice,cop_class.new(document).callwithcop_classa parameter) and keep the must-fire fixture@target.test.command.check: present
child_argumentsinplugins/os-sdlc/lib/os_sdlc/cops/structural/reach_through_chain.rbplugins/os-sdlc/lib/os_sdlc/cops/structural/reach_through_chain/noise.rb, addStringIOtoCORE_OBJECTS. Add must-pass fixturesinvocation.stdout.string.strip(parameter root) and@env.out.puts(1)(held ivar) toreach_through_chain_test.rb.check: present
StringIOinplugins/os-sdlc/lib/os_sdlc/cops/structural/reach_through_chain/noise.rbnoise.rb, add the RuboCop AST message rule parallel toprism_message?, gated on a file whose source namesRuboCop. Add a test parallel totest_prism_node_messages_are_noise_only_in_a_file_that_names_prismwithnode.first_argument.value.check: present
RuboCop::ASTinplugins/os-sdlc/lib/os_sdlc/cops/structural/reach_through_chain/noise.rbplugins/os-sdlc/lint-lessons/sdlc-structural-reach-through-chain.yml, state insignalthat a chain whose child takes arguments is not an offense, and that noise includes a StringIO read or IO write and RuboCop AST messages.check: present
takes argumentsinplugins/os-sdlc/lint-lessons/sdlc-structural-reach-through-chain.ymldocs/implementation-status/os-sdlc.md.check: present
#1378indocs/implementation-status/os-sdlc.mdAcceptance criteria
def run(bound, name: nil) = bound.test.command, when the cop runs, then it recordsheld_ivar: nilandheld_parameter: "bound"and reports one offense.@clientwith@client.ask(text).fetch(:source).choice, or a parametercop_classwithcop_class.new(document).call, when the cop runs, then it records the chain withchild_arguments: trueand reports no offense.invocationwithinvocation.stdout.string.strip, or a held@envwith@env.out.puts(1), when the cop runs, then it recordsnoise: trueand reports no offense.def run(node) = node.first_argument.valuein a file that namesRuboCop, when the cop runs, then it recordsnoise: true; in a file that does not nameRuboCop, it still reports the chain.@target.test.command, when the cop runs, then it reports one offense, as today.plugins/os-sdlc/bin/detector reach-through --target <t>for every target in.sdlc/project.yaml, when it runs after the change, then its stdout is byte-identical to its stdout on the base commit.plugins/os-sdlc/bin/lint-sweep plugins/<p>for os-backlog, os, os-adr, os-aidd-lint, and os-me, when it runs, then no Sdlc/Structural/ReachThroughChain offense has a child send with arguments or a stream child (31 offenses on4a0fc41dbecome 21).Owner sketch
RuboCop::Cop::Sdlc::Structural::ReachThroughChain(existing) owns the offense decision insmell?.ReachThroughChain::Site(existing) owns what holds the root.ReachThroughChain::Chain(existing) owns the send list and answerschild_arguments?.ReachThroughChain::Noise(existing) owns the noise rules.Detector::ReachThrough::Found(existing) maps the facts to the detector'sHeld. No new object.In scope / out of scope
Found#heldmapping, and the lesson signal.plugins/): a constructor child without arguments (klass.new.call); 0 offenses remain with.newafter the argument rule.section.heading.text,entry.record.id,labels.source.source) or a gem client (repo.labels.list). The cop has no type for the root, so no syntax rule separates them from a collaborator with behavior. The 3 others (document.frontmatter.*) are true positives. Sequel calls such as@dispatch.refresh.step_resultsare in this group.stream_write?(StringIOcovers it); the other detector code.Blocking edges
None. One implement run. Target:
os-sdlc(/os-sdlc:implement 1378 --target os-sdlc).Origin
VERDICT: CREATE
Decision memo (AFK, 2026-10-26): what to do with the type-dependent ReachThroughChain offenses that remain after this ticket.
After #1378, 21 sweep offenses remain. 3 are true positives (
document.frontmatter.metadata_value(...), Document is a class with behavior). 18 read through a Struct or Data value or a gem client, which the lessons call "not a smell":A (default): keep them as offenses. The cop has no type for the root; its message already says "when the root is a collaborator with behavior", and the sweep is a judgment-tier pass.
section.heading.text # Section = Struct.new(:heading, ...) -> offense stays, reader decides
B: report only roots whose type the cop can see as a class with behavior (for example
@x = Foo.newwith Foo a class, not Struct/Data). Parameter and reader roots never report.def run(section) = section.heading.text # no offense
@target = Project::Target.new(x); @target.test.command # offense
Recommendation: A for this ticket, confidence 0.6. Deciding reason: no syntax rule separates a Struct parameter from a collaborator parameter, and B drops the 3 true positives too (all are parameter roots). A follow-up can move the type judgment to
bin/detector reach-through, which already resolves parent types. Ticket is written on default A.Sdlc/Structural/ReachThroughChain: treat stream reads as noise and record parameter roots in the right fieldto Sdlc/Structural/ReachThroughChain: do not report child sends with arguments or stream and RuboCop AST chains; record parameter roots in held_parameter