Redesign RepeatedLiteralArgument into RepeatedPathLiteral (whole-path cop) #1014

Closed
opened 2026-10-15 17:55:21 +00:00 by jared · 4 comments
Owner

Problem

The cop Sdlc/Dry/RepeatedLiteralArgument groups each string argument of File.join by value and fires at 3 or more calls. It counts path segments, not whole paths. It ignores the same path written as a plain string. Analysis: docs/research/lint-lessons/sdlc-dry-repeated-literal-argument.md, sections 2, 3, 6, and 7.

Two results from #1004 (eval/ticket-takedown/test/command/final_suite_test.rb):

  1. False positive on a segment. The cop reports "lib" in 4 File.join calls. The segment "lib" is shared by different paths. The real fact is the whole path lib/y.rb, typed 9 times in two spellings.
  2. Miss on whole paths. .sdlc/project.yaml appears in 6 places, one of them as the string ".sdlc/project.yaml". The cop counts the segment ".sdlc" only.

Commit d3cfd3e6 also shows a dodge. Two classes each define def project_file = File.join(@repo, ".sdlc", "project.yaml"), and a string form of the same path remains. The helper hides the repeat from a per-call count.

Reproduce

n/a: design ticket. The cop source is plugins/os-sdlc/lib/os_sdlc/cops/repeated_literal_argument.rb.

Approach

Design ruled by Jared on 2026-10-15. Count whole path values, string forms included, in path positions only.

  • Value of a path expression: the literal segments after the first non-literal root, joined with "/". File.join(@repo, "lib", "y.rb") has the value lib/y.rb.
  • ["lib/y.rb"] in a path array, or a path-typed argument, has the same value lib/y.rb.
  • File.join(@repo, "lib", "z.rb") has a different value, lib/z.rb.
  • Group by value per file. Fire at the existing threshold of 3 or more places with one value.
  • Rename the cop to Sdlc/Dry/RepeatedPathLiteral, with its config block, require line, source directory, and test file. The lesson key becomes sdlc-dry-repeated-path-literal.
  • Catch the helper dodge: two or more methods (in one or more classes) whose body is the same path value count as separate places, together with any string form of that value.

Must fire: lib/y.rb in 9 places (analysis section 2). .sdlc/project.yaml in 6 places including a string form.
Must pass: the shared segment lib across different paths. A path inside a log or message string, for example "wrote lib/y.rb".

RULED 2026-10-16 (Jared): "path position" is defined by the value, not by the consumer. There is no list of path-taking calls.

A path value is: a string literal, taken whole, with no space and no "://", that either contains "/" or ends in a file extension (for example project.yaml, y.rb); OR a File.join / Pathname#/ expression whose trailing literal segments join to such a value (File.join(@repo, "lib", "y.rb") has the value lib/y.rb). Interpolated strings and heredocs are not counted. Group by value per file; fire at 3 or more places. A literal that is a path but sits in a non-file call (deleted: ["lib/y.rb"]) counts: it is the same fact. A string that contains a path inside other text ("wrote lib/y.rb") does not count: it is not equal to the value.

Tier: this cop is judgment-tier, not must-pass. It will have some false positives. It must NOT be enabled in the gate config plugins/os-sdlc/.rubocop.yml, and it must never reach the fix-lints worklist. It runs only on occasional sweep passes (see the "Sweeps" section of the fix-lints skill) through a separate sweep config and runner that this ticket adds. The lint lesson sdlc-dry-repeated-path-literal is the fix guidance; when the agent finds no smell at a reported site it files a false-positive ticket.

Before enabling the cop in the sweep config, run it over plugins/ and classify every hit by hand; tighten the value rule for each false hit, never add an allowlist.

Required work:

  1. Add the renamed cop file — check: present RepeatedPathLiteral in plugins/os-sdlc/lib/os_sdlc/cops/*.rb
  2. Remove the old cop name from the config — check: absent RepeatedLiteralArgument in plugins/os-sdlc/.rubocop.yml
  3. The cop is absent from the gate config — check: absent RepeatedPathLiteral in plugins/os-sdlc/.rubocop.yml
  4. The sweep config enables the cop — check: present RepeatedPathLiteral in plugins/os-sdlc/.rubocop-sweep.yml
  5. The sweep runner exists — check: present rubocop in plugins/os-sdlc/bin/lint-sweep

Acceptance criteria

  1. Given File.join(@repo, "lib", "y.rb") in 3 places and ["lib/y.rb"] in 6 other path positions of one file, when the cop runs, then it reports the value lib/y.rb.
  2. Given File.join(@repo, ".sdlc", "project.yaml") in 5 places and ".sdlc/project.yaml" in a path position, when the cop runs, then it reports the value .sdlc/project.yaml.
  3. Given File.join(@repo, "lib", "y.rb"), File.join(@repo, "lib", "z.rb"), and File.join(@repo, "lib", "w.rb"), when the cop runs, then it reports nothing.
  4. Given "lib/y.rb" inside a log or message string 3 times, when the cop runs, then it reports nothing.
  5. Given two classes that each define def project_file = File.join(@repo, ".sdlc", "project.yaml") and one string form of the same path, when the cop runs, then it reports the value.
  6. Given deleted: ["lib/y.rb"] in 3 places of one file, when the cop runs, then it reports the value lib/y.rb.
  7. Given the gate config, when bin/lint-worklist runs, then RepeatedPathLiteral offenses do not appear in any phase.
  8. Given bin/lint-sweep is run on a directory, when the cop finds a repeated path value, then the offense is printed with the cop name and nothing exits non-zero as a gate.

Owner sketch

class RepeatedPathLiteral < RuboCop::Cop::Base
  def on_new_investigation
end

Non-goals

Fixes at the #1004 sites. The lesson text (drafted separately).

Origin

  • Skeptic: VERDICT: CREATE (user-requested ticket)
  • Trigger: lint-lesson analysis of the #1004 offense (docs/research/lint-lessons/sdlc-dry-repeated-literal-argument.md)
  • Improvised this session: none
  • Chain: segment counted, whole path missed ← cop groups File.join string arguments by value ← DESIGN (analysis sections 3, 6, 7; Jared ruling 2026-10-15)
  • Root candidate: none
  • Where: plugins/os-sdlc/lib/os_sdlc/cops/repeated_literal_argument.rb
  • Session: 270dfee2-6a92-4e87-828c-9ac8c32c1cd7
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os--claude-worktrees-prism-architect/270dfee2-6a92-4e87-828c-9ac8c32c1cd7.jsonl
## Problem The cop `Sdlc/Dry/RepeatedLiteralArgument` groups each string argument of `File.join` by value and fires at 3 or more calls. It counts path segments, not whole paths. It ignores the same path written as a plain string. Analysis: docs/research/lint-lessons/sdlc-dry-repeated-literal-argument.md, sections 2, 3, 6, and 7. Two results from #1004 (`eval/ticket-takedown/test/command/final_suite_test.rb`): 1. False positive on a segment. The cop reports `"lib"` in 4 `File.join` calls. The segment `"lib"` is shared by different paths. The real fact is the whole path `lib/y.rb`, typed 9 times in two spellings. 2. Miss on whole paths. `.sdlc/project.yaml` appears in 6 places, one of them as the string `".sdlc/project.yaml"`. The cop counts the segment `".sdlc"` only. Commit d3cfd3e6 also shows a dodge. Two classes each define `def project_file = File.join(@repo, ".sdlc", "project.yaml")`, and a string form of the same path remains. The helper hides the repeat from a per-call count. ## Reproduce n/a: design ticket. The cop source is plugins/os-sdlc/lib/os_sdlc/cops/repeated_literal_argument.rb. ## Approach Design ruled by Jared on 2026-10-15. Count whole path values, string forms included, in path positions only. - Value of a path expression: the literal segments after the first non-literal root, joined with "/". `File.join(@repo, "lib", "y.rb")` has the value `lib/y.rb`. - `["lib/y.rb"]` in a path array, or a path-typed argument, has the same value `lib/y.rb`. - `File.join(@repo, "lib", "z.rb")` has a different value, `lib/z.rb`. - Group by value per file. Fire at the existing threshold of 3 or more places with one value. - Rename the cop to `Sdlc/Dry/RepeatedPathLiteral`, with its config block, require line, source directory, and test file. The lesson key becomes `sdlc-dry-repeated-path-literal`. - Catch the helper dodge: two or more methods (in one or more classes) whose body is the same path value count as separate places, together with any string form of that value. Must fire: `lib/y.rb` in 9 places (analysis section 2). `.sdlc/project.yaml` in 6 places including a string form. Must pass: the shared segment `lib` across different paths. A path inside a log or message string, for example `"wrote lib/y.rb"`. RULED 2026-10-16 (Jared): "path position" is defined by the value, not by the consumer. There is no list of path-taking calls. A path value is: a string literal, taken whole, with no space and no "://", that either contains "/" or ends in a file extension (for example `project.yaml`, `y.rb`); OR a `File.join` / `Pathname#/` expression whose trailing literal segments join to such a value (`File.join(@repo, "lib", "y.rb")` has the value `lib/y.rb`). Interpolated strings and heredocs are not counted. Group by value per file; fire at 3 or more places. A literal that is a path but sits in a non-file call (`deleted: ["lib/y.rb"]`) counts: it is the same fact. A string that contains a path inside other text (`"wrote lib/y.rb"`) does not count: it is not equal to the value. Tier: this cop is judgment-tier, not must-pass. It will have some false positives. It must NOT be enabled in the gate config `plugins/os-sdlc/.rubocop.yml`, and it must never reach the fix-lints worklist. It runs only on occasional sweep passes (see the "Sweeps" section of the fix-lints skill) through a separate sweep config and runner that this ticket adds. The lint lesson `sdlc-dry-repeated-path-literal` is the fix guidance; when the agent finds no smell at a reported site it files a false-positive ticket. Before enabling the cop in the sweep config, run it over `plugins/` and classify every hit by hand; tighten the value rule for each false hit, never add an allowlist. Required work: 1. Add the renamed cop file — check: present `RepeatedPathLiteral` in `plugins/os-sdlc/lib/os_sdlc/cops/*.rb` 2. Remove the old cop name from the config — check: absent `RepeatedLiteralArgument` in `plugins/os-sdlc/.rubocop.yml` 3. The cop is absent from the gate config — check: absent `RepeatedPathLiteral` in `plugins/os-sdlc/.rubocop.yml` 4. The sweep config enables the cop — check: present `RepeatedPathLiteral` in `plugins/os-sdlc/.rubocop-sweep.yml` 5. The sweep runner exists — check: present `rubocop` in `plugins/os-sdlc/bin/lint-sweep` ## Acceptance criteria 1. Given `File.join(@repo, "lib", "y.rb")` in 3 places and `["lib/y.rb"]` in 6 other path positions of one file, when the cop runs, then it reports the value `lib/y.rb`. 2. Given `File.join(@repo, ".sdlc", "project.yaml")` in 5 places and `".sdlc/project.yaml"` in a path position, when the cop runs, then it reports the value `.sdlc/project.yaml`. 3. Given `File.join(@repo, "lib", "y.rb")`, `File.join(@repo, "lib", "z.rb")`, and `File.join(@repo, "lib", "w.rb")`, when the cop runs, then it reports nothing. 4. Given `"lib/y.rb"` inside a log or message string 3 times, when the cop runs, then it reports nothing. 5. Given two classes that each define `def project_file = File.join(@repo, ".sdlc", "project.yaml")` and one string form of the same path, when the cop runs, then it reports the value. 6. Given `deleted: ["lib/y.rb"]` in 3 places of one file, when the cop runs, then it reports the value `lib/y.rb`. 7. Given the gate config, when `bin/lint-worklist` runs, then `RepeatedPathLiteral` offenses do not appear in any phase. 8. Given `bin/lint-sweep` is run on a directory, when the cop finds a repeated path value, then the offense is printed with the cop name and nothing exits non-zero as a gate. ## Owner sketch ```ruby class RepeatedPathLiteral < RuboCop::Cop::Base def on_new_investigation end ``` ## Non-goals Fixes at the #1004 sites. The lesson text (drafted separately). ## Origin - Skeptic: VERDICT: CREATE (user-requested ticket) - Trigger: lint-lesson analysis of the #1004 offense (docs/research/lint-lessons/sdlc-dry-repeated-literal-argument.md) - Improvised this session: none - Chain: segment counted, whole path missed ← cop groups File.join string arguments by value ← DESIGN (analysis sections 3, 6, 7; Jared ruling 2026-10-15) - Root candidate: none - Where: plugins/os-sdlc/lib/os_sdlc/cops/repeated_literal_argument.rb - Session: 270dfee2-6a92-4e87-828c-9ac8c32c1cd7 - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os--claude-worktrees-prism-architect/270dfee2-6a92-4e87-828c-9ac8c32c1cd7.jsonl
Author
Owner

Ruling 2026-10-16 (Jared, via session 983d18f5): path position is defined by value, not consumer; the cop is judgment-tier and runs only in a sweep pass, never in the gate. Approach, required work, and acceptance criteria updated in the body. Build starts in worktree lint-lessons.

Ruling 2026-10-16 (Jared, via session 983d18f5): path position is defined by value, not consumer; the cop is judgment-tier and runs only in a sweep pass, never in the gate. Approach, required work, and acceptance criteria updated in the body. Build starts in worktree lint-lessons.
Author
Owner

ADR constraints for the sweep runner (from /os-adr:find, 2026-10-16; no new ADR needed): (1) ADR-0082: bin/lint-sweep resolves the target from the invoking project's .sdlc/project.yaml and layers plugin .rubocop.yml first, target config second, the same way bin/lint-worklist does; it installs nothing into the target. (2) ADR-0066: the sweep reports only; it never writes Exclude entries into a target .rubocop.yml. (3) ADR-0179: lint_config default is nil and a configured file that is missing raises HarnessError. The sweep config plugins/os-sdlc/.rubocop-sweep.yml inherits the gate config's cop requires and enables only judgment-tier cops. Adding a non-gating tier to Worklist::CopPhase is a habit change, no ADR.

ADR constraints for the sweep runner (from /os-adr:find, 2026-10-16; no new ADR needed): (1) ADR-0082: bin/lint-sweep resolves the target from the invoking project's .sdlc/project.yaml and layers plugin .rubocop.yml first, target config second, the same way bin/lint-worklist does; it installs nothing into the target. (2) ADR-0066: the sweep reports only; it never writes Exclude entries into a target .rubocop.yml. (3) ADR-0179: lint_config default is nil and a configured file that is missing raises HarnessError. The sweep config plugins/os-sdlc/.rubocop-sweep.yml inherits the gate config's cop requires and enables only judgment-tier cops. Adding a non-gating tier to Worklist::CopPhase is a habit change, no ADR.
Author
Owner

Work started 2026-10-16 in worktree lint-lessons (branch lint-lessons, based on prism-architect). Pipeline: /os-sdlc:implement 1014.

Work started 2026-10-16 in worktree lint-lessons (branch lint-lessons, based on prism-architect). Pipeline: /os-sdlc:implement 1014.
Author
Owner

Resolution

Done: Sdlc/Dry/RepeatedPathLiteral replaces RepeatedLiteralArgument: whole path values grouped per file, string and File.join spellings together, 3+ places, once per source line. Judgment tier: absent from the gate config, run by bin/lint-sweep with .rubocop-sweep.yml (advisory, exit 0 on offenses, exit 3 on rubocop failure). Lint lesson sdlc-dry-repeated-path-literal written. Value rule tightened after a probe over plugins/.

Evidence: Branch lint-lessons commits 5d6c440e (cop, sweep, tests) and 1cd2fe5b (lesson). Suite 1579 runs 0 failures 0 errors. Rubocop 0 offenses on changed files. Probe: 197 hits before tightening, 164 after, none in lib/. Design audit: 4 findings, all fixed. Implementation 51 escalated on a test defect (missing require in the cop test) and was hand-finished per the escalation procedure.

Follow-ups: #1020 lint-worklist UnmappedCop on os-sdlc target (filed, CREATE). Remaining judgment-tier false positives are lowercase slash identifiers such as project/cc-os; no syntactic rule separates them, accepted as the tier's residue. The 164 real hits are fixture paths repeated in test files; fixing them belongs to #1019 fixture strategy.

## Resolution **Done:** Sdlc/Dry/RepeatedPathLiteral replaces RepeatedLiteralArgument: whole path values grouped per file, string and File.join spellings together, 3+ places, once per source line. Judgment tier: absent from the gate config, run by bin/lint-sweep with .rubocop-sweep.yml (advisory, exit 0 on offenses, exit 3 on rubocop failure). Lint lesson sdlc-dry-repeated-path-literal written. Value rule tightened after a probe over plugins/. **Evidence:** Branch lint-lessons commits 5d6c440e (cop, sweep, tests) and 1cd2fe5b (lesson). Suite 1579 runs 0 failures 0 errors. Rubocop 0 offenses on changed files. Probe: 197 hits before tightening, 164 after, none in lib/. Design audit: 4 findings, all fixed. Implementation 51 escalated on a test defect (missing require in the cop test) and was hand-finished per the escalation procedure. **Follow-ups:** #1020 lint-worklist UnmappedCop on os-sdlc target (filed, CREATE). Remaining judgment-tier false positives are lowercase slash identifiers such as project/cc-os; no syntactic rule separates them, accepted as the tier's residue. The 164 real hits are fixture paths repeated in test files; fixing them belongs to #1019 fixture strategy.
jared closed this issue 2026-10-16 15:43:39 +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#1014
No description provided.