Skip to content

L4J-SHELL: two malformed Gradle exclude shapes still blank a real declaration #91

Closed

Description

@rodrigorjsf

Follow-up to #68, which landed the Gradle Refusal recognizer. The recognizer is tempered against the
#45 false negative, and the temper has a bound: two malformed-input shapes still blank a real
declaration. Both are accepted, documented and shipped — this issue carries the analysis so the
tradeoff can be revisited deliberately rather than rediscovered.

Finding

_GRADLE_EXCLUDE_CALL (src/skillspector/langchain4j/signals.py:129) blanks a Gradle exclude call
and its argument list before shell_artifact_declarations matches. Two inputs make that blanking
swallow a declaration the reader can see. Both measured against shell_artifact_declarations
directly, on d314eab.

A — an unclosed exclude( and an orphan ) with no brace between them.

dependencies {
    exclude(group: "dev.langchain4j"
    implementation 'dev.langchain4j:langchain4j-experimental-skills-shell:1.18.1-beta28'
    testImplementation 'junit:junit:4.13.2')
}

shell_artifact_declarations returns {}. The control — the same file with the exclude( line
deleted — returns ShellDeclaration(line=2, artifact_id='langchain4j-experimental-skills-shell').

B — a Groovy exclude line whose trailing comma is followed by a declaration.

dependencies {
    implementation('com.example:app:1.0') { exclude group: 'org.slf4j',
    implementation 'dev.langchain4j:langchain4j-experimental-skills-shell:1.18.1-beta28'
}

shell_artifact_declarations returns {}.

Reason

The recognizer has two branches, one per DSL, and each branch's boundary is what fails.

  • The parenthesised branch is \s*\((?:[^(){}]|\([^(){}]*\))*\). Barring { and } is what stops
    an unclosed call at its enclosing closure, which is the shape UNCLOSED_EXCLUDE_GRADLE pins. With
    no brace between the open paren and a later orphan ), there is nothing left to stop it.
  • The Groovy branch is (?!\s*\()(?:[^\n]*,[ \t]*\n)*[^\n]* — end of line, continued while the line
    ends in a comma. That continuation is what recognises the wrapped form (exclude group: 'x', on
    one line, module: 'y' on the next); it cannot tell a continued argument list from a following
    statement.

Both are the same class as the residue _refusal_subtree already accepts on the Maven side: an
unclosed tag and an orphan close inside one <dependency>, with a declaration between them. A
textual scan cannot tell it apart from well-formed input, and neither can a reader.

Impact

LOW as measured, and the direction is what makes it worth tracking: a false negative — a real
langchain4j-…shell… declaration goes unreported, and a Scan that reports nothing is
indistinguishable from a clean repository. That is the failure #45 exists to prevent, which is why
it is written down rather than left to a future reader of the regex.

Reaching it takes a build file Gradle itself would reject (A) or a line no formatter produces (B).
Both are absent from the 262 real build files #88 surveyed. Nothing regressed relative to main
before #68 for shape A in the general case — before the fix Gradle had no blanking at all, so this
is a new hole, not a pre-existing one.

Gotchas

  • UNCLOSED_EXCLUDE_GRADLE passing proves less than it looks. Its fixture puts the inner
    closure's } between the unclosed call and the declaration, so it pins the brace temper and
    nothing wider. A green suite is not evidence shape A is closed.
  • The obvious fix is the one L4J-SHELL: Gradle's exclude group:/module: form still reads as a declaration #68 rejected. Requiring the exclude to sit inside a dependency
    closure needs brace-nesting — the parsing signals.py deliberately exists without, as
    _refusal_subtree's docstring records for Maven.
  • Tightening the Groovy branch can invert the fix. Dropping the comma continuation would restore
    the false positive on the wrapped form, and anchoring the suppression to "a line holding
    exclude" reintroduces the same-line false negative that
    DECLARING_AND_EXCLUDING_GRADLE_ONE_LINE guards.
  • The behavior snapshot tests/behavior/snapshots/langchain4j_gradle_skill.json is unaffected by
    any of this — its fixture holds the well-formed discriminating shape.

Options

  1. Accept and keep documenting it (status quo, zero cost). signals.py,
    docs/adr/0007-l4j-shell-survives-the-graduation-rename.md and the test docstring all name the
    residue and link here. Defensible while both shapes stay unobserved.
  2. Bound the argument list by line count — cap a parenthesised list at the longest wrapped form
    the survey supports plus a margin. Cheap and textual; the cap is arbitrary, which is exactly what
    the Maven side avoided by choosing a semantic boundary.
  3. Bar the Groovy continuation from crossing a line that opens a declaration — a lookahead for
    the configuration-name shape (implementation, api, testImplementation, …). Closes B, needs
    its own vocabulary of configuration names, which is a new thing to keep true.
  4. Parse the braces. Closes both, and reverses the decision signals.py is built on. Only worth
    it if a second Rule needs the nesting anyway.
  5. Measure first. Extend Gradle has no corpus fixture, and #68's refusal spellings are unmeasured #88's survey to ask how often either shape occurs in the wild — the
    survey population is already downloaded and its filter script is described in the L4J-SHELL: Gradle's exclude group:/module: form still reads as a declaration #68 survey
    comment. Cheapest way to find out whether options 2–4 are worth anything.

Recommended first step is 5, then 1 if the population says zero.

Acceptance criteria

  • a decision is recorded — either this issue is closed as accepted, with ADR 0007 amended to
    say the residue was measured and kept, or one of options 2–4 lands
  • if code changes: shape A and shape B each get a test, and every case in
    TestRefusingTheModuleInGradleIsNotDeclaringIt still passes
  • tests/behavior/snapshots/langchain4j_gradle_skill.json stays byte-identical

Ticked during triage: option 1 taken in #93 — the residue is measured (262 files, zero
occurrences) and kept, ADR 0007 amended. No code changed, so the second criterion is satisfied
vacuously. make update-snapshots left the tree clean, so the snapshot is byte-identical.

Activity

  1. added
    bugSomething isn't working
    ready-for-agentAgent: ready for Claude to pick up and work on
    needs-triageTriage: issue was just created; untriaged
    and removed
    ready-for-agentAgent: ready for Claude to pick up and work on
    on Aug 5, 2026
  2. rodrigorjsf commented on Aug 5, 2026

    @rodrigorjsf
    OwnerAuthor

    This was generated by AI during triage.

    Triage: option 5 run, and it says zero

    Both shapes were re-verified on current main (d5e9682, not the d314eab this body measures on),
    each with a control so the mechanism is isolated rather than assumed:

    input shell_artifact_declarations
    shape A {}
    shape A, exclude( line deleted ShellDeclaration(line=2, langchain4j-experimental-skills-shell)
    shape B {}
    shape B, trailing comma dropped ShellDeclaration(line=3, langchain4j-experimental-skills-shell)

    A is the parenthesised branch with no brace to stop it; B is the comma continuation. Confirmed as
    written.

    Option 5 was then run against #88's population, which is still on disk — all 262 files. Rather than
    grepping for the two shapes literally, the shipped _GRADLE_EXCLUDE_CALL was run over each file
    looking for the hazard they share: a match spanning lines that swallows a line opening a dependency
    declaration (implementation, api, testImplementation, …). That criterion catches A and B with
    one test and does not depend on a langchain4j coordinate being present.

    files scanned: 262
    multi-line exclude matches: 4
    matches swallowing a declaration line: 0
    control A: detector fires = True
    control B: detector fires = True
    

    The four multi-line matches are the four wrapped exclude( calls #88 had already classified as file
    filters. The two controls are there because a silent detector and an absent shape look identical.

    The population is filtered, not universal — build files naming both langchain4j and exclude.
    A zero bounds how often these shapes occur near the code this Rule reads; it does not prove they
    cannot exist. The ADR amendment says rarity, not impossibility, for that reason.

    Disposition

    Option 1, which the measurement now makes honest — before it, "the residue was measured and kept"
    had no backing, and writing it would have been the documentation defect the #68 review caught.
    Options 2–4 stay rejected: two trade this residue for another (an arbitrary line cap; a vocabulary
    of configuration names to keep true) and the third reverses the no-brace-parsing decision
    signals.py is built on. Zero observed instances buys none of that.

    #93 amends ADR 0007. No code, no test, no snapshot change. This issue closes when it merges.

    Category moved bug → documentation: the remaining deliverable is prose. State ready-for-human
    — a human merges #93.

  3. added
    documentationImprovements or additions to documentation
    ready-for-humanHuman: ready for review, merge, deploy, or a judgment call
    and removed
    bugSomething isn't working
    needs-triageTriage: issue was just created; untriaged
    on Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationready-for-humanHuman: ready for review, merge, deploy, or a judgment call

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions