Skip to content

Commit f50e698

Browse files
Broaden OutputValidator hallucination check to cover more file extensions (closes #8) (#153)
The backticked-path regex only matched a fixed extension allowlist, so hallucinated references in languages and config formats outside that list (.tf, .tfvars, .hcl, .kts, .ini, .env, lockfiles, and more) were never considered and shipped through untouched. Replace the fixed-alternation regex with one that captures any path-shaped token plus its extension, then judge candidates against KNOWN_EXTENSIONS unioned with extensions derived from the scanned project's own source files. Add poetry.lock to the well-known basenames and drop the dead bare .env entry so directory-qualified config/.env is flagged. Silent stripping behavior is unchanged.
1 parent 5b83732 commit f50e698

3 files changed

Lines changed: 125 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
### Changed
1010
- **Provider-agnostic TUI symbols**: Renamed `AppState.ollamaStatus` to `llmProviderStatus`; renamed matching helpers in `WelcomeView` (`ollamaReady/Dot/Label/Detail` -> `llmProviderReady/Dot/Label/Detail`), `Footer` (`ollamaState` -> `llmProviderState`), and `SettingsView`. The system-check card now derives the service row label from the resolved provider's `displayName()` instead of hardcoding "Ollama". Error hints in `buildLlmErrorMessage` are driven by the resolved provider name (falling back to "the local model server") rather than hardcoding "Ollama". The `/settings` command palette description now reads "Configure LLM provider and remote standards repo" (closes #27).
1111

12+
### Fixed
13+
- **Hallucinated-path detection coverage**: The generated-context cleaner now recognizes file references in languages and config formats its fixed extension allowlist previously skipped (Terraform `.tf`/`.tfvars`/`.hcl`, Kotlin script `.kts`, `.ini`, `.env`, lockfiles, and more), so invented paths like `infra/variables.tf` or `config/app.ini` are stripped instead of shipping. Recognized extensions are now unioned with those used by the scanned project's own source files, so an unusual real extension is honored without hardcoding. Directory-qualified `config/.env` references are now flagged (bare `.env` remains a convention reference) (closes #8).
14+
1215
### Added
1316
- **Project readiness view model**: `ReadinessViewMapper` turns a `ReadinessResult` plus project identity (name, path, type label) into a presentation-ready `ProjectReadinessView` - badge status, reason lines, the single recommended action, and the valid action menu. The mapper is the single place that decides which actions each status permits (deriving both the recommended action and the menu from status, never copying the evaluator's pre-set action), so the dashboard, CLI `--json`, and MCP render identical badges and actions; `PREPARE` is never offered for `UNSUPPORTED` or `IGNORED` (closes #136).
1417
- **Workspace configuration store**: A `WorkspaceConfigService` backed by `~/.launchpad/config.yml` is now the single source of truth for which directories Launchpad scans. It persists workspace `roots` (replace the built-in defaults when set), `additionalRoots` (appended to defaults only when `roots` is unset), an `ignored` list, scan `depth` (clamped 1-5, default 3), and a `detectGitOnly` gate (default true). `~` and `~/<subpath>` expand to absolute paths on read; an absent file means "use defaults" and a parse failure warns and falls back to defaults instead of erroring. TUI project discovery and live search now read roots/depth/ignored from the store and only surface build-roots that are also git repositories when `detectGitOnly` is on (closes #125).

src/main/java/com/acltabontabon/launchpad/ai/OutputValidator.java

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,32 @@
1919
public final class OutputValidator {
2020

2121
private static final int MIN_CONTENT_CHARS = 120;
22+
// Matches any backticked, path-shaped token bearing a trailing extension.
23+
// Group 1 = full ref, group 2 = extension. The fixed extension allowlist
24+
// moved out of the regex into KNOWN_EXTENSIONS (plus per-project extensions
25+
// derived from the scan) - see findHallucinatedRefs. The prefix requires at
26+
// least one char before the final dot, so a bare `.env` token is never
27+
// matched and multi-dot paths (`infra/dev.tfvars`) resolve greedily.
2228
private static final Pattern BACKTICKED_PATH = Pattern.compile(
23-
"`([A-Za-z0-9_\\-./]+\\.(java|kt|py|ts|tsx|js|jsx|go|rs|cs|rb|swift|xml|yml|yaml|toml|json|md|properties))`");
29+
"`([A-Za-z0-9_\\-./]+\\.([A-Za-z0-9]+))`");
30+
// File extensions (lowercase, no dot) we recognize as real source / config
31+
// formats. A backticked token whose extension is outside this set - and not
32+
// present among the scanned project's own source files - is left untouched,
33+
// preserving the validator's conservative bias against false positives.
34+
private static final Set<String> KNOWN_EXTENSIONS = Set.of(
35+
// JVM
36+
"java", "kt", "kts", "gradle", "groovy", "scala",
37+
// Scripting / web
38+
"py", "ts", "tsx", "js", "jsx", "mjs", "cjs", "rb", "php",
39+
// Systems
40+
"go", "rs", "cs", "swift", "c", "h", "cpp", "hpp",
41+
// Data / scripts
42+
"sql", "sh", "bash",
43+
// Config / markup
44+
"xml", "yml", "yaml", "toml", "json", "json5", "properties",
45+
"ini", "env", "conf", "cfg", "tf", "tfvars", "hcl", "lock",
46+
// Docs / misc
47+
"md", "txt", "imports");
2448
// Spring profile config convention: application-<profile>.{properties,yml,yaml}.
2549
// These are real files in countless Spring projects even when the scanner
2650
// didn't find one - the model citing them is a convention reference, not a
@@ -59,13 +83,14 @@ public CleanResult cleanHallucinations(String content, ProjectContext ctx) {
5983
if (content == null || content.isBlank()) return new CleanResult(content, 0);
6084
Set<String> sourceSet = new HashSet<>(ctx.sourceFiles());
6185
Set<String> basenames = buildBasenameAllowlist(ctx);
86+
Set<String> extensions = buildExtensionAllowlist(ctx);
6287

6388
var lines = content.split("\n", -1);
6489
var out = new StringBuilder();
6590
int stripped = 0;
6691
for (int i = 0; i < lines.length; i++) {
6792
var line = lines[i];
68-
var hallucinated = findHallucinatedRefs(line, sourceSet, basenames);
93+
var hallucinated = findHallucinatedRefs(line, sourceSet, basenames, extensions);
6994
if (hallucinated.isEmpty()) {
7095
out.append(line);
7196
} else if (isBulletLine(line)) {
@@ -117,10 +142,13 @@ private static boolean isBulletLine(String line) {
117142
"requirements.txt", "setup.py", "pyproject.toml", "Pipfile",
118143
// Rust / Go / Ruby / .NET
119144
"Cargo.toml", "Cargo.lock", "go.mod", "go.sum",
120-
"Gemfile", "Gemfile.lock", "Rakefile",
145+
"Gemfile", "Gemfile.lock", "Rakefile", "poetry.lock",
121146
// Docker / infra
122147
"Dockerfile", "docker-compose.yml", "docker-compose.yaml",
123-
".gitignore", ".dockerignore", ".env", ".env.example"
148+
// Note: bare ".env" is intentionally NOT allowlisted - the path regex
149+
// never matches a bare dotfile token, so the entry only ever affected
150+
// directory-qualified refs like `config/.env`, which we want flagged.
151+
".gitignore", ".dockerignore", ".env.example"
124152
);
125153

126154
private static Set<String> buildBasenameAllowlist(ProjectContext ctx) {
@@ -134,11 +162,35 @@ private static Set<String> buildBasenameAllowlist(ProjectContext ctx) {
134162
return basenames;
135163
}
136164

137-
private static List<String> findHallucinatedRefs(String line, Set<String> sourceSet, Set<String> basenames) {
165+
/**
166+
* Recognized file extensions for this scan: the static {@link #KNOWN_EXTENSIONS}
167+
* set unioned with the extensions actually present among the project's source
168+
* files. The union means a project using an uncommon extension still has that
169+
* extension treated as a real file type, even when it is not hardcoded.
170+
*/
171+
private static Set<String> buildExtensionAllowlist(ProjectContext ctx) {
172+
Set<String> extensions = new HashSet<>(KNOWN_EXTENSIONS);
173+
for (var p : ctx.sourceFiles()) {
174+
int slash = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
175+
String basename = slash < 0 ? p : p.substring(slash + 1);
176+
int dot = basename.lastIndexOf('.');
177+
if (dot >= 0 && dot < basename.length() - 1) {
178+
extensions.add(basename.substring(dot + 1).toLowerCase());
179+
}
180+
}
181+
return extensions;
182+
}
183+
184+
private static List<String> findHallucinatedRefs(
185+
String line, Set<String> sourceSet, Set<String> basenames, Set<String> extensions
186+
) {
138187
var hits = new ArrayList<String>();
139188
Matcher m = BACKTICKED_PATH.matcher(line);
140189
while (m.find()) {
141190
String ref = m.group(1);
191+
// Only judge tokens whose extension we recognize as a real file
192+
// type; anything else is left untouched (e.g. `e.g.`, `v1.2`).
193+
if (!extensions.contains(m.group(2).toLowerCase())) continue;
142194
String basename = ref.contains("/") ? ref.substring(ref.lastIndexOf('/') + 1) : ref;
143195
if (sourceSet.contains(ref) || basenames.contains(basename)) continue;
144196
if (SPRING_PROFILE_CONFIG.matcher(basename).matches()) continue;

src/test/java/com/acltabontabon/launchpad/eval/OutputValidatorTest.java

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
import static org.assertj.core.api.Assertions.assertThat;
44

55
import com.acltabontabon.launchpad.ai.OutputValidator;
6+
import com.acltabontabon.launchpad.scanner.ProjectContext;
7+
import com.acltabontabon.launchpad.scanner.StackProfile;
68
import com.acltabontabon.launchpad.springboot.scanner.ProjectScanner;
79
import java.util.List;
10+
import java.util.Map;
811
import org.junit.jupiter.api.Test;
912

1013
/**
@@ -69,6 +72,68 @@ void cleanHallucinationsAllowsSpringProfileConfigConvention() throws Exception {
6972
assertThat(clean.content()).contains("application-prod.properties");
7073
}
7174

75+
@Test
76+
void cleanHallucinationsStripsInventedInfraAndConfigPaths() throws Exception {
77+
var root = FixtureSupport.fixturePath("spring-boot");
78+
var ctx = ProjectScanner.forTesting().scan(root.toString(), msg -> { });
79+
80+
// Each invented path uses an extension that the old fixed-allowlist regex
81+
// never matched (.tf, .tfvars, .kts, .ini, .env). A real fixture path is
82+
// mixed in to guard against over-stripping.
83+
String output = """
84+
Provision with `infra/variables.tf` and `infra/dev.tfvars`.
85+
Release via `gradle/scripts/release.kts`; tune `config/app.ini` and `config/.env`.
86+
The real service is `src/main/java/com/example/users/UserService.java`.
87+
""";
88+
89+
var clean = validator.cleanHallucinations(output, ctx);
90+
assertThat(clean.strippedCount()).isEqualTo(5);
91+
assertThat(clean.content())
92+
.doesNotContain("variables.tf")
93+
.doesNotContain("dev.tfvars")
94+
.doesNotContain("release.kts")
95+
.doesNotContain("app.ini")
96+
.doesNotContain("config/.env");
97+
assertThat(clean.content()).contains("UserService.java");
98+
}
99+
100+
@Test
101+
void cleanHallucinationsRecognizesProjectDerivedExtensions() {
102+
// `.fooext` is in no static allowlist - it is recognized only because the
103+
// scanned project lists a real source file with that extension.
104+
var ctx = new ProjectContext(
105+
"demo", "/tmp", StackProfile.unknown(),
106+
List.of("schema/model.fooext"),
107+
List.of(), Map.of(), List.of(), Map.of(), List.of(), null);
108+
109+
String output = """
110+
The schema lives in `schema/model.fooext`.
111+
But `docs/missing.fooext` does not exist.
112+
""";
113+
114+
var clean = validator.cleanHallucinations(output, ctx);
115+
assertThat(clean.strippedCount()).isEqualTo(1);
116+
assertThat(clean.content()).contains("schema/model.fooext");
117+
assertThat(clean.content()).doesNotContain("docs/missing.fooext");
118+
}
119+
120+
@Test
121+
void cleanHallucinationsStillStripsAlreadySupportedExtensions() throws Exception {
122+
// Regression guard: .go / .yaml already matched before this change.
123+
var root = FixtureSupport.fixturePath("spring-boot");
124+
var ctx = ProjectScanner.forTesting().scan(root.toString(), msg -> { });
125+
126+
String output = """
127+
See `cmd/server/main.go` and `config/app.yaml` for the invented bits.
128+
""";
129+
130+
var clean = validator.cleanHallucinations(output, ctx);
131+
assertThat(clean.strippedCount()).isEqualTo(2);
132+
assertThat(clean.content())
133+
.doesNotContain("main.go")
134+
.doesNotContain("app.yaml");
135+
}
136+
72137
@Test
73138
void passesWhenAllSectionsPresentAndPathsReal() throws Exception {
74139
var root = FixtureSupport.fixturePath("spring-boot");

0 commit comments

Comments
 (0)