Repository navigation
L4J-SHELL: two malformed Gradle exclude shapes still blank a real declaration #91
Description
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 shapeUNCLOSED_EXCLUDE_GRADLEpins. 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_GRADLEpassing 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
excludeto sit inside a dependency
closure needs brace-nesting — the parsingsignals.pydeliberately 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_LINEguards. - The behavior snapshot
tests/behavior/snapshots/langchain4j_gradle_skill.jsonis unaffected by
any of this — its fixture holds the well-formed discriminating shape.
Options
- Accept and keep documenting it (status quo, zero cost).
signals.py,
docs/adr/0007-l4j-shell-survives-the-graduation-rename.mdand the test docstring all name the
residue and link here. Defensible while both shapes stay unobserved. - 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. - 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. - Parse the braces. Closes both, and reverses the decision
signals.pyis built on. Only worth
it if a second Rule needs the nesting anyway. - 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
TestRefusingTheModuleInGradleIsNotDeclaringItstill passes -
tests/behavior/snapshots/langchain4j_gradle_skill.jsonstays 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
fix(langchain4j): read a Gradle exclude as a Refusal, not a declarati…
rodrigorjsf commentedon Aug 5, 2026
Triage: option 5 run, and it says zero
Both shapes were re-verified on current
main(d5e9682, not thed314eabthis body measures on),each with a control so the mechanism is isolated rather than assumed:
shell_artifact_declarations{}exclude(line deletedShellDeclaration(line=2, langchain4j-experimental-skills-shell){}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_CALLwas run over each filelooking 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 withone test and does not depend on a langchain4j coordinate being present.
The four multi-line matches are the four wrapped
exclude(calls #88 had already classified as filefilters. 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
langchain4jandexclude.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.pyis 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. Stateready-for-human— a human merges #93.
docs(claude): note that #88's survey corpus persists on disk
docs(adr): record that the Gradle exclude residue was measured, not a…