From d76dabc5b0fe695cd1948fe61cebc0d8569faacf Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 11:51:07 -0700 Subject: [PATCH 01/40] refactor(ingestion): make Java API manifest canonical Make the resource manifest the sole owner of complete Java API mirror records. Project it into Java and Bash consumers, reject malformed or incomplete catalogs, and document the ownership rules for future agents. --- AGENTS.md | 3 + docs/contracts/code-change.md | 3 +- docs/ingestion.md | 16 +- docs/pipeline-commands.md | 38 +-- scripts/fetch_all_docs.sh | 128 +++++----- scripts/lib/documentation_sources.sh | 218 ++++++++++++++++++ .../javachat/cli/DocumentationSetCatalog.java | 34 +-- .../javachat/config/DocsSourceRegistry.java | 96 +++++--- .../config/JavaApiDocumentationManifest.java | 180 +++++++++++++++ src/main/resources/docs-sources.properties | 8 +- .../java-api-documentation-sources.manifest | 4 + .../javachat/ExtractorQualityTest.java | 32 +-- .../javachat/StandaloneExtractionTest.java | 31 +-- .../JavaApiDocumentationSourceParityTest.java | 57 +++++ .../config/DocsSourceRegistryTest.java | 37 +++ .../JavaApiDocumentationManifestTest.java | 106 +++++++++ 16 files changed, 818 insertions(+), 173 deletions(-) create mode 100644 scripts/lib/documentation_sources.sh create mode 100644 src/main/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifest.java create mode 100644 src/main/resources/java-api-documentation-sources.manifest create mode 100644 src/test/java/com/williamcallahan/javachat/cli/JavaApiDocumentationSourceParityTest.java create mode 100644 src/test/java/com/williamcallahan/javachat/config/DocsSourceRegistryTest.java create mode 100644 src/test/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifestTest.java diff --git a/AGENTS.md b/AGENTS.md index c14abd8d..b0a1731a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ alwaysApply: true - [ZA1a-c] Zero Tolerance Policy (zero assumptions, validation workflow, forbidden practices) - [GT1a-l] Git, history safety, hooks/signing, lock files, and clean commits - [CC1a-d] Clean Code & DDD (Mandatory) +- [SS1a-j] Single Semantic Owner and Java API catalog ownership - [ID1a-d] Idiomatic Patterns & Defaults - [EV1a-f] Secrets and Env Vars (no secrets in properties; OpenAI/Qdrant via .env/env) - [RC1a-f] Root Cause Resolution (single implementation, no fallbacks, no shims/workarounds) @@ -78,6 +79,8 @@ alwaysApply: true - [SS1f] **Stop-Work Trigger**: If an implementation requires listing the same governed fields/keys/rules in a second place, stop and redesign before editing. - [SS1g] **No Positional-Null Sludge**: Constructor/factory calls with repeated placeholder nulls or low-legibility optional argument trains are prohibited; use named factories/builders, parameter objects, or bind/import the canonical owner. - [SS1h] **Whole-List Smell**: If a file "knows the whole list" of a governed concept and it is not the canonical owner, the design is presumed wrong and must be reduced or removed. +- [SS1i] **Java API Catalog Owner**: Define complete Java API mirror records only in `src/main/resources/java-api-documentation-sources.manifest`; consume that manifest from Java, shell, tests, and documentation. +- [SS1j] **Java API Catalog Projection**: Never recreate Java API releases, URLs, mirror paths, display names, or fetch parameters in properties, constants, arrays, fixtures, or documentation; verify projections with `--list-java-api-sources` and `JavaApiDocumentationSourceParityTest`. ## [ID1] Idiomatic Patterns & Defaults diff --git a/docs/contracts/code-change.md b/docs/contracts/code-change.md index 3d328adf..1a1a5063 100644 --- a/docs/contracts/code-change.md +++ b/docs/contracts/code-change.md @@ -6,7 +6,7 @@ description: "Evergreen contract for change decisions (new file vs edit), reposi # Code Change Policy Contract -See `AGENTS.md` ([FS1a-k], [FS1l], [AR1a-f], [ND1a-c], [CC1a-d], [AB1a-d], [RC1a-f], [CS1a-h]). +See `AGENTS.md` ([SS1a-j], [FS1a-k], [FS1l], [AR1a-f], [ND1a-c], [CC1a-d], [AB1a-d], [RC1a-f], [CS1a-h]). ## Non-negotiables (applies to every change) @@ -28,6 +28,7 @@ Use this as a hard rule, not a suggestion. | Logic change in stable code | Extract/replace via composition; keep stable code stable ([AB1c], [CC1a]) | Add flags, shims, or “compat” paths to hide uncertainty ([RC1b]) | | Touching a large/overloaded file | Extract at least one seam (new type + typed contract) ([FS1f], [FS1b]) | Grow the file further ([FS1f]) | | Reuse needed across features | Add a domain value object / explicit port / explicit service with intent-revealing name ([AB1b]) | Add `*Utils/*Helper/*Common/*Base*` grab bags ([FS1e]) | +| Governed catalog change | Edit its single canonical owner and project it everywhere else ([SS1a-c]) | Restate the inventory in code, scripts, tests, fixtures, or docs ([SS1b], [SS1h]) | ### When adding a method is allowed diff --git a/docs/ingestion.md b/docs/ingestion.md index 98b5c6d8..40c1cf76 100644 --- a/docs/ingestion.md +++ b/docs/ingestion.md @@ -23,11 +23,9 @@ Fetch all configured sources: make fetch-all ``` -This runs `scripts/fetch_all_docs.sh` (requires `wget`). Source URLs live in: - -- `src/main/resources/docs-sources.properties` - -See [pipeline-commands.md](pipeline-commands.md#scrape-fetch-html-mirrors) for flags (`--force`, `--include-quick`, `--no-clean`). +This runs `scripts/fetch_all_docs.sh` (requires `wget`). See the canonical source ownership and edit +workflow in [pipeline-commands.md](pipeline-commands.md#scrape-fetch-html-mirrors), along with flags +(`--force`, `--include-quick`, `--no-clean`). ## Process + upload to Qdrant @@ -46,14 +44,14 @@ This runs `scripts/process_all_to_qdrant.sh`, which: ### Doc set filtering (CLI) -Limit ingestion to specific doc sets: +List canonical complete Java API paths, then limit ingestion to a selected doc set: ```bash -DOCS_SETS=java25-complete make process-doc-sets -./scripts/process_all_to_qdrant.sh --doc-sets=java25-complete,spring-boot-complete +./scripts/fetch_all_docs.sh --list-java-api-sources +DOCS_SETS=relative/path/from/listing make process-doc-sets ``` -See [pipeline-commands.md](pipeline-commands.md#doc-set-filtering) for the full doc set ID table. +See [pipeline-commands.md](pipeline-commands.md#doc-set-filtering) for filtering and the canonical Java API listing command. ## Hybrid vector storage diff --git a/docs/pipeline-commands.md b/docs/pipeline-commands.md index 72e493ef..3cd197cb 100644 --- a/docs/pipeline-commands.md +++ b/docs/pipeline-commands.md @@ -14,7 +14,7 @@ Incremental runs are the default — unchanged content is skipped via SHA-256 ha | **Scrape** (mirror HTML) | `make fetch-all` | `make fetch-force` | | **Ingest** (chunk → embed → upload) | `make process-all` | Clear state, then `make process-all` ([details](#full-re-ingest)) | | **Both** | `make full-pipeline` | `make fetch-force`, then clear state, then `make process-all` | -| **Ingest subset** | `DOCS_SETS=java25-complete make process-doc-sets` | — | +| **Ingest subset** | Set `DOCS_SETS` to a catalog path, then run `make process-doc-sets` | — | | **Ingest GitHub repo** | `REPO_URL=https://github.com/owner/repo make process-github-repo` | — | --- @@ -22,7 +22,19 @@ Incremental runs are the default — unchanged content is skipped via SHA-256 ha ## Scrape (fetch HTML mirrors) The scrape phase mirrors upstream documentation into `data/docs/` using `wget`. -Source URLs are defined in `src/main/resources/docs-sources.properties`. + +### Java API source catalog + +Complete Java API mirror records are defined in +`src/main/resources/java-api-documentation-sources.manifest`; source URLs outside complete Java API mirrors are defined in +`src/main/resources/docs-sources.properties`. + +The manifest is the single semantic owner of each complete Java API release, remote URL, mirror path, +display name, and fetch policy. Add, change, or remove a complete Java API source by editing one manifest +row only. Do not add `JAVA*_API_BASE` properties or environment overrides, and do not duplicate the +manifest's rows or field values in properties, Java constants, shell arrays, tests, or docs. +Run `./scripts/fetch_all_docs.sh --list-java-api-sources` to inspect the exact rows consumed by the fetch +script; the CLI catalog and citation registry project the same rows and parity is enforced by tests. ### Make targets @@ -35,7 +47,7 @@ make fetch-force # Full: force refetch even if mirrors look complete ### Script flags ```bash -./scripts/fetch_all_docs.sh [--include-quick] [--no-clean] [--force] +./scripts/fetch_all_docs.sh [--include-quick] [--no-clean] [--force] [--list-java-api-sources] ``` | Flag | Effect | @@ -43,6 +55,7 @@ make fetch-force # Full: force refetch even if mirrors look complete | `--include-quick` | Also fetch small landing-page mirrors (Spring Boot/Framework/AI quick sets) | | `--no-clean` | Do not quarantine incomplete mirrors before refetching | | `--force` | Refresh all sources even if they look complete | +| `--list-java-api-sources` | Print configured Java API source projections without fetching | | `--help` | Show usage | ### What "incremental" means for scraping @@ -107,23 +120,18 @@ GitHub ingestion runs in headless CLI mode (`spring.main.web-application-type=no Limit ingestion to specific doc sets by ID or path: ```bash -# Single doc set -DOCS_SETS=java25-complete make process-doc-sets -./scripts/process_all_to_qdrant.sh --doc-sets=java25-complete - -# Multiple doc sets -./scripts/process_all_to_qdrant.sh --doc-sets=java25-complete,spring-boot-complete +# Complete Java API paths: copy relativeMirrorPath from the listing command +./scripts/fetch_all_docs.sh --list-java-api-sources +DOCS_SETS=relative/path/from/listing make process-doc-sets -# Path-style IDs -./scripts/process_all_to_qdrant.sh --doc-sets=java/java25-complete +# Non-Java example +./scripts/process_all_to_qdrant.sh --doc-sets=spring-boot-complete ``` -Available doc set IDs are defined in `DocumentationSetCatalog.java`. Common ones: +Complete Java API doc set paths are projected from the manifest. Common non-Java doc sets are: | ID | Content | |---|---| -| `java24-complete` | Java 24 complete API (Oracle Javadoc) | -| `java25-complete` | Java 25 complete API (Oracle Javadoc) | | `spring-boot-complete` | Spring Boot reference + API docs | | `spring-framework-complete` | Spring Framework reference + Javadoc | | `spring-ai-complete` | Spring AI reference + API (stable + 2.0) | @@ -230,7 +238,7 @@ To run the CLI directly: app_jar=$(ls -1 build/libs/*.jar | grep -v -- "-plain.jar" | head -1) # With doc set filtering -DOCS_SETS=java25-complete java -Dspring.profiles.active=cli -jar "$app_jar" +DOCS_SETS=spring-boot-complete java -Dspring.profiles.active=cli -jar "$app_jar" ``` The docs root defaults to `data/docs` unless `DOCS_DIR` is set. diff --git a/scripts/fetch_all_docs.sh b/scripts/fetch_all_docs.sh index 12fd7ad6..e045c636 100755 --- a/scripts/fetch_all_docs.sh +++ b/scripts/fetch_all_docs.sh @@ -1,7 +1,7 @@ #!/bin/bash # Consolidated Documentation Fetcher with Deduplication -# This script fetches all required documentation (Java 24/25, Spring ecosystem) +# This script fetches all required documentation (configured Java APIs, Spring ecosystem) # and ensures no redundant downloads by checking existing files set -euo pipefail @@ -12,9 +12,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/shell_bootstrap.sh" # shellcheck source=lib/env_loader.sh source "$SCRIPT_DIR/lib/env_loader.sh" +# shellcheck source=lib/documentation_sources.sh +source "$SCRIPT_DIR/lib/documentation_sources.sh" -# Centralized source URLs (single source of truth) +# Centralized source definitions RES_PROPS="$SCRIPT_DIR/../src/main/resources/docs-sources.properties" +JAVA_API_SOURCES_MANIFEST="$SCRIPT_DIR/../src/main/resources/java-api-documentation-sources.manifest" if [ -f "$RES_PROPS" ]; then preserve_process_env_then_source_file "$RES_PROPS" fi @@ -25,6 +28,7 @@ LOG_FILE="$SCRIPT_DIR/../fetch_all_docs.log" INCLUDE_QUICK="${INCLUDE_QUICK:-false}" CLEAN_INCOMPLETE="${CLEAN_INCOMPLETE:-true}" FORCE_REFRESH="${FORCE_REFRESH:-false}" +LIST_JAVA_API_SOURCES="false" for arg in "$@"; do case $arg in --include-quick) @@ -36,11 +40,15 @@ for arg in "$@"; do --force) FORCE_REFRESH="true" ;; + --list-java-api-sources) + LIST_JAVA_API_SOURCES="true" + ;; --help|-h) - echo "Usage: $0 [--include-quick] [--no-clean] [--force]" + echo "Usage: $0 [--include-quick] [--no-clean] [--force] [--list-java-api-sources]" echo " --include-quick : Also refresh small 'quick' doc mirrors" echo " --no-clean : Do not quarantine incomplete mirrors before refetch" echo " --force : Refresh even when mirrors look complete" + echo " --list-java-api-sources : Print canonical Java API source projections without fetching" exit 0 ;; *) @@ -51,16 +59,6 @@ for arg in "$@"; do esac done -echo "==============================================" -echo "Consolidated Documentation Fetcher" -echo "==============================================" -echo "Docs root: $DOCS_ROOT" -echo "Log file: $LOG_FILE" -echo "" - -# Initialize log -echo "[$(date)] Starting consolidated documentation fetch" > "$LOG_FILE" - extract_meta_version() { local file_path="$1" if [ -z "$file_path" ] || [ ! -f "$file_path" ]; then @@ -76,30 +74,6 @@ extract_meta_version() { echo "$line" | sed -E 's/.*content="([^"]+)".*/\1/' } -extract_javadoc_comment_version() { - local file_path="$1" - if [ -z "$file_path" ] || [ ! -f "$file_path" ]; then - echo "" - return 0 - fi - local line - line="$(grep -E '
- -
-
- -

{selectedLesson.title}

-
-
+
@@ -909,62 +889,6 @@ position: relative; } - .lesson-header { - flex-shrink: 0; - padding: var(--space-4) var(--space-6); - border-bottom: 1px solid var(--color-border-subtle); - background: var(--color-bg-secondary); - } - - .lesson-header-inner { - display: flex; - align-items: center; - gap: var(--space-4); - max-width: 1400px; - margin: 0 auto; - width: 100%; - } - - .back-btn { - flex-shrink: 0; - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-3); - background: transparent; - border: 1px solid var(--color-border-default); - border-radius: var(--radius-md); - font-size: var(--text-sm); - line-height: 1; - color: var(--color-text-secondary); - cursor: pointer; - transition: all var(--duration-fast) var(--ease-out); - } - - .back-btn:hover { - background: var(--color-bg-tertiary); - color: var(--color-text-primary); - } - - .back-btn svg { - width: 16px; - height: 16px; - } - - .lesson-title-header { - font-family: var(--font-serif); - font-size: var(--text-xl); - font-weight: 500; - color: var(--color-text-primary); - letter-spacing: var(--tracking-tight); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1; - margin: 0; - padding-bottom: 2px; - } - /* Two-column Layout */ .lesson-layout { flex: 1; @@ -1099,14 +1023,6 @@ font-size: var(--text-base); } - .lesson-header { - padding: var(--space-3) var(--space-4); - } - - .lesson-title-header { - font-size: var(--text-lg); - } - .lesson-content-panel { padding: var(--space-4); } @@ -1134,9 +1050,5 @@ .lesson-summary { font-size: var(--text-xs); } - - .back-btn span { - display: none; - } } diff --git a/frontend/src/lib/components/LearnView.test.ts b/frontend/src/lib/components/LearnView.test.ts index 05fe28a8..6242998b 100644 --- a/frontend/src/lib/components/LearnView.test.ts +++ b/frontend/src/lib/components/LearnView.test.ts @@ -53,6 +53,9 @@ describe("LearnView guided chat streaming stability", () => { const allLessonsButton = await findByRole("button", { name: "All Lessons" }); expect(allLessonsButton).toHaveAttribute("aria-label", "All Lessons"); expect(allLessonsButton).toHaveTextContent("All Lessons"); + + await fireEvent.click(allLessonsButton); + expect(await findByRole("button", { name: /test lesson/i })).toBeInTheDocument(); }); it("keeps the guided assistant message DOM node stable when the stream completes", async () => { From ae4b64ff64d3f5f28d45e0abadb22b66c99c2026 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 12:00:24 -0700 Subject: [PATCH 06/40] fix(streaming): commit exchanges after completion Record the user and assistant turns only after chat or guided streaming completes. This keeps the controller call sites aligned with the atomic memory API and leaves failed streams out of session history. --- .../java/com/williamcallahan/javachat/web/ChatController.java | 4 +--- .../javachat/web/GuidedLearningController.java | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/web/ChatController.java b/src/main/java/com/williamcallahan/javachat/web/ChatController.java index 9115b73e..7f3c7b90 100644 --- a/src/main/java/com/williamcallahan/javachat/web/ChatController.java +++ b/src/main/java/com/williamcallahan/javachat/web/ChatController.java @@ -121,8 +121,6 @@ public Flux> stream( List history = chatMemory.getHistory(sessionId); PIPELINE_LOG.info("[{}] Chat history loaded", requestToken); - chatMemory.addUser(sessionId, userQuery); - return Flux.defer(() -> { // Build structured prompt for intelligent truncation // Pass model hint to optimize RAG for token-constrained models @@ -191,7 +189,7 @@ public Flux> stream( citationEvent); }) .doOnComplete(() -> { - chatMemory.addAssistant(sessionId, fullResponse.toString()); + chatMemory.addExchange(sessionId, userQuery, fullResponse.toString()); PIPELINE_LOG.info("[{}] STREAMING COMPLETE", requestToken); }); } diff --git a/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java b/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java index db276d54..402d68a5 100644 --- a/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java +++ b/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java @@ -250,7 +250,6 @@ public Flux> stream( */ private Flux> streamGuidedResponse(String sessionId, String userQuery, String lessonSlug) { List history = chatMemory.getHistory(sessionId); - chatMemory.addUser(sessionId, userQuery); return Flux.defer(() -> { StringBuilder fullResponse = new StringBuilder(); @@ -308,7 +307,7 @@ private Flux> streamGuidedResponse(String sessionId, Str Flux.merge(runtimeStreamingEvents, dataEvents, heartbeats), citationEvent); }) - .doOnComplete(() -> chatMemory.addAssistant(sessionId, fullResponse.toString())); + .doOnComplete(() -> chatMemory.addExchange(sessionId, userQuery, fullResponse.toString())); }) .onErrorResume(error -> { Optional terminalFailureContext = From 5a2beb1d0f485ca2c69e05a8f88511c863b3db09 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 12:07:02 -0700 Subject: [PATCH 07/40] fix(ingestion): align manifest line ending parsing --- scripts/lib/documentation_sources.sh | 1 + .../config/JavaApiDocumentationManifest.java | 13 +++-- .../JavaApiDocumentationManifestTest.java | 55 +++++++++++++++---- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/scripts/lib/documentation_sources.sh b/scripts/lib/documentation_sources.sh index a0ed3d02..9d4f8966 100644 --- a/scripts/lib/documentation_sources.sh +++ b/scripts/lib/documentation_sources.sh @@ -70,6 +70,7 @@ load_java_api_documentation_sources() { local retained_relative_mirror_paths=("") while IFS= read -r manifest_line || [ -n "$manifest_line" ]; do manifest_line_number=$((manifest_line_number + 1)) + manifest_line="${manifest_line%$'\r'}" if [ "$manifest_line_number" -eq 1 ]; then manifest_header="$manifest_line" if [ -z "$manifest_header" ]; then diff --git a/src/main/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifest.java b/src/main/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifest.java index c9b25a51..9dd72c1e 100644 --- a/src/main/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifest.java +++ b/src/main/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifest.java @@ -35,16 +35,21 @@ static List load() { throw new IllegalStateException("Canonical Java API documentation source manifest is missing"); } - try (manifestStream; - BufferedReader manifestReader = - new BufferedReader(new InputStreamReader(manifestStream, StandardCharsets.UTF_8))) { - return parse(manifestReader.lines().toList()); + try { + return parse(manifestStream); } catch (IOException manifestReadError) { throw new IllegalStateException( "Canonical Java API documentation source manifest could not be read", manifestReadError); } } + static List parse(InputStream manifestStream) throws IOException { + try (BufferedReader manifestReader = + new BufferedReader(new InputStreamReader(manifestStream, StandardCharsets.UTF_8))) { + return parse(manifestReader.lines().toList()); + } + } + static List parse(List manifestLines) { if (manifestLines.size() < 2) { throw new IllegalStateException("Canonical Java API documentation source manifest has no records"); diff --git a/src/test/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifestTest.java b/src/test/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifestTest.java index d8626402..2453bade 100644 --- a/src/test/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifestTest.java +++ b/src/test/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifestTest.java @@ -1,9 +1,11 @@ package com.williamcallahan.javachat.config; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -22,6 +24,25 @@ class JavaApiDocumentationManifestTest { @TempDir Path temporaryDirectory; + @Test + void acceptsSharedCrLfManifestFixtureInJavaAndBash() throws IOException, InterruptedException { + String canonicalManifestText = Files.readString(CANONICAL_MANIFEST_PATH, StandardCharsets.UTF_8); + String crLfManifestText = canonicalManifestText.replace("\n", "\r\n"); + Path crLfManifestPath = temporaryDirectory.resolve("crlf.manifest"); + Files.writeString(crLfManifestPath, crLfManifestText, StandardCharsets.UTF_8); + + try (InputStream manifestStream = Files.newInputStream(crLfManifestPath)) { + List javaProjectionRows = JavaApiDocumentationManifest.parse(manifestStream).stream() + .map(DocsSourceRegistry.JavaApiDocumentationSource::toManifestRow) + .toList(); + List canonicalRows = canonicalManifestText.lines().skip(1).toList(); + assertEquals(canonicalRows, javaProjectionRows); + } + + ShellValidation shellValidation = runShellValidation(crLfManifestPath); + assertEquals(0, shellValidation.exitCode(), shellValidation.standardOutput()); + } + @Test void rejectsSharedInvalidManifestFixturesInJavaAndBash() throws IOException, InterruptedException { List canonicalManifestLines = Files.readAllLines(CANONICAL_MANIFEST_PATH, StandardCharsets.UTF_8); @@ -52,21 +73,29 @@ void rejectsSharedInvalidManifestFixturesInJavaAndBash() throws IOException, Int Path invalidManifestPath = temporaryDirectory.resolve("invalid-manifest-" + fixtureIndex + ".manifest"); Files.write(invalidManifestPath, invalidManifestLines, StandardCharsets.UTF_8); - Process bashValidation = new ProcessBuilder( - "/bin/bash", - "-c", - "source \"$1\"; load_java_api_documentation_sources \"$2\"", - "manifest-validation", - SHELL_INTERPRETER_PATH.toAbsolutePath().toString(), - invalidManifestPath.toString()) - .redirectErrorStream(true) - .start(); - String bashOutput = new String(bashValidation.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - int bashExitCode = bashValidation.waitFor(); - assertNotEquals(0, bashExitCode, "Bash accepted invalid fixture " + fixtureIndex + ": " + bashOutput); + ShellValidation shellValidation = runShellValidation(invalidManifestPath); + assertNotEquals( + 0, + shellValidation.exitCode(), + "Bash accepted invalid fixture " + fixtureIndex + ": " + shellValidation.standardOutput()); } } + private static ShellValidation runShellValidation(Path manifestPath) throws IOException, InterruptedException { + Process bashValidation = new ProcessBuilder( + "/bin/bash", + "-c", + "source \"$1\"; load_java_api_documentation_sources \"$2\"", + "manifest-validation", + SHELL_INTERPRETER_PATH.toAbsolutePath().toString(), + manifestPath.toString()) + .redirectErrorStream(true) + .start(); + String standardOutput = new String(bashValidation.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + int exitCode = bashValidation.waitFor(); + return new ShellValidation(exitCode, standardOutput); + } + private static List withLine(List manifestLines, int lineIndex, String replacementLine) { List changedManifestLines = new ArrayList<>(manifestLines); changedManifestLines.set(lineIndex, replacementLine); @@ -103,4 +132,6 @@ private static List withDuplicateMirrorPath(List manifestLines) changedManifestLines.add(String.join("|", duplicateMirrorColumns)); return List.copyOf(changedManifestLines); } + + private record ShellValidation(int exitCode, String standardOutput) {} } From 111a320b0da5b7ea07da155d2e93ac381b1b5285 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 12:07:18 -0700 Subject: [PATCH 08/40] test(retrieval): derive Javadoc URLs from catalog --- .../javachat/service/RetrievalServiceTest.java | 14 ++++++++------ .../util/JavadocTypeCanonicalizerTest.java | 5 ++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java index b62964a9..06f6fb4a 100644 --- a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java @@ -11,6 +11,7 @@ import static org.mockito.Mockito.when; import com.williamcallahan.javachat.config.AppProperties; +import com.williamcallahan.javachat.config.DocsSourceRegistry; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.ai.document.Document; @@ -56,6 +57,9 @@ void preservesDistinctSamePageChunksForRerankingAndDeduplicatesTheirCitations() AppProperties appProperties = new AppProperties(); RetrievalService retrievalService = new RetrievalService(hybridSearchService, appProperties, rerankerService, documentFactory); + String javaApiBaseUrl = + DocsSourceRegistry.javaApiDocumentationSources().getFirst().remoteBaseUrl(); + String stringJavadocUrl = javaApiBaseUrl + "java.base/java/lang/String.html"; Document urlOnlyDocument = Document.builder() .id("url-only") @@ -70,19 +74,19 @@ void preservesDistinctSamePageChunksForRerankingAndDeduplicatesTheirCitations() Document firstJavadocChunk = Document.builder() .id("first-javadoc-chunk") .text("First Javadoc chunk") - .metadata("url", "https://docs.oracle.com/en/java/javase/21/relnotes/21-0-2-relnotes.html") + .metadata("url", stringJavadocUrl) .metadata("hash", "first-content-hash") .build(); Document secondJavadocChunkWithDistinctHash = Document.builder() .id("second-javadoc-chunk") .text("Second Javadoc chunk") - .metadata("url", "https://docs.oracle.com/en/java/javase/21/relnotes/21-0-2-relnotes.html#assert(...)") + .metadata("url", stringJavadocUrl + "#assert(...)") .metadata("hash", "second-content-hash") .build(); Document sameContentHashWithDifferentUrl = Document.builder() .id("same-content-hash") .text("Duplicate content under another URL") - .metadata("url", "https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Object.html") + .metadata("url", javaApiBaseUrl + "java.base/java/lang/Object.html") .metadata("hash", "first-content-hash") .build(); List retrievalCandidates = List.of( @@ -105,9 +109,7 @@ void preservesDistinctSamePageChunksForRerankingAndDeduplicatesTheirCitations() List.of(urlOnlyDocument, firstJavadocChunk, secondJavadocChunkWithDistinctHash), retrievalOutcome.documents()); assertEquals(2, citationOutcome.citations().size()); - assertEquals( - "https://docs.oracle.com/en/java/javase/21/relnotes/21-0-2-relnotes.html", - citationOutcome.citations().get(1).getUrl()); + assertEquals(stringJavadocUrl, citationOutcome.citations().get(1).getUrl()); assertEquals("First Javadoc chunk", citationOutcome.citations().get(1).getSnippet()); assertEquals(0, citationOutcome.failedConversionCount()); } diff --git a/src/test/java/com/williamcallahan/javachat/util/JavadocTypeCanonicalizerTest.java b/src/test/java/com/williamcallahan/javachat/util/JavadocTypeCanonicalizerTest.java index de2cc736..19cbfe8d 100644 --- a/src/test/java/com/williamcallahan/javachat/util/JavadocTypeCanonicalizerTest.java +++ b/src/test/java/com/williamcallahan/javachat/util/JavadocTypeCanonicalizerTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.williamcallahan.javachat.config.DocsSourceRegistry; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -19,7 +20,9 @@ void canonicalizeTypeReturnsEmptyForGenericOnlyToken() { @Test void refineMemberAnchorUrlIgnoresGenericOnlyMethodParameterFragment() { - String classPageUrl = "https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/String.html"; + String classPageUrl = + DocsSourceRegistry.javaApiDocumentationSources().getFirst().remoteBaseUrl() + + "java.base/java/lang/String.html"; String surroundingDocText = "Use parse() to parse values."; String refinedUrl = From a97a5e398a6ecc8bdc2ce1d340a4e0b42079f17e Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 12:17:23 -0700 Subject: [PATCH 09/40] style(ingestion): name manifest record threshold --- .../javachat/config/JavaApiDocumentationManifest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifest.java b/src/main/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifest.java index 9dd72c1e..563a6b7b 100644 --- a/src/main/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifest.java +++ b/src/main/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifest.java @@ -25,6 +25,7 @@ final class JavaApiDocumentationManifest { private static final String MANIFEST_RESOURCE = "/java-api-documentation-sources.manifest"; private static final String MANIFEST_DELIMITER = "|"; private static final String MANIFEST_DELIMITER_REGEX = "\\|"; + private static final int MINIMUM_MANIFEST_LINE_COUNT = 2; private static final Pattern CANONICAL_UNSIGNED_INTEGER = Pattern.compile("(?:0|[1-9][0-9]*)"); private JavaApiDocumentationManifest() {} @@ -51,7 +52,7 @@ static List parse(InputStream manifestStream) throws } static List parse(List manifestLines) { - if (manifestLines.size() < 2) { + if (manifestLines.size() < MINIMUM_MANIFEST_LINE_COUNT) { throw new IllegalStateException("Canonical Java API documentation source manifest has no records"); } validateHeader(manifestLines.get(0)); From faf139300a9aa2e278156a3c4f2536e344e40079 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 12:26:59 -0700 Subject: [PATCH 10/40] feat(ingestion): honor per-source partial mirror policy Oracle Javadoc mirrors can legitimately land below their expected HTML minimum on an interrupted or throttled fetch, yet the fixed threshold treated any short mirror as a hard failure and quarantined the partial result, discarding work that incremental reruns could resume. Thread the manifest's seventh projection field (ALLOW_PARTIAL) through fetch_docs and its Oracle/generic helpers so sources flagged partial keep a validated short mirror with a warning instead of failing, and skip the pre-fetch quarantine that would erase it. Extract fetch_documentation_source to project one seven-field row into the fetch boundary and guard the script so it can be sourced by tests without executing. - Add partial_mirror_allowed parameter to validate_fetch_result, fetch_oracle_javadoc_seed, fetch_docs_mirror, and fetch_docs - Add fetch_documentation_source to parse and dispatch a seven-field row - Add scripts/test_java_api_fetch_projection.sh asserting manifest rows retain seven distinct fetch fields in order --- scripts/fetch_all_docs.sh | 106 +++++++++++++++------- scripts/test_java_api_fetch_projection.sh | 73 +++++++++++++++ 2 files changed, 146 insertions(+), 33 deletions(-) create mode 100755 scripts/test_java_api_fetch_projection.sh diff --git a/scripts/fetch_all_docs.sh b/scripts/fetch_all_docs.sh index e045c636..e6fe0595 100755 --- a/scripts/fetch_all_docs.sh +++ b/scripts/fetch_all_docs.sh @@ -160,11 +160,13 @@ quarantine_versioned_reference_subdirs() { # $2 - target directory (for HTML count) # $3 - human-readable name # $4 - minimum required HTML files (0 = no minimum) +# $5 - whether a validated partial mirror is accepted validate_fetch_result() { local wget_exit_code="$1" local target_dir="$2" local name="$3" local min_files="$4" + local partial_mirror_allowed="$5" local fetched_html_count fetched_html_count="$(count_html_files "$target_dir")" @@ -178,8 +180,12 @@ validate_fetch_result() { log "${GREEN}✓ $name fetched successfully: $fetched_html_count HTML files${NC}" if [ "$min_files" -gt 0 ] && [ "$fetched_html_count" -lt "$min_files" ]; then - log "${RED}✗ $name mirror is still incomplete after fetch: $fetched_html_count HTML files (expected $min_files+)${NC}" - return 1 + if [ "$partial_mirror_allowed" = "true" ]; then + log "${YELLOW}⚠ $name mirror is still incomplete after fetch: $fetched_html_count HTML files (expected $min_files+); keeping partial mirror for incremental reruns${NC}" + else + log "${RED}✗ $name mirror is still incomplete after fetch: $fetched_html_count HTML files (expected $min_files+)${NC}" + return 1 + fi fi return 0 } @@ -193,12 +199,14 @@ validate_fetch_result() { # $3 - human-readable name # $4 - --cut-dirs value # $5 - minimum required HTML files +# $6 - whether a validated partial mirror is accepted fetch_oracle_javadoc_seed() { local url="$1" local target_dir="$2" local name="$3" local cut_dirs="$4" local min_files="$5" + local partial_mirror_allowed="$6" local seed_file="$target_dir/.oracle-javadoc-seed.txt" log "${BLUE}ℹ Oracle Javadoc detected; generating explicit URL seed list...${NC}" @@ -229,7 +237,7 @@ fetch_oracle_javadoc_seed() { local wget_exit_code=$? cd - > /dev/null - validate_fetch_result "$wget_exit_code" "$target_dir" "$name" "$min_files" + validate_fetch_result "$wget_exit_code" "$target_dir" "$name" "$min_files" "$partial_mirror_allowed" } # Fetches documentation using wget --mirror for generic (non-Oracle) sites. @@ -241,6 +249,7 @@ fetch_oracle_javadoc_seed() { # $4 - --cut-dirs value # $5 - minimum required HTML files # $6 - reject regex (optional) +# $7 - whether a validated partial mirror is accepted fetch_docs_mirror() { local url="$1" local target_dir="$2" @@ -248,6 +257,7 @@ fetch_docs_mirror() { local cut_dirs="$4" local min_files="$5" local reject_regex="${6:-}" + local partial_mirror_allowed="$7" local wget_args=( --mirror \ @@ -280,7 +290,7 @@ fetch_docs_mirror() { local wget_exit_code=$? cd - > /dev/null - validate_fetch_result "$wget_exit_code" "$target_dir" "$name" "$min_files" + validate_fetch_result "$wget_exit_code" "$target_dir" "$name" "$min_files" "$partial_mirror_allowed" } # Dispatches documentation fetching: performs pre-fetch housekeeping (existing @@ -294,6 +304,7 @@ fetch_docs_mirror() { # $4 - --cut-dirs value # $5 - minimum required HTML files # $6 - reject regex (optional) +# $7 - whether a validated partial mirror is accepted fetch_docs() { local url="$1" local target_dir="$2" @@ -301,6 +312,7 @@ fetch_docs() { local cut_dirs="$4" local min_files="$5" local reject_regex="${6:-}" + local partial_mirror_allowed="$7" # Allow config-friendly placeholder for regex alternation without breaking our field delimiter. reject_regex="${reject_regex//__OR__/|}" @@ -311,7 +323,7 @@ fetch_docs() { if [ "$existing_count" -gt 0 ]; then log "${BLUE}ℹ Existing mirror: $existing_count HTML files${NC}" fi - if [ "$min_files" -gt 0 ] && [ "$existing_count" -gt 0 ] && [ "$existing_count" -lt "$min_files" ]; then + if [ "$partial_mirror_allowed" != "true" ] && [ "$min_files" -gt 0 ] && [ "$existing_count" -gt 0 ] && [ "$existing_count" -lt "$min_files" ]; then quarantine_incomplete_dir "$target_dir" "$name" "$existing_count" "$min_files" fi @@ -336,35 +348,70 @@ fetch_docs() { log "${GREEN}✓ $name already fetched: $existing_count HTML files (minimum: $min_files)${NC}" return 0 fi - fetch_oracle_javadoc_seed "$url" "$target_dir" "$name" "$cut_dirs" "$min_files" + fetch_oracle_javadoc_seed "$url" "$target_dir" "$name" "$cut_dirs" "$min_files" "$partial_mirror_allowed" else - fetch_docs_mirror "$url" "$target_dir" "$name" "$cut_dirs" "$min_files" "$reject_regex" + fetch_docs_mirror "$url" "$target_dir" "$name" "$cut_dirs" "$min_files" "$reject_regex" "$partial_mirror_allowed" + fi +} + +# Projects one seven-field documentation source row into the fetch boundary. +fetch_documentation_source() { + local documentation_source_projection="$1" + local fetch_projection_delimiters="${documentation_source_projection//[^|]/}" + if [ "${#fetch_projection_delimiters}" -ne 6 ]; then + log "${RED}✗ Documentation source projection must contain exactly seven fields${NC}" + return 1 + fi + + local documentation_source_url + local mirror_directory + local documentation_source_name + local cut_directories + local minimum_html_files + local reject_regex + local partial_mirror_allowed + IFS='|' read -r documentation_source_url mirror_directory documentation_source_name cut_directories minimum_html_files reject_regex partial_mirror_allowed <<< "$documentation_source_projection" + + if [ "$partial_mirror_allowed" != "true" ] && [ "$partial_mirror_allowed" != "false" ]; then + log "${RED}✗ Documentation source partial-mirror policy must be true or false${NC}" + return 1 fi + + echo "" + log "Processing: $documentation_source_name" + log "URL: $documentation_source_url" + log "Target: $mirror_directory" + + fetch_docs "$documentation_source_url" "$mirror_directory" "$documentation_source_name" "$cut_directories" "$minimum_html_files" "$reject_regex" "$partial_mirror_allowed" } +if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then + return 0 +fi + # Documentation sources configuration -# Format: URL|TARGET_DIR|NAME|CUT_DIRS|MIN_FILES|REJECT_REGEX +# Format: URL|TARGET_DIR|NAME|CUT_DIRS|MIN_FILES|REJECT_REGEX|ALLOW_PARTIAL DOC_SOURCES=( # Spring Boot (current) - "${SPRING_BOOT_REFERENCE_BASE:-https://docs.spring.io/spring-boot/reference/}|$DOCS_ROOT/spring-boot-complete|Spring Boot Reference (current)|1|50|" - "${SPRING_BOOT_API_BASE:-https://docs.spring.io/spring-boot/api/}|$DOCS_ROOT/spring-boot-complete|Spring Boot API (current)|1|7000|" + "${SPRING_BOOT_REFERENCE_BASE:-https://docs.spring.io/spring-boot/reference/}|$DOCS_ROOT/spring-boot-complete|Spring Boot Reference (current)|1|50||false" + "${SPRING_BOOT_API_BASE:-https://docs.spring.io/spring-boot/api/}|$DOCS_ROOT/spring-boot-complete|Spring Boot API (current)|1|7000||false" # Spring AI # Stable reference (1.1.x) - avoid pulling versioned reference subtrees (including 2.0) and SNAPSHOT content. - "${SPRING_AI_REFERENCE_BASE:-https://docs.spring.io/spring-ai/reference/}|$DOCS_ROOT/spring-ai-reference|Spring AI Reference (stable)|1|80|/spring-ai/reference/[0-9]__OR__/spring-ai/reference/[^/]*SNAPSHOT" + "${SPRING_AI_REFERENCE_BASE:-https://docs.spring.io/spring-ai/reference/}|$DOCS_ROOT/spring-ai-reference|Spring AI Reference (stable)|1|80|/spring-ai/reference/[0-9]__OR__/spring-ai/reference/[^/]*SNAPSHOT|false" # 2.0 reference (milestone) - avoid SNAPSHOT content. - "${SPRING_AI_REFERENCE_2_BASE:-https://docs.spring.io/spring-ai/reference/2.0/}|$DOCS_ROOT/spring-ai-reference-2|Spring AI Reference (2.0)|1|80|/spring-ai/reference/[^/]*SNAPSHOT" + "${SPRING_AI_REFERENCE_2_BASE:-https://docs.spring.io/spring-ai/reference/2.0/}|$DOCS_ROOT/spring-ai-reference-2|Spring AI Reference (2.0)|1|80|/spring-ai/reference/[^/]*SNAPSHOT|false" # API docs (stable + 2.x). Keep these separate to avoid quarantine/validation conflicts. - "${SPRING_AI_API_STABLE_BASE:-https://docs.spring.io/spring-ai/docs/current/api/}|$DOCS_ROOT/spring-ai-api-stable|Spring AI API (stable)|1|200|" - "${SPRING_AI_API_2_BASE:-https://docs.spring.io/spring-ai/docs/2.0.x/api/}|$DOCS_ROOT/spring-ai-api-2|Spring AI API (2.x)|1|200|" + "${SPRING_AI_API_STABLE_BASE:-https://docs.spring.io/spring-ai/docs/current/api/}|$DOCS_ROOT/spring-ai-api-stable|Spring AI API (stable)|1|200||false" + "${SPRING_AI_API_2_BASE:-https://docs.spring.io/spring-ai/docs/2.0.x/api/}|$DOCS_ROOT/spring-ai-api-2|Spring AI API (2.x)|1|200||false" # Spring Framework (current) - avoid pulling older reference versions under /reference/6.x, etc. - "${SPRING_FRAMEWORK_REFERENCE_BASE:-https://docs.spring.io/spring-framework/reference/}|$DOCS_ROOT/spring-framework-complete|Spring Framework Reference (current)|1|3000|/spring-framework/reference/[0-9]__OR__/spring-framework/reference/[^/]*SNAPSHOT" - "${SPRING_FRAMEWORK_API_BASE:-https://docs.spring.io/spring-framework/docs/current/javadoc-api/}|$DOCS_ROOT/spring-framework-complete|Spring Framework Javadoc (current)|1|7000|" + "${SPRING_FRAMEWORK_REFERENCE_BASE:-https://docs.spring.io/spring-framework/reference/}|$DOCS_ROOT/spring-framework-complete|Spring Framework Reference (current)|1|3000|/spring-framework/reference/[0-9]__OR__/spring-framework/reference/[^/]*SNAPSHOT|false" + "${SPRING_FRAMEWORK_API_BASE:-https://docs.spring.io/spring-framework/docs/current/javadoc-api/}|$DOCS_ROOT/spring-framework-complete|Spring Framework Javadoc (current)|1|7000||false" - "${JAVA25_RELEASE_NOTES_ISSUES_URL:-https://www.oracle.com/java/technologies/javase/25-relnote-issues.html}|$DOCS_ROOT/oracle/javase|Java 25 Release Notes Issues|3|1|" - "${IBM_JAVA25_ARTICLE_URL:-https://developer.ibm.com/articles/java-whats-new-java25/}|$DOCS_ROOT/ibm/articles|IBM Java 25 Overview|1|1|" - "${JETBRAINS_JAVA25_BLOG_URL:-https://blog.jetbrains.com/idea/2025/09/java-25-lts-and-intellij-idea/}|$DOCS_ROOT/jetbrains/idea/2025/09|JetBrains Java 25 Blog|3|1|" + "${JAVA25_RELEASE_NOTES_ISSUES_URL:-https://www.oracle.com/java/technologies/javase/25-relnote-issues.html}|$DOCS_ROOT/oracle/javase|Java 25 Release Notes Issues|3|1||false" + "${IBM_JAVA25_ARTICLE_URL:-https://developer.ibm.com/articles/java-whats-new-java25/}|$DOCS_ROOT/ibm/articles|IBM Java 25 Overview|1|1||false" + "${JETBRAINS_JAVA25_BLOG_URL:-https://blog.jetbrains.com/idea/2025/09/java-25-lts-and-intellij-idea/}|$DOCS_ROOT/jetbrains/idea/2025/09|JetBrains Java 25 Blog|3|1||false" ) load_java_api_documentation_sources "$JAVA_API_SOURCES_MANIFEST" @@ -377,10 +424,10 @@ fi if [ "$INCLUDE_QUICK" = "true" ]; then DOC_SOURCES+=( - "${SPRING_BOOT_REFERENCE_BASE:-https://docs.spring.io/spring-boot/reference/}|$DOCS_ROOT/spring-boot|Spring Boot Quick (reference landing)|1|1|" - "${SPRING_FRAMEWORK_REFERENCE_BASE:-https://docs.spring.io/spring-framework/reference/}|$DOCS_ROOT/spring-framework|Spring Framework Quick (reference landing)|1|1|/spring-framework/reference/[0-9]__OR__/spring-framework/reference/[^/]*SNAPSHOT" - "${SPRING_AI_REFERENCE_BASE:-https://docs.spring.io/spring-ai/reference/}|$DOCS_ROOT/spring-ai|Spring AI Quick (reference landing)|1|1|/spring-ai/reference/[0-9]__OR__/spring-ai/reference/[^/]*SNAPSHOT" - "${SPRING_AI_REFERENCE_2_BASE:-https://docs.spring.io/spring-ai/reference/2.0/}|$DOCS_ROOT/spring-ai-2|Spring AI Quick (2.0 landing)|1|1|/spring-ai/reference/[^/]*SNAPSHOT" + "${SPRING_BOOT_REFERENCE_BASE:-https://docs.spring.io/spring-boot/reference/}|$DOCS_ROOT/spring-boot|Spring Boot Quick (reference landing)|1|1||false" + "${SPRING_FRAMEWORK_REFERENCE_BASE:-https://docs.spring.io/spring-framework/reference/}|$DOCS_ROOT/spring-framework|Spring Framework Quick (reference landing)|1|1|/spring-framework/reference/[0-9]__OR__/spring-framework/reference/[^/]*SNAPSHOT|false" + "${SPRING_AI_REFERENCE_BASE:-https://docs.spring.io/spring-ai/reference/}|$DOCS_ROOT/spring-ai|Spring AI Quick (reference landing)|1|1|/spring-ai/reference/[0-9]__OR__/spring-ai/reference/[^/]*SNAPSHOT|false" + "${SPRING_AI_REFERENCE_2_BASE:-https://docs.spring.io/spring-ai/reference/2.0/}|$DOCS_ROOT/spring-ai-2|Spring AI Quick (2.0 landing)|1|1|/spring-ai/reference/[^/]*SNAPSHOT|false" ) fi @@ -403,19 +450,12 @@ log "Starting documentation fetch process..." echo "==============================================" # Process each documentation source -for source in "${DOC_SOURCES[@]}"; do - IFS='|' read -r url target_dir name cut_dirs min_files reject_regex <<< "$source" - - echo "" - log "Processing: $name" - log "URL: $url" - log "Target: $target_dir" - - if fetch_docs "$url" "$target_dir" "$name" "$cut_dirs" "$min_files" "${reject_regex:-}"; then +for documentation_source_projection in "${DOC_SOURCES[@]}"; do + if fetch_documentation_source "$documentation_source_projection"; then TOTAL_FETCHED=$((TOTAL_FETCHED + 1)) else TOTAL_FAILED=$((TOTAL_FAILED + 1)) - log "${RED}Error: Failed to fetch $name; auditing the remaining sources before exit${NC}" + log "${RED}Error: Failed to fetch documentation source; auditing the remaining sources before exit${NC}" fi done diff --git a/scripts/test_java_api_fetch_projection.sh b/scripts/test_java_api_fetch_projection.sh new file mode 100755 index 00000000..d7e2c696 --- /dev/null +++ b/scripts/test_java_api_fetch_projection.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +# Verifies that canonical Java API manifest rows retain their seven-field fetch projection. + +set -euo pipefail + +TEST_SCRIPT_DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$TEST_SCRIPT_DIRECTORY/.." && pwd)" +FETCH_SCRIPT="$PROJECT_ROOT/scripts/fetch_all_docs.sh" +JAVA_API_SOURCES_MANIFEST="$PROJECT_ROOT/src/main/resources/java-api-documentation-sources.manifest" +TEST_DOCS_ROOT="/canonical-test-docs" + +fail_java_api_fetch_projection_test() { + local failure_message="$1" + printf 'FAIL: %s\n' "$failure_message" >&2 + exit 1 +} + +test_script_arguments=("$@") +set -- +# shellcheck source=fetch_all_docs.sh +source "$FETCH_SCRIPT" +set -- "${test_script_arguments[@]}" + +log() { + : +} + +fetch_execution_arguments=() +fetch_docs() { + fetch_execution_arguments=("$@") +} + +JAVA_API_SOURCE_PROJECTIONS=() +load_java_api_documentation_sources "$JAVA_API_SOURCES_MANIFEST" +DOC_SOURCES=() +append_java_api_fetch_sources "$TEST_DOCS_ROOT" + +if [ "${#JAVA_API_SOURCE_PROJECTIONS[@]}" -ne "${#DOC_SOURCES[@]}" ]; then + fail_java_api_fetch_projection_test "fetch projections did not preserve the complete manifest order" +fi + +for java_api_source_index in "${!JAVA_API_SOURCE_PROJECTIONS[@]}"; do + manifest_projection="${JAVA_API_SOURCE_PROJECTIONS[$java_api_source_index]}" + documentation_fetch_projection="${DOC_SOURCES[$java_api_source_index]}" + fetch_projection_delimiters="${documentation_fetch_projection//[^|]/}" + if [ "${#fetch_projection_delimiters}" -ne 6 ]; then + fail_java_api_fetch_projection_test "Java API fetch projection at index $java_api_source_index must contain exactly seven fields" + fi + + fetch_execution_arguments=() + fetch_documentation_source "$documentation_fetch_projection" > /dev/null + if [ "${#fetch_execution_arguments[@]}" -ne 7 ]; then + fail_java_api_fetch_projection_test "Java API fetch execution at index $java_api_source_index must receive exactly seven fields" + fi + + actual_fetch_projection="$(IFS='|'; printf '%s' "${fetch_execution_arguments[*]}")" + if [ "$actual_fetch_projection" != "$documentation_fetch_projection" ]; then + fail_java_api_fetch_projection_test "Java API fetch execution at index $java_api_source_index diverged from the appended projection" + fi + + expected_allow_partial="${manifest_projection##*|}" + manifest_projection_without_allow_partial="${manifest_projection%|*}" + expected_reject_regex="${manifest_projection_without_allow_partial##*|}" + if [ "${fetch_execution_arguments[5]}" != "$expected_reject_regex" ]; then + fail_java_api_fetch_projection_test "Java API reject regex at index $java_api_source_index was not projected separately" + fi + if [ "${fetch_execution_arguments[6]}" != "$expected_allow_partial" ]; then + fail_java_api_fetch_projection_test "Java API partial-mirror policy at index $java_api_source_index was not projected separately" + fi +done + +printf 'PASS: Java API fetch projections retain seven distinct fields in manifest order.\n' From f9f71b104de4bb6397fb2f66b35e93140ba4f205 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 12:30:17 -0700 Subject: [PATCH 11/40] test(ingestion): assert partial mirror policy behavior The projection parity test proved the seventh field survived manifest projection but never exercised what the flag does, leaving the partial-mirror branch of validate_fetch_result and the pre-fetch quarantine guard in fetch_docs without behavioral coverage. Drive both policies directly: assert allowPartial=true keeps a validated below-minimum mirror while allowPartial=false rejects it, and that fetch_docs quarantines an incomplete mirror only when partial mirrors are disallowed. Use a mktemp docs root with an EXIT trap so the test leaves no artifacts behind. - Add validate_fetch_result true/false assertions for below-minimum counts - Add fetch_docs quarantine assertions gated on the partial-mirror policy - Replace the hardcoded docs root with a self-cleaning mktemp directory --- scripts/test_java_api_fetch_projection.sh | 47 +++++++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/scripts/test_java_api_fetch_projection.sh b/scripts/test_java_api_fetch_projection.sh index d7e2c696..27767efb 100755 --- a/scripts/test_java_api_fetch_projection.sh +++ b/scripts/test_java_api_fetch_projection.sh @@ -8,7 +8,8 @@ TEST_SCRIPT_DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$TEST_SCRIPT_DIRECTORY/.." && pwd)" FETCH_SCRIPT="$PROJECT_ROOT/scripts/fetch_all_docs.sh" JAVA_API_SOURCES_MANIFEST="$PROJECT_ROOT/src/main/resources/java-api-documentation-sources.manifest" -TEST_DOCS_ROOT="/canonical-test-docs" +TEST_DOCS_ROOT="$(mktemp -d)" +trap 'rm -rf "$TEST_DOCS_ROOT"' EXIT fail_java_api_fetch_projection_test() { local failure_message="$1" @@ -16,11 +17,9 @@ fail_java_api_fetch_projection_test() { exit 1 } -test_script_arguments=("$@") set -- # shellcheck source=fetch_all_docs.sh source "$FETCH_SCRIPT" -set -- "${test_script_arguments[@]}" log() { : @@ -70,4 +69,44 @@ for java_api_source_index in "${!JAVA_API_SOURCE_PROJECTIONS[@]}"; do fi done -printf 'PASS: Java API fetch projections retain seven distinct fields in manifest order.\n' +set -- +# shellcheck source=fetch_all_docs.sh +source "$FETCH_SCRIPT" + +log() { + : +} + +count_html_files() { + printf '5\n' +} + +if ! validate_fetch_result 0 "$TEST_DOCS_ROOT/partial" "Partial mirror" 10 true; then + fail_java_api_fetch_projection_test "allowPartial=true rejected a validated below-minimum mirror" +fi +if validate_fetch_result 0 "$TEST_DOCS_ROOT/complete-required" "Complete mirror" 10 false; then + fail_java_api_fetch_projection_test "allowPartial=false accepted a below-minimum mirror" +fi + +quarantine_capture_file="$TEST_DOCS_ROOT/quarantine.log" +quarantine_incomplete_dir() { + printf 'quarantined\n' >> "$quarantine_capture_file" +} +fetch_docs_mirror() { + return 0 +} + +if ! (fetch_docs "https://example.com/api/" "$TEST_DOCS_ROOT/partial" "Partial mirror" 1 10 "" true); then + fail_java_api_fetch_projection_test "allowPartial=true fetch dispatch failed" +fi +if [ -s "$quarantine_capture_file" ]; then + fail_java_api_fetch_projection_test "allowPartial=true quarantined an incremental mirror" +fi +if ! (fetch_docs "https://example.com/api/" "$TEST_DOCS_ROOT/complete-required" "Complete mirror" 1 10 "" false); then + fail_java_api_fetch_projection_test "allowPartial=false fetch dispatch failed" +fi +if [ ! -s "$quarantine_capture_file" ]; then + fail_java_api_fetch_projection_test "allowPartial=false did not quarantine an incomplete mirror" +fi + +printf 'PASS: Java API fetch projections retain all fields and enforce partial-mirror policy.\n' From 287def4c5f7daf42d2a97818ca43e101216f1fb1 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 12:31:17 -0700 Subject: [PATCH 12/40] fix(security): keep CSRF protection stateless Replace the session-coupled CSRF repository with Spring Security's cookie repository so valid cookie/header tokens survive stateless requests. Keep the browser-visible token lifetime and consistent JSON denial response on the verified framework boundary. - Configure SameSite and max-age through the cookie customizer - Remove server-session token metadata and expiry branching - Cover stateless success and missing-token failure through MockMvc --- .../web/security/CsrfAccessDeniedHandler.java | 27 ++- .../ExpiringCookieCsrfTokenRepository.java | 157 ------------------ .../javachat/config/SecurityConfig.java | 11 +- .../javachat/config/SecurityConfigTest.java | 92 ++++++++++ 4 files changed, 107 insertions(+), 180 deletions(-) delete mode 100644 src/main/java/com/williamcallahan/javachat/adapters/in/web/security/ExpiringCookieCsrfTokenRepository.java create mode 100644 src/test/java/com/williamcallahan/javachat/config/SecurityConfigTest.java diff --git a/src/main/java/com/williamcallahan/javachat/adapters/in/web/security/CsrfAccessDeniedHandler.java b/src/main/java/com/williamcallahan/javachat/adapters/in/web/security/CsrfAccessDeniedHandler.java index d01c6727..43d773c8 100644 --- a/src/main/java/com/williamcallahan/javachat/adapters/in/web/security/CsrfAccessDeniedHandler.java +++ b/src/main/java/com/williamcallahan/javachat/adapters/in/web/security/CsrfAccessDeniedHandler.java @@ -13,13 +13,9 @@ import org.springframework.security.web.access.AccessDeniedHandler; /** - * Returns JSON 403 responses with clear CSRF expiry messaging for API callers. - * - *

Uses request attributes set by the expiring CSRF repository to distinguish - * expired tokens from other CSRF failures.

+ * Returns JSON 403 responses with clear invalid-CSRF messaging for API callers. */ public final class CsrfAccessDeniedHandler implements AccessDeniedHandler { - private static final String CSRF_EXPIRED_MESSAGE = "CSRF token expired. Refresh the page and retry the request."; private static final String CSRF_INVALID_MESSAGE = "CSRF token missing or invalid. Refresh the page and retry the request."; @@ -37,27 +33,26 @@ public CsrfAccessDeniedHandler(ObjectMapper objectMapper) { /** * Writes a JSON 403 response tailored for CSRF failures. * - * @param request incoming HTTP request - * @param response outgoing HTTP response + * @param httpRequest incoming HTTP request + * @param httpResponse outgoing HTTP response * @param accessDeniedException access denied exception from Spring Security * @throws IOException when the response cannot be written * @throws ServletException when the servlet container rejects the write */ @Override public void handle( - HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) + HttpServletRequest httpRequest, + HttpServletResponse httpResponse, + AccessDeniedException accessDeniedException) throws IOException, ServletException { - if (response.isCommitted()) { + if (httpResponse.isCommitted()) { return; } - boolean csrfExpired = - Boolean.TRUE.equals(request.getAttribute(ExpiringCookieCsrfTokenRepository.EXPIRED_ATTRIBUTE)); - String message = csrfExpired ? CSRF_EXPIRED_MESSAGE : CSRF_INVALID_MESSAGE; - ApiErrorResponse payload = ApiErrorResponse.error(message); + ApiErrorResponse csrfError = ApiErrorResponse.error(CSRF_INVALID_MESSAGE); - response.setStatus(HttpStatus.FORBIDDEN.value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - objectMapper.writeValue(response.getOutputStream(), payload); + httpResponse.setStatus(HttpStatus.FORBIDDEN.value()); + httpResponse.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(httpResponse.getOutputStream(), csrfError); } } diff --git a/src/main/java/com/williamcallahan/javachat/adapters/in/web/security/ExpiringCookieCsrfTokenRepository.java b/src/main/java/com/williamcallahan/javachat/adapters/in/web/security/ExpiringCookieCsrfTokenRepository.java deleted file mode 100644 index b7d835f3..00000000 --- a/src/main/java/com/williamcallahan/javachat/adapters/in/web/security/ExpiringCookieCsrfTokenRepository.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.williamcallahan.javachat.adapters.in.web.security; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; -import java.time.Clock; -import java.time.Duration; -import java.util.Objects; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; -import org.springframework.security.web.csrf.CsrfToken; -import org.springframework.security.web.csrf.CsrfTokenRepository; - -/** - * Stores CSRF tokens in cookies while enforcing a fixed lifetime backed by the servlet session. - * - *

Uses the cookie repository for token transport while tracking the issued timestamp in the - * session so tokens expire after a bounded TTL instead of living for the entire session.

- */ -public final class ExpiringCookieCsrfTokenRepository implements CsrfTokenRepository { - /** - * Request attribute set when an expired token is detected so handlers can surface a clear message. - */ - public static final String EXPIRED_ATTRIBUTE = ExpiringCookieCsrfTokenRepository.class.getName() + ".EXPIRED"; - - private static final String SESSION_TOKEN_KEY = ExpiringCookieCsrfTokenRepository.class.getName() + ".TOKEN"; - private static final String SESSION_ISSUED_AT_KEY = - ExpiringCookieCsrfTokenRepository.class.getName() + ".ISSUED_AT"; - - private final CookieCsrfTokenRepository delegate; - private final Duration tokenTtl; - private final Clock clock; - - /** - * Creates a repository that expires CSRF tokens after the supplied TTL. - * - * @param delegate cookie-backed token repository - * @param tokenTtl token lifetime for session-bound validation - */ - public ExpiringCookieCsrfTokenRepository(CookieCsrfTokenRepository delegate, Duration tokenTtl) { - this(delegate, tokenTtl, Clock.systemUTC()); - } - - ExpiringCookieCsrfTokenRepository(CookieCsrfTokenRepository delegate, Duration tokenTtl, Clock clock) { - this.delegate = Objects.requireNonNull(delegate, "delegate"); - this.tokenTtl = Objects.requireNonNull(tokenTtl, "tokenTtl"); - if (tokenTtl.isZero() || tokenTtl.isNegative()) { - throw new IllegalArgumentException("tokenTtl must be positive"); - } - this.clock = Objects.requireNonNull(clock, "clock"); - } - - /** - * Generates a new CSRF token using the delegate repository. - * - * @param request incoming HTTP request - * @return newly generated CSRF token - */ - @Override - public CsrfToken generateToken(HttpServletRequest request) { - return delegate.generateToken(request); - } - - /** - * Persists the CSRF token in the response cookie and tracks its issue time in the session. - * - * @param token CSRF token to store, or null to delete - * @param request incoming HTTP request - * @param response outgoing HTTP response - */ - @Override - public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) { - if (token == null) { - clearSessionMetadata(request); - delegate.saveToken(null, request, response); - return; - } - - HttpSession session = request.getSession(true); - session.setAttribute(SESSION_TOKEN_KEY, token.getToken()); - session.setAttribute(SESSION_ISSUED_AT_KEY, clock.millis()); - delegate.saveToken(token, request, response); - } - - /** - * Loads the CSRF token when present and rejects it when the session metadata is expired. - * - * @param request incoming HTTP request - * @return CSRF token if valid, otherwise null to force re-issuance - */ - @Override - public CsrfToken loadToken(HttpServletRequest request) { - CsrfToken token = delegate.loadToken(request); - if (token == null) { - return null; - } - - HttpSession session = request.getSession(false); - if (session == null) { - return null; - } - - String sessionToken = readSessionToken(session); - Long issuedAtMillis = readIssuedAtMillis(session); - if (sessionToken == null || issuedAtMillis == null) { - return null; - } - - if (!sessionToken.equals(token.getToken())) { - return null; - } - - long nowMillis = clock.millis(); - if (isExpired(nowMillis, issuedAtMillis)) { - markExpired(request); - return null; - } - - return token; - } - - private boolean isExpired(long nowMillis, long issuedAtMillis) { - long ageMillis = nowMillis - issuedAtMillis; - return ageMillis > tokenTtl.toMillis(); - } - - private void clearSessionMetadata(HttpServletRequest request) { - HttpSession session = request.getSession(false); - if (session == null) { - return; - } - session.removeAttribute(SESSION_TOKEN_KEY); - session.removeAttribute(SESSION_ISSUED_AT_KEY); - } - - private String readSessionToken(HttpSession session) { - Object attribute = session.getAttribute(SESSION_TOKEN_KEY); - if (attribute instanceof String tokenText && !tokenText.isBlank()) { - return tokenText; - } - return null; - } - - private Long readIssuedAtMillis(HttpSession session) { - Object attribute = session.getAttribute(SESSION_ISSUED_AT_KEY); - if (attribute instanceof Long issuedAtMillis) { - return issuedAtMillis; - } - if (attribute instanceof Number issuedAtNumber) { - return issuedAtNumber.longValue(); - } - return null; - } - - private void markExpired(HttpServletRequest request) { - request.setAttribute(EXPIRED_ATTRIBUTE, Boolean.TRUE); - } -} diff --git a/src/main/java/com/williamcallahan/javachat/config/SecurityConfig.java b/src/main/java/com/williamcallahan/javachat/config/SecurityConfig.java index 2532999b..8d1eef7d 100644 --- a/src/main/java/com/williamcallahan/javachat/config/SecurityConfig.java +++ b/src/main/java/com/williamcallahan/javachat/config/SecurityConfig.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.williamcallahan.javachat.adapters.in.web.security.CsrfAccessDeniedHandler; import com.williamcallahan.javachat.adapters.in.web.security.CsrfTokenCookieFilter; -import com.williamcallahan.javachat.adapters.in.web.security.ExpiringCookieCsrfTokenRepository; import java.time.Duration; import java.util.List; import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; @@ -32,7 +31,7 @@ public class SecurityConfig { private static final String LIVENESS_ENDPOINT = "/actuator/health/liveness"; private static final String READINESS_ENDPOINT = "/actuator/health/readiness"; private static final String PROMETHEUS_ENDPOINT = "/actuator/prometheus"; - private static final long CSRF_TOKEN_TTL_MINUTES = 15L; + private static final Duration CSRF_COOKIE_MAX_AGE = Duration.ofMinutes(15L); /** * CORS configuration source for Spring Security filter chain integration. @@ -89,11 +88,9 @@ public SecurityFilterChain managementSecurityFilterChain( public SecurityFilterChain appSecurityFilterChain( HttpSecurity http, CorsConfigurationSource corsConfigurationSource, ObjectMapper objectMapper) throws Exception { - CookieCsrfTokenRepository cookieRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); - cookieRepository.setCookieCustomizer(cookie -> cookie.sameSite("Lax")); - Duration csrfTokenTtl = Duration.ofMinutes(CSRF_TOKEN_TTL_MINUTES); - ExpiringCookieCsrfTokenRepository csrfTokenRepository = - new ExpiringCookieCsrfTokenRepository(cookieRepository, csrfTokenTtl); + CookieCsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); + csrfTokenRepository.setCookieCustomizer( + csrfCookie -> csrfCookie.sameSite("Lax").maxAge(CSRF_COOKIE_MAX_AGE)); CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler(); CsrfAccessDeniedHandler accessDeniedHandler = new CsrfAccessDeniedHandler(objectMapper); diff --git a/src/test/java/com/williamcallahan/javachat/config/SecurityConfigTest.java b/src/test/java/com/williamcallahan/javachat/config/SecurityConfigTest.java new file mode 100644 index 00000000..812b1787 --- /dev/null +++ b/src/test/java/com/williamcallahan/javachat/config/SecurityConfigTest.java @@ -0,0 +1,92 @@ +package com.williamcallahan.javachat.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.williamcallahan.javachat.web.CsrfController; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Verifies that the browser-readable CSRF cookie remains valid across stateless requests. + */ +@WebMvcTest(controllers = CsrfController.class) +@Import({AppProperties.class, SecurityConfig.class, SecurityConfigTest.ProtectedPostController.class}) +class SecurityConfigTest { + private static final String CSRF_REFRESH_ENDPOINT = "/api/security/csrf"; + private static final String CSRF_PROTECTED_ENDPOINT = "/api/security/csrf-test"; + private static final String CSRF_COOKIE_NAME = "XSRF-TOKEN"; + private static final String CSRF_HEADER_NAME = "X-XSRF-TOKEN"; + private static final int CSRF_COOKIE_MAX_AGE_SECONDS = 900; + private static final String CSRF_COOKIE_SAME_SITE_POLICY = "Lax"; + private static final String CSRF_INVALID_MESSAGE = + "CSRF token missing or invalid. Refresh the page and retry the request."; + + @Autowired + MockMvc mockMvc; + + @Test + void acceptsCookieAndHeaderTokenWithoutServerSession() throws Exception { + MvcResult csrfRefreshExchange = mockMvc.perform(get(CSRF_REFRESH_ENDPOINT)) + .andExpect(status().isOk()) + .andReturn(); + + Cookie csrfCookie = csrfRefreshExchange.getResponse().getCookie(CSRF_COOKIE_NAME); + assertNotNull(csrfCookie); + assertEquals(CSRF_COOKIE_MAX_AGE_SECONDS, csrfCookie.getMaxAge()); + assertEquals(CSRF_COOKIE_SAME_SITE_POLICY, csrfCookie.getAttribute("SameSite")); + assertFalse(csrfCookie.isHttpOnly()); + assertNull(csrfRefreshExchange.getRequest().getSession(false)); + assertNull(csrfRefreshExchange.getResponse().getCookie("JSESSIONID")); + + MvcResult protectedPostExchange = mockMvc.perform(post(CSRF_PROTECTED_ENDPOINT) + .cookie(csrfCookie) + .header(CSRF_HEADER_NAME, csrfCookie.getValue())) + .andExpect(status().isNoContent()) + .andReturn(); + + assertNull(protectedPostExchange.getRequest().getSession(false)); + assertNull(protectedPostExchange.getResponse().getCookie("JSESSIONID")); + } + + @Test + void returnsJsonWhenCsrfTokenIsMissing() throws Exception { + mockMvc.perform(post(CSRF_PROTECTED_ENDPOINT)) + .andExpect(status().isForbidden()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(CSRF_INVALID_MESSAGE)); + } + + /** + * Exposes a harmless state-changing route so the real security filter chain can be tested. + */ + @RestController + public static final class ProtectedPostController { + + /** + * Confirms that Spring Security admitted a state-changing request. + * + * @return an empty success response + */ + @PostMapping(CSRF_PROTECTED_ENDPOINT) + public ResponseEntity acceptProtectedPost() { + return ResponseEntity.noContent().build(); + } + } +} From f204efce04b7dd86726f9a639811027aafbabaef Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 12:33:06 -0700 Subject: [PATCH 13/40] fix(ingestion): isolate manifest fetch execution --- scripts/fetch_all_docs.sh | 85 +++++++++++-------- scripts/test_java_api_fetch_projection.sh | 17 ++++ .../JavaApiDocumentationSourceParityTest.java | 11 +++ 3 files changed, 76 insertions(+), 37 deletions(-) diff --git a/scripts/fetch_all_docs.sh b/scripts/fetch_all_docs.sh index e6fe0595..94b1fbc3 100755 --- a/scripts/fetch_all_docs.sh +++ b/scripts/fetch_all_docs.sh @@ -4,8 +4,6 @@ # This script fetches all required documentation (configured Java APIs, Spring ecosystem) # and ensures no redundant downloads by checking existing files -set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/shell_bootstrap.sh @@ -18,9 +16,6 @@ source "$SCRIPT_DIR/lib/documentation_sources.sh" # Centralized source definitions RES_PROPS="$SCRIPT_DIR/../src/main/resources/docs-sources.properties" JAVA_API_SOURCES_MANIFEST="$SCRIPT_DIR/../src/main/resources/java-api-documentation-sources.manifest" -if [ -f "$RES_PROPS" ]; then - preserve_process_env_then_source_file "$RES_PROPS" -fi DOCS_ROOT="$SCRIPT_DIR/../data/docs" LOG_FILE="$SCRIPT_DIR/../fetch_all_docs.log" @@ -29,35 +24,39 @@ INCLUDE_QUICK="${INCLUDE_QUICK:-false}" CLEAN_INCOMPLETE="${CLEAN_INCOMPLETE:-true}" FORCE_REFRESH="${FORCE_REFRESH:-false}" LIST_JAVA_API_SOURCES="false" -for arg in "$@"; do - case $arg in - --include-quick) - INCLUDE_QUICK="true" - ;; - --no-clean) - CLEAN_INCOMPLETE="false" - ;; - --force) - FORCE_REFRESH="true" - ;; - --list-java-api-sources) - LIST_JAVA_API_SOURCES="true" - ;; - --help|-h) - echo "Usage: $0 [--include-quick] [--no-clean] [--force] [--list-java-api-sources]" - echo " --include-quick : Also refresh small 'quick' doc mirrors" - echo " --no-clean : Do not quarantine incomplete mirrors before refetch" - echo " --force : Refresh even when mirrors look complete" - echo " --list-java-api-sources : Print canonical Java API source projections without fetching" - exit 0 - ;; - *) - echo "Unknown option: $arg" - echo "Use --help for usage information" - exit 1 - ;; - esac -done + +parse_fetch_arguments() { + local fetch_argument + for fetch_argument in "$@"; do + case $fetch_argument in + --include-quick) + INCLUDE_QUICK="true" + ;; + --no-clean) + CLEAN_INCOMPLETE="false" + ;; + --force) + FORCE_REFRESH="true" + ;; + --list-java-api-sources) + LIST_JAVA_API_SOURCES="true" + ;; + --help|-h) + echo "Usage: $0 [--include-quick] [--no-clean] [--force] [--list-java-api-sources]" + echo " --include-quick : Also refresh small 'quick' doc mirrors" + echo " --no-clean : Do not quarantine incomplete mirrors before refetch" + echo " --force : Refresh even when mirrors look complete" + echo " --list-java-api-sources : Print canonical Java API source projections without fetching" + exit 0 + ;; + *) + echo "Unknown option: $fetch_argument" + echo "Use --help for usage information" + exit 1 + ;; + esac + done +} extract_meta_version() { local file_path="$1" @@ -178,7 +177,10 @@ validate_fetch_result() { return 1 fi - log "${GREEN}✓ $name fetched successfully: $fetched_html_count HTML files${NC}" + if [ "$fetched_html_count" -eq 0 ]; then + log "${RED}✗ $name fetch produced no HTML files${NC}" + return 1 + fi if [ "$min_files" -gt 0 ] && [ "$fetched_html_count" -lt "$min_files" ]; then if [ "$partial_mirror_allowed" = "true" ]; then log "${YELLOW}⚠ $name mirror is still incomplete after fetch: $fetched_html_count HTML files (expected $min_files+); keeping partial mirror for incremental reruns${NC}" @@ -187,6 +189,7 @@ validate_fetch_result() { return 1 fi fi + log "${GREEN}✓ $name fetched successfully: $fetched_html_count HTML files${NC}" return 0 } @@ -385,9 +388,12 @@ fetch_documentation_source() { fetch_docs "$documentation_source_url" "$mirror_directory" "$documentation_source_name" "$cut_directories" "$minimum_html_files" "$reject_regex" "$partial_mirror_allowed" } -if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then - return 0 +run_documentation_fetch() { +set -euo pipefail +if [ -f "$RES_PROPS" ]; then + preserve_process_env_then_source_file "$RES_PROPS" fi +parse_fetch_arguments "$@" # Documentation sources configuration # Format: URL|TARGET_DIR|NAME|CUT_DIRS|MIN_FILES|REJECT_REGEX|ALLOW_PARTIAL @@ -528,3 +534,8 @@ if [ "$TOTAL_FAILED" -gt 0 ]; then exit 1 fi echo "Next step: Run 'make run' or './scripts/process_all_to_qdrant.sh' to process and upload to Qdrant" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + run_documentation_fetch "$@" +fi diff --git a/scripts/test_java_api_fetch_projection.sh b/scripts/test_java_api_fetch_projection.sh index 27767efb..80ddcc5d 100755 --- a/scripts/test_java_api_fetch_projection.sh +++ b/scripts/test_java_api_fetch_projection.sh @@ -47,6 +47,15 @@ for java_api_source_index in "${!JAVA_API_SOURCE_PROJECTIONS[@]}"; do fail_java_api_fetch_projection_test "Java API fetch projection at index $java_api_source_index must contain exactly seven fields" fi + manifest_projection_without_release="${manifest_projection#*|}" + expected_remote_base_url="${manifest_projection_without_release%%|*}" + manifest_projection_after_remote_base_url="${manifest_projection_without_release#*|}" + expected_relative_mirror_path="${manifest_projection_after_remote_base_url%%|*}" + expected_fetch_projection="$expected_remote_base_url|$TEST_DOCS_ROOT/$expected_relative_mirror_path|${manifest_projection_after_remote_base_url#*|}" + if [ "$documentation_fetch_projection" != "$expected_fetch_projection" ]; then + fail_java_api_fetch_projection_test "Java API fetch projection at index $java_api_source_index diverged from the canonical manifest row" + fi + fetch_execution_arguments=() fetch_documentation_source "$documentation_fetch_projection" > /dev/null if [ "${#fetch_execution_arguments[@]}" -ne 7 ]; then @@ -77,6 +86,14 @@ log() { : } +count_html_files() { + printf '0\n' +} + +if validate_fetch_result 0 "$TEST_DOCS_ROOT/empty-partial" "Empty partial mirror" 10 true; then + fail_java_api_fetch_projection_test "allowPartial=true accepted an empty mirror" +fi + count_html_files() { printf '5\n' } diff --git a/src/test/java/com/williamcallahan/javachat/cli/JavaApiDocumentationSourceParityTest.java b/src/test/java/com/williamcallahan/javachat/cli/JavaApiDocumentationSourceParityTest.java index 0151426a..5b6ac615 100644 --- a/src/test/java/com/williamcallahan/javachat/cli/JavaApiDocumentationSourceParityTest.java +++ b/src/test/java/com/williamcallahan/javachat/cli/JavaApiDocumentationSourceParityTest.java @@ -53,5 +53,16 @@ void projectsCanonicalJavaApiSourcesIntoCliCatalogAndFetchScript() throws IOExce .toList(); assertEquals(manifestSourceRows, actualJavaProjections); assertEquals(manifestSourceRows, actualScriptProjections); + + Path fetchProjectionTestPath = + Path.of("scripts", "test_java_api_fetch_projection.sh").toAbsolutePath(); + ProcessBuilder fetchProjectionTestCommand = new ProcessBuilder("/bin/bash", fetchProjectionTestPath.toString()); + fetchProjectionTestCommand.redirectErrorStream(true); + Process fetchProjectionTestProcess = fetchProjectionTestCommand.start(); + String fetchProjectionTestOutput = + new String(fetchProjectionTestProcess.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + int fetchProjectionTestExitCode = fetchProjectionTestProcess.waitFor(); + + assertEquals(0, fetchProjectionTestExitCode, fetchProjectionTestOutput); } } From 6ab46824d65fc05785cac08ad9ac24975afab31c Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 12:40:16 -0700 Subject: [PATCH 14/40] test(ingestion): cover oracle javadoc partial mirror dispatch The projection test exercised the generic mirror path but left the Oracle Javadoc branch unverified, so a regression that dropped the partial-mirror policy while dispatching Oracle seeds or validating their fetch result would have gone unnoticed. Assert that fetch_docs forwards the partial-mirror policy to fetch_oracle_javadoc_seed, and that fetch_oracle_javadoc_seed in turn hands it to validate_fetch_result, by stubbing each boundary and capturing the propagated flag. - Capture the policy forwarded into fetch_oracle_javadoc_seed via fetch_docs - Capture the policy fetch_oracle_javadoc_seed forwards to validate_fetch_result --- scripts/test_java_api_fetch_projection.sh | 49 +++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/scripts/test_java_api_fetch_projection.sh b/scripts/test_java_api_fetch_projection.sh index 80ddcc5d..f7cbe63c 100755 --- a/scripts/test_java_api_fetch_projection.sh +++ b/scripts/test_java_api_fetch_projection.sh @@ -126,4 +126,53 @@ if [ ! -s "$quarantine_capture_file" ]; then fail_java_api_fetch_projection_test "allowPartial=false did not quarantine an incomplete mirror" fi +oracle_dispatch_capture_file="$TEST_DOCS_ROOT/oracle-dispatch-policy" +fetch_oracle_javadoc_seed() { + printf '%s\n' "$6" > "$oracle_dispatch_capture_file" +} +if ! (fetch_docs \ + "https://docs.oracle.com/en/java/javase/21/docs/api/" \ + "$TEST_DOCS_ROOT/oracle-dispatch" \ + "Java 21 API" \ + 5 \ + 10 \ + "" \ + true); then + fail_java_api_fetch_projection_test "Oracle Javadoc fetch dispatch failed" +fi +if [ "$(cat "$oracle_dispatch_capture_file")" != "true" ]; then + fail_java_api_fetch_projection_test "Oracle Javadoc dispatch dropped the partial-mirror policy" +fi + +set -- +# shellcheck source=fetch_all_docs.sh +source "$FETCH_SCRIPT" + +LOG_FILE="$TEST_DOCS_ROOT/oracle-seed-fetch.log" +oracle_validation_capture_file="$TEST_DOCS_ROOT/oracle-validation-policy" +python3() { + : > "$5" +} +wget() { + return 0 +} +validate_fetch_result() { + printf '%s\n' "$5" > "$oracle_validation_capture_file" +} + +mkdir -p "$TEST_DOCS_ROOT/oracle-validation" +if ! (cd "$TEST_DOCS_ROOT/oracle-validation" \ + && fetch_oracle_javadoc_seed \ + "https://docs.oracle.com/en/java/javase/21/docs/api/" \ + "$TEST_DOCS_ROOT/oracle-validation" \ + "Java 21 API" \ + 5 \ + 10 \ + true); then + fail_java_api_fetch_projection_test "Oracle Javadoc validation boundary failed" +fi +if [ "$(cat "$oracle_validation_capture_file")" != "true" ]; then + fail_java_api_fetch_projection_test "Oracle Javadoc validation dropped the partial-mirror policy" +fi + printf 'PASS: Java API fetch projections retain all fields and enforce partial-mirror policy.\n' From 0154de24ff757f1e7b330266121165937b51f733 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 12:52:20 -0700 Subject: [PATCH 15/40] fix(ingestion): block incomplete documentation runs Preserve nonempty partial mirrors for incremental retries without reporting them as completed fetches. Track partial sources separately and fail the fetch command before Qdrant ingestion until every configured minimum is satisfied. Extract metadata emission from the fetch orchestrator so the touched script retains one orchestration responsibility, and document the resulting pipeline contract. --- docs/pipeline-commands.md | 3 +- scripts/fetch_all_docs.sh | 97 ++++++--------------- scripts/lib/documentation_fetch_metadata.sh | 73 ++++++++++++++++ scripts/test_java_api_fetch_projection.sh | 24 ++++- 4 files changed, 122 insertions(+), 75 deletions(-) create mode 100644 scripts/lib/documentation_fetch_metadata.sh diff --git a/docs/pipeline-commands.md b/docs/pipeline-commands.md index 3cd197cb..7d28611b 100644 --- a/docs/pipeline-commands.md +++ b/docs/pipeline-commands.md @@ -61,7 +61,8 @@ make fetch-force # Full: force refetch even if mirrors look complete ### What "incremental" means for scraping - `wget --mirror --timestamping` skips files that haven't changed on the server. -- Sources with fewer HTML files than their configured minimum are quarantined and re-fetched. +- Sources with fewer HTML files than their configured minimum are quarantined and re-fetched when `allowPartial=false`. +- Sources with `allowPartial=true` retain nonempty, below-minimum mirrors for incremental reruns, but the fetch exits nonzero until they reach the configured minimum. Consequently, `make full-pipeline` stops before Qdrant ingestion while any retained mirror remains partial. - Oracle Javadoc uses a deterministic Python seed generator (`scripts/oracle_javadoc_seed.py`) to avoid incomplete recursive crawls. --- diff --git a/scripts/fetch_all_docs.sh b/scripts/fetch_all_docs.sh index 94b1fbc3..7b6c597a 100755 --- a/scripts/fetch_all_docs.sh +++ b/scripts/fetch_all_docs.sh @@ -12,6 +12,8 @@ source "$SCRIPT_DIR/lib/shell_bootstrap.sh" source "$SCRIPT_DIR/lib/env_loader.sh" # shellcheck source=lib/documentation_sources.sh source "$SCRIPT_DIR/lib/documentation_sources.sh" +# shellcheck source=lib/documentation_fetch_metadata.sh +source "$SCRIPT_DIR/lib/documentation_fetch_metadata.sh" # Centralized source definitions RES_PROPS="$SCRIPT_DIR/../src/main/resources/docs-sources.properties" @@ -24,6 +26,7 @@ INCLUDE_QUICK="${INCLUDE_QUICK:-false}" CLEAN_INCOMPLETE="${CLEAN_INCOMPLETE:-true}" FORCE_REFRESH="${FORCE_REFRESH:-false}" LIST_JAVA_API_SOURCES="false" +DOCUMENTATION_FETCH_PARTIAL_STATUS=2 parse_fetch_arguments() { local fetch_argument @@ -58,21 +61,6 @@ parse_fetch_arguments() { done } -extract_meta_version() { - local file_path="$1" - if [ -z "$file_path" ] || [ ! -f "$file_path" ]; then - echo "" - return 0 - fi - local line - line="$(grep -E '/dev/null | head -n 1 || true)" - if [ -z "$line" ]; then - echo "" - return 0 - fi - echo "$line" | sed -E 's/.*content="([^"]+)".*/\1/' -} - quarantine_incomplete_dir() { local dir="$1" local name="$2" @@ -152,14 +140,15 @@ quarantine_versioned_reference_subdirs() { } # Validates a wget fetch result: checks exit code, counts HTML files, and -# enforces the minimum-files threshold. Returns 0 on success, 1 on failure. +# enforces the minimum-files threshold. Returns 0 on success, 2 for a retained +# partial mirror, and 1 for any other failure. # # Arguments: # $1 - wget exit code # $2 - target directory (for HTML count) # $3 - human-readable name # $4 - minimum required HTML files (0 = no minimum) -# $5 - whether a validated partial mirror is accepted +# $5 - whether a nonempty partial mirror is retained for incremental reruns validate_fetch_result() { local wget_exit_code="$1" local target_dir="$2" @@ -184,6 +173,7 @@ validate_fetch_result() { if [ "$min_files" -gt 0 ] && [ "$fetched_html_count" -lt "$min_files" ]; then if [ "$partial_mirror_allowed" = "true" ]; then log "${YELLOW}⚠ $name mirror is still incomplete after fetch: $fetched_html_count HTML files (expected $min_files+); keeping partial mirror for incremental reruns${NC}" + return "$DOCUMENTATION_FETCH_PARTIAL_STATUS" else log "${RED}✗ $name mirror is still incomplete after fetch: $fetched_html_count HTML files (expected $min_files+)${NC}" return 1 @@ -252,7 +242,7 @@ fetch_oracle_javadoc_seed() { # $4 - --cut-dirs value # $5 - minimum required HTML files # $6 - reject regex (optional) -# $7 - whether a validated partial mirror is accepted +# $7 - whether a nonempty partial mirror is retained for incremental reruns fetch_docs_mirror() { local url="$1" local target_dir="$2" @@ -307,7 +297,7 @@ fetch_docs_mirror() { # $4 - --cut-dirs value # $5 - minimum required HTML files # $6 - reject regex (optional) -# $7 - whether a validated partial mirror is accepted +# $7 - whether a nonempty partial mirror is retained for incremental reruns fetch_docs() { local url="$1" local target_dir="$2" @@ -449,6 +439,7 @@ echo "[$(date)] Starting consolidated documentation fetch" > "$LOG_FILE" # Statistics TOTAL_FETCHED=0 +TOTAL_PARTIAL=0 TOTAL_FAILED=0 echo "" @@ -460,8 +451,14 @@ for documentation_source_projection in "${DOC_SOURCES[@]}"; do if fetch_documentation_source "$documentation_source_projection"; then TOTAL_FETCHED=$((TOTAL_FETCHED + 1)) else - TOTAL_FAILED=$((TOTAL_FAILED + 1)) - log "${RED}Error: Failed to fetch documentation source; auditing the remaining sources before exit${NC}" + documentation_fetch_status=$? + if [ "$documentation_fetch_status" -eq "$DOCUMENTATION_FETCH_PARTIAL_STATUS" ]; then + TOTAL_PARTIAL=$((TOTAL_PARTIAL + 1)) + log "${YELLOW}Partial documentation source preserved; completion remains blocked${NC}" + else + TOTAL_FAILED=$((TOTAL_FAILED + 1)) + log "${RED}Error: Failed to fetch documentation source; auditing the remaining sources before exit${NC}" + fi fi done @@ -471,66 +468,24 @@ echo "Documentation Fetch Summary" echo "==============================================" log "📊 Statistics:" log " - Newly fetched: $TOTAL_FETCHED" +log " - Partial: $TOTAL_PARTIAL" log " - Failed: $TOTAL_FAILED" log " - Include quick mirrors: $INCLUDE_QUICK" log " - Clean incomplete mirrors: $CLEAN_INCOMPLETE" -# Count total files -TOTAL_HTML=$(find "$DOCS_ROOT" -name "*.html" 2>/dev/null | wc -l | tr -d ' ') -TOTAL_FILES=$(find "$DOCS_ROOT" -type f 2>/dev/null | wc -l | tr -d ' ') - -SPRING_BOOT_REFERENCE_VERSION="$(extract_meta_version "$DOCS_ROOT/spring-boot-complete/reference/index.html")" -SPRING_FRAMEWORK_REFERENCE_VERSION="$(extract_meta_version "$DOCS_ROOT/spring-framework-complete/reference/index.html")" -SPRING_AI_REFERENCE_STABLE_VERSION="$(extract_meta_version "$DOCS_ROOT/spring-ai-reference/reference/index.html")" -SPRING_AI_REFERENCE_2_VERSION="$(extract_meta_version "$DOCS_ROOT/spring-ai-reference-2/reference/2.0/index.html")" -JAVA_JAVADOC_VERSIONS_METADATA="$(render_java_javadoc_versions_metadata "$DOCS_ROOT")" -JAVA_COMPLETE_DIRECTORIES_METADATA="$(render_java_complete_directories_metadata "$DOCS_ROOT")" - -log " - Total HTML files: $TOTAL_HTML" -log " - Total files: $TOTAL_FILES" -log " - Documentation root: $DOCS_ROOT" - echo "" -if [ "$TOTAL_FAILED" -eq 0 ]; then - log "${GREEN}✅ Documentation fetch complete!${NC}" -else +if [ "$TOTAL_FAILED" -gt 0 ]; then log "${RED}✗ Documentation fetch failed for $TOTAL_FAILED source(s)${NC}" +elif [ "$TOTAL_PARTIAL" -gt 0 ]; then + log "${YELLOW}⚠ Documentation fetch remains partial for $TOTAL_PARTIAL source(s)${NC}" +else + log "${GREEN}✅ Documentation fetch complete!${NC}" fi log "Check log for details: $LOG_FILE" -# Create a marker file with fetch metadata -METADATA_FILE="$DOCS_ROOT/.fetch_metadata.json" -cat > "$METADATA_FILE" << EOF -{ - "last_fetch": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", - "statistics": { - "newly_fetched": $TOTAL_FETCHED, - "failed": $TOTAL_FAILED, - "total_html_files": $TOTAL_HTML, - "total_files": $TOTAL_FILES - }, - "versions": { -$JAVA_JAVADOC_VERSIONS_METADATA, - "spring_boot_reference": "$SPRING_BOOT_REFERENCE_VERSION", - "spring_framework_reference": "$SPRING_FRAMEWORK_REFERENCE_VERSION", - "spring_ai_reference_stable": "$SPRING_AI_REFERENCE_STABLE_VERSION", - "spring_ai_reference_2": "$SPRING_AI_REFERENCE_2_VERSION" - }, - "directories": { -$JAVA_COMPLETE_DIRECTORIES_METADATA, - "spring_boot_complete": "$(find "$DOCS_ROOT/spring-boot-complete" -name "*.html" 2>/dev/null | wc -l | tr -d ' ')", - "spring_framework_complete": "$(find "$DOCS_ROOT/spring-framework-complete" -name "*.html" 2>/dev/null | wc -l | tr -d ' ')", - "spring_ai_reference_stable": "$(find "$DOCS_ROOT/spring-ai-reference" -name "*.html" 2>/dev/null | wc -l | tr -d ' ')", - "spring_ai_reference_2": "$(find "$DOCS_ROOT/spring-ai-reference-2" -name "*.html" 2>/dev/null | wc -l | tr -d ' ')", - "spring_ai_api_stable": "$(find "$DOCS_ROOT/spring-ai-api-stable" -name "*.html" 2>/dev/null | wc -l | tr -d ' ')", - "spring_ai_api_2": "$(find "$DOCS_ROOT/spring-ai-api-2" -name "*.html" 2>/dev/null | wc -l | tr -d ' ')" - } -} -EOF - -log "Metadata saved to: $METADATA_FILE" +write_documentation_fetch_metadata echo "" -if [ "$TOTAL_FAILED" -gt 0 ]; then +if [ "$TOTAL_FAILED" -gt 0 ] || [ "$TOTAL_PARTIAL" -gt 0 ]; then exit 1 fi echo "Next step: Run 'make run' or './scripts/process_all_to_qdrant.sh' to process and upload to Qdrant" diff --git a/scripts/lib/documentation_fetch_metadata.sh b/scripts/lib/documentation_fetch_metadata.sh new file mode 100644 index 00000000..7bee049f --- /dev/null +++ b/scripts/lib/documentation_fetch_metadata.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +# Writes fetch metadata from the completed documentation-fetch run. + +extract_meta_version() { + local file_path="$1" + if [ -z "$file_path" ] || [ ! -f "$file_path" ]; then + echo "" + return 0 + fi + local version_meta_tag + version_meta_tag="$(grep -E '/dev/null | head -n 1 || true)" + if [ -z "$version_meta_tag" ]; then + echo "" + return 0 + fi + echo "$version_meta_tag" | sed -E 's/.*content="([^"]+)".*/\1/' +} + +write_documentation_fetch_metadata() { + local total_html_files + total_html_files=$(find "$DOCS_ROOT" -name "*.html" 2>/dev/null | wc -l | tr -d ' ') + local total_files + total_files=$(find "$DOCS_ROOT" -type f 2>/dev/null | wc -l | tr -d ' ') + local spring_boot_reference_version + spring_boot_reference_version="$(extract_meta_version "$DOCS_ROOT/spring-boot-complete/reference/index.html")" + local spring_framework_reference_version + spring_framework_reference_version="$(extract_meta_version "$DOCS_ROOT/spring-framework-complete/reference/index.html")" + local spring_ai_reference_stable_version + spring_ai_reference_stable_version="$(extract_meta_version "$DOCS_ROOT/spring-ai-reference/reference/index.html")" + local spring_ai_reference_2_version + spring_ai_reference_2_version="$(extract_meta_version "$DOCS_ROOT/spring-ai-reference-2/reference/2.0/index.html")" + local java_javadoc_versions_metadata + java_javadoc_versions_metadata="$(render_java_javadoc_versions_metadata "$DOCS_ROOT")" + local java_complete_directories_metadata + java_complete_directories_metadata="$(render_java_complete_directories_metadata "$DOCS_ROOT")" + + log " - Total HTML files: $total_html_files" + log " - Total files: $total_files" + log " - Documentation root: $DOCS_ROOT" + + local metadata_file="$DOCS_ROOT/.fetch_metadata.json" + cat > "$metadata_file" << EOF +{ + "last_fetch": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "statistics": { + "newly_fetched": $TOTAL_FETCHED, + "partial": $TOTAL_PARTIAL, + "failed": $TOTAL_FAILED, + "total_html_files": $total_html_files, + "total_files": $total_files + }, + "versions": { +$java_javadoc_versions_metadata, + "spring_boot_reference": "$spring_boot_reference_version", + "spring_framework_reference": "$spring_framework_reference_version", + "spring_ai_reference_stable": "$spring_ai_reference_stable_version", + "spring_ai_reference_2": "$spring_ai_reference_2_version" + }, + "directories": { +$java_complete_directories_metadata, + "spring_boot_complete": "$(find "$DOCS_ROOT/spring-boot-complete" -name "*.html" 2>/dev/null | wc -l | tr -d ' ')", + "spring_framework_complete": "$(find "$DOCS_ROOT/spring-framework-complete" -name "*.html" 2>/dev/null | wc -l | tr -d ' ')", + "spring_ai_reference_stable": "$(find "$DOCS_ROOT/spring-ai-reference" -name "*.html" 2>/dev/null | wc -l | tr -d ' ')", + "spring_ai_reference_2": "$(find "$DOCS_ROOT/spring-ai-reference-2" -name "*.html" 2>/dev/null | wc -l | tr -d ' ')", + "spring_ai_api_stable": "$(find "$DOCS_ROOT/spring-ai-api-stable" -name "*.html" 2>/dev/null | wc -l | tr -d ' ')", + "spring_ai_api_2": "$(find "$DOCS_ROOT/spring-ai-api-2" -name "*.html" 2>/dev/null | wc -l | tr -d ' ')" + } +} +EOF + + log "Metadata saved to: $metadata_file" +} diff --git a/scripts/test_java_api_fetch_projection.sh b/scripts/test_java_api_fetch_projection.sh index f7cbe63c..a66825c6 100755 --- a/scripts/test_java_api_fetch_projection.sh +++ b/scripts/test_java_api_fetch_projection.sh @@ -98,13 +98,31 @@ count_html_files() { printf '5\n' } -if ! validate_fetch_result 0 "$TEST_DOCS_ROOT/partial" "Partial mirror" 10 true; then - fail_java_api_fetch_projection_test "allowPartial=true rejected a validated below-minimum mirror" +partial_validation_status=0 +if validate_fetch_result 0 "$TEST_DOCS_ROOT/partial" "Partial mirror" 10 true; then + fail_java_api_fetch_projection_test "allowPartial=true reported a below-minimum mirror as complete" +else + partial_validation_status=$? +fi +if [ "$partial_validation_status" -ne "$DOCUMENTATION_FETCH_PARTIAL_STATUS" ]; then + fail_java_api_fetch_projection_test "allowPartial=true did not return the partial fetch status" fi if validate_fetch_result 0 "$TEST_DOCS_ROOT/complete-required" "Complete mirror" 10 false; then fail_java_api_fetch_projection_test "allowPartial=false accepted a below-minimum mirror" fi +count_html_files() { + printf '10\n' +} + +if ! validate_fetch_result 0 "$TEST_DOCS_ROOT/complete-partial-allowed" "Complete partial-allowed mirror" 10 true; then + fail_java_api_fetch_projection_test "allowPartial=true rejected a complete mirror" +fi + +count_html_files() { + printf '5\n' +} + quarantine_capture_file="$TEST_DOCS_ROOT/quarantine.log" quarantine_incomplete_dir() { printf 'quarantined\n' >> "$quarantine_capture_file" @@ -175,4 +193,4 @@ if [ "$(cat "$oracle_validation_capture_file")" != "true" ]; then fail_java_api_fetch_projection_test "Oracle Javadoc validation dropped the partial-mirror policy" fi -printf 'PASS: Java API fetch projections retain all fields and enforce partial-mirror policy.\n' +printf 'PASS: Java API fetch projections preserve partial mirrors without reporting completion.\n' From f0c2ddba94c8e52d6ec346a459461f5d3b9d01ba Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 12:53:25 -0700 Subject: [PATCH 16/40] test(ingestion): cover partial run exit --- scripts/test_java_api_fetch_projection.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/scripts/test_java_api_fetch_projection.sh b/scripts/test_java_api_fetch_projection.sh index a66825c6..d28aa76b 100755 --- a/scripts/test_java_api_fetch_projection.sh +++ b/scripts/test_java_api_fetch_projection.sh @@ -193,4 +193,22 @@ if [ "$(cat "$oracle_validation_capture_file")" != "true" ]; then fail_java_api_fetch_projection_test "Oracle Javadoc validation dropped the partial-mirror policy" fi +run_summary_capture_file="$TEST_DOCS_ROOT/run-summary" +LOG_FILE="$TEST_DOCS_ROOT/full-run.log" +fetch_documentation_source() { + return "$DOCUMENTATION_FETCH_PARTIAL_STATUS" +} +write_documentation_fetch_metadata() { + printf '%s|%s|%s\n' "$TOTAL_FETCHED" "$TOTAL_PARTIAL" "$TOTAL_FAILED" > "$run_summary_capture_file" +} + +if (run_documentation_fetch > /dev/null 2>&1); then + fail_java_api_fetch_projection_test "a full fetch run reported success while every source remained partial" +fi + +IFS='|' read -r aggregated_fetched_count aggregated_partial_count aggregated_failed_count < "$run_summary_capture_file" +if [ "$aggregated_fetched_count" -ne 0 ] || [ "$aggregated_partial_count" -le 0 ] || [ "$aggregated_failed_count" -ne 0 ]; then + fail_java_api_fetch_projection_test "a full fetch run did not aggregate retained partial mirrors separately from failures" +fi + printf 'PASS: Java API fetch projections preserve partial mirrors without reporting completion.\n' From 8259404ff1d628ac079f7a58b89fa4d81f524ea1 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 13:32:19 -0700 Subject: [PATCH 17/40] fix(streaming): let request timeout govern SDK reads Remove the redundant SDK read deadline so provider gateways remain the sole owner of first-output and inter-output timing while the whole request keeps its configured bound. --- docs/configuration.md | 3 +- .../service/OpenAIStreamingService.java | 4 --- src/main/resources/application-dev.properties | 4 +-- src/main/resources/application.properties | 4 +-- .../OpenAIStreamingTimeoutContractTest.java | 29 +++++++++++++++++++ 5 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingTimeoutContractTest.java diff --git a/docs/configuration.md b/docs/configuration.md index 2b889dc5..3dfaf6ab 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,8 +27,7 @@ Common variables: - `OPENAI_BASE_URL` (default `https://api.openai.com/v1`) - `OPENAI_MODEL` (application fallback default `gpt-5.2`; shared-gateway alias `gemma-4-26b-a4b`) - `OPENAI_REASONING_EFFORT` (optional, GPT‑5 family) -- `OPENAI_STREAMING_REQUEST_TIMEOUT_SECONDS` (default `600`) -- `OPENAI_STREAMING_READ_TIMEOUT_SECONDS` (default `75`) +- `OPENAI_STREAMING_REQUEST_TIMEOUT_SECONDS` (default `600`; bounds the complete SDK call while provider gateways own first-output and inter-output deadlines) ### Researchly shared gateway diff --git a/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java b/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java index 1993e576..1763eddf 100644 --- a/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java +++ b/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java @@ -75,9 +75,6 @@ public class OpenAIStreamingService { @Value("${OPENAI_STREAMING_REQUEST_TIMEOUT_SECONDS:600}") private long streamingRequestTimeoutSeconds; - @Value("${OPENAI_STREAMING_READ_TIMEOUT_SECONDS:75}") - private long streamingReadTimeoutSeconds; - /** * LLM-gateway priority class sent as the {@code X-Tier} header on live chat * turns. The gateway queue treats untagged requests as {@code default} @@ -410,7 +407,6 @@ private Flux executeStreamingRequest( private Timeout streamingTimeout() { return Timeout.builder() .request(Duration.ofSeconds(Math.max(1, streamingRequestTimeoutSeconds))) - .read(Duration.ofSeconds(Math.max(1, streamingReadTimeoutSeconds))) .build(); } diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 65fb3369..1662cde6 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -17,8 +17,8 @@ app.clicky.enabled=false # Defaults are set via @Value annotations in OpenAIStreamingService.java; # override via environment variables GITHUB_MODELS_BASE_URL or OPENAI_BASE_URL. # Optional: set OPENAI_REASONING_EFFORT for GPT-5.2 family (e.g., high for gpt-5.2-pro). -# Streaming timeouts: OPENAI_STREAMING_REQUEST_TIMEOUT_SECONDS (default: 600), -# OPENAI_STREAMING_READ_TIMEOUT_SECONDS (default: 75). All set via env vars. +# Streaming timeout: OPENAI_STREAMING_REQUEST_TIMEOUT_SECONDS (default: 600). +# Provider gateways own first-output and inter-output timeout policy. # Provider ordering: LLM_PRIMARY_PROVIDER=github_models (default) or openai. # If both providers are configured, the secondary provider is used as explicit fallback. diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d2b041ee..27c44297 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -43,8 +43,8 @@ spring.ai.openai.chat.api-key=${GITHUB_TOKEN:${OPENAI_API_KEY:}} # GitHub model IDs should be provider-qualified (for example OPENAI/GPT-5 => openai/gpt-5). # OPENAI_MODEL defaults to gpt-5.2 and GITHUB_MODELS_CHAT_MODEL defaults to openai/gpt-5. # Optional: set OPENAI_REASONING_EFFORT for GPT-5.2 family (e.g., high for gpt-5.2-pro). -# Streaming timeouts: OPENAI_STREAMING_REQUEST_TIMEOUT_SECONDS (default: 600), -# OPENAI_STREAMING_READ_TIMEOUT_SECONDS (default: 75). All set via env vars. +# Streaming timeout: OPENAI_STREAMING_REQUEST_TIMEOUT_SECONDS (default: 600). +# Provider gateways own first-output and inter-output timeout policy. # Provider ordering: LLM_PRIMARY_PROVIDER=github_models (default) or openai. # If both providers are configured, the secondary provider is used as explicit fallback. diff --git a/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingTimeoutContractTest.java b/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingTimeoutContractTest.java new file mode 100644 index 00000000..715f2fa2 --- /dev/null +++ b/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingTimeoutContractTest.java @@ -0,0 +1,29 @@ +package com.williamcallahan.javachat.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +import com.openai.core.Timeout; +import com.williamcallahan.javachat.application.streaming.StreamingFailureReporter; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +/** Verifies that provider gateways remain the sole owner of streaming subphase deadlines. */ +class OpenAIStreamingTimeoutContractTest { + + @Test + void streamingReadDeadlineInheritsWholeRequestDeadline() { + OpenAIStreamingService streamingService = new OpenAIStreamingService( + mock(RateLimitService.class), + mock(OpenAiRequestFactory.class), + mock(OpenAiProviderRoutingService.class), + mock(StreamingFailureReporter.class)); + ReflectionTestUtils.setField(streamingService, "streamingRequestTimeoutSeconds", 600L); + + Timeout streamingTimeout = ReflectionTestUtils.invokeMethod(streamingService, "streamingTimeout"); + + assertEquals(Duration.ofSeconds(600), streamingTimeout.request()); + assertEquals(streamingTimeout.request(), streamingTimeout.read()); + } +} From 9b4bb5ee78308595b4b638fee6b1831fbc614a60 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 13:32:37 -0700 Subject: [PATCH 18/40] fix(security): preserve CSRF cookie deletion Use the framework's stateless session-cookie lifetime so logout can emit an immediate deletion cookie, and cover mismatched and single-sided token rejection. --- .../javachat/config/SecurityConfig.java | 5 +- .../javachat/config/SecurityConfigTest.java | 47 ++++++++++++++++--- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/config/SecurityConfig.java b/src/main/java/com/williamcallahan/javachat/config/SecurityConfig.java index 8d1eef7d..ba13232e 100644 --- a/src/main/java/com/williamcallahan/javachat/config/SecurityConfig.java +++ b/src/main/java/com/williamcallahan/javachat/config/SecurityConfig.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.williamcallahan.javachat.adapters.in.web.security.CsrfAccessDeniedHandler; import com.williamcallahan.javachat.adapters.in.web.security.CsrfTokenCookieFilter; -import java.time.Duration; import java.util.List; import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.context.annotation.Bean; @@ -31,7 +30,6 @@ public class SecurityConfig { private static final String LIVENESS_ENDPOINT = "/actuator/health/liveness"; private static final String READINESS_ENDPOINT = "/actuator/health/readiness"; private static final String PROMETHEUS_ENDPOINT = "/actuator/prometheus"; - private static final Duration CSRF_COOKIE_MAX_AGE = Duration.ofMinutes(15L); /** * CORS configuration source for Spring Security filter chain integration. @@ -89,8 +87,7 @@ public SecurityFilterChain appSecurityFilterChain( HttpSecurity http, CorsConfigurationSource corsConfigurationSource, ObjectMapper objectMapper) throws Exception { CookieCsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); - csrfTokenRepository.setCookieCustomizer( - csrfCookie -> csrfCookie.sameSite("Lax").maxAge(CSRF_COOKIE_MAX_AGE)); + csrfTokenRepository.setCookieCustomizer(csrfCookie -> csrfCookie.sameSite("Lax")); CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler(); CsrfAccessDeniedHandler accessDeniedHandler = new CsrfAccessDeniedHandler(objectMapper); diff --git a/src/test/java/com/williamcallahan/javachat/config/SecurityConfigTest.java b/src/test/java/com/williamcallahan/javachat/config/SecurityConfigTest.java index 812b1787..11cbe0ed 100644 --- a/src/test/java/com/williamcallahan/javachat/config/SecurityConfigTest.java +++ b/src/test/java/com/williamcallahan/javachat/config/SecurityConfigTest.java @@ -31,9 +31,9 @@ class SecurityConfigTest { private static final String CSRF_REFRESH_ENDPOINT = "/api/security/csrf"; private static final String CSRF_PROTECTED_ENDPOINT = "/api/security/csrf-test"; + private static final String LOGOUT_ENDPOINT = "/logout"; private static final String CSRF_COOKIE_NAME = "XSRF-TOKEN"; private static final String CSRF_HEADER_NAME = "X-XSRF-TOKEN"; - private static final int CSRF_COOKIE_MAX_AGE_SECONDS = 900; private static final String CSRF_COOKIE_SAME_SITE_POLICY = "Lax"; private static final String CSRF_INVALID_MESSAGE = "CSRF token missing or invalid. Refresh the page and retry the request."; @@ -43,13 +43,9 @@ class SecurityConfigTest { @Test void acceptsCookieAndHeaderTokenWithoutServerSession() throws Exception { - MvcResult csrfRefreshExchange = mockMvc.perform(get(CSRF_REFRESH_ENDPOINT)) - .andExpect(status().isOk()) - .andReturn(); - + MvcResult csrfRefreshExchange = requestCsrfCookie(); Cookie csrfCookie = csrfRefreshExchange.getResponse().getCookie(CSRF_COOKIE_NAME); assertNotNull(csrfCookie); - assertEquals(CSRF_COOKIE_MAX_AGE_SECONDS, csrfCookie.getMaxAge()); assertEquals(CSRF_COOKIE_SAME_SITE_POLICY, csrfCookie.getAttribute("SameSite")); assertFalse(csrfCookie.isHttpOnly()); assertNull(csrfRefreshExchange.getRequest().getSession(false)); @@ -73,6 +69,45 @@ void returnsJsonWhenCsrfTokenIsMissing() throws Exception { .andExpect(jsonPath("$.message").value(CSRF_INVALID_MESSAGE)); } + @Test + void rejectsMismatchedAndSingleSidedCsrfTokens() throws Exception { + Cookie csrfCookie = requestCsrfCookie().getResponse().getCookie(CSRF_COOKIE_NAME); + assertNotNull(csrfCookie); + + mockMvc.perform(post(CSRF_PROTECTED_ENDPOINT) + .cookie(csrfCookie) + .header(CSRF_HEADER_NAME, csrfCookie.getValue() + "-mismatch")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").value(CSRF_INVALID_MESSAGE)); + mockMvc.perform(post(CSRF_PROTECTED_ENDPOINT).cookie(csrfCookie)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").value(CSRF_INVALID_MESSAGE)); + mockMvc.perform(post(CSRF_PROTECTED_ENDPOINT).header(CSRF_HEADER_NAME, csrfCookie.getValue())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").value(CSRF_INVALID_MESSAGE)); + } + + @Test + void deletesCsrfCookieImmediatelyOnLogout() throws Exception { + Cookie csrfCookie = requestCsrfCookie().getResponse().getCookie(CSRF_COOKIE_NAME); + assertNotNull(csrfCookie); + + MvcResult logoutExchange = mockMvc.perform( + post(LOGOUT_ENDPOINT).cookie(csrfCookie).header(CSRF_HEADER_NAME, csrfCookie.getValue())) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + Cookie deletedCsrfCookie = logoutExchange.getResponse().getCookie(CSRF_COOKIE_NAME); + assertNotNull(deletedCsrfCookie); + assertEquals(0, deletedCsrfCookie.getMaxAge()); + } + + private MvcResult requestCsrfCookie() throws Exception { + return mockMvc.perform(get(CSRF_REFRESH_ENDPOINT)) + .andExpect(status().isOk()) + .andReturn(); + } + /** * Exposes a harmless state-changing route so the real security filter chain can be tested. */ From e474671261dec14c9cec3d0140a04cffde469103 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 13:32:49 -0700 Subject: [PATCH 19/40] fix(citations): anchor guided PDF chunks before dedupe Preserve document-to-citation alignment through PDF page estimation, then collapse only citations that resolve to the same final page anchor. --- .../service/GuidedLearningService.java | 68 ++++++++-- .../GuidedLearningServiceCitationTest.java | 116 ++++++++++++++++++ 2 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/williamcallahan/javachat/service/GuidedLearningServiceCitationTest.java diff --git a/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java b/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java index cad006b9..364a0813 100644 --- a/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java +++ b/src/main/java/com/williamcallahan/javachat/service/GuidedLearningService.java @@ -14,8 +14,10 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import org.slf4j.Logger; @@ -126,9 +128,7 @@ public List citationsForLesson(String slug) { List retrievedDocuments = retrievalService.retrieve(query); List bookDocuments = filterToBook(retrievedDocuments); if (bookDocuments.isEmpty()) return List.of(); - List baseCitations = - retrievalService.toCitations(bookDocuments).citations(); - return pdfCitationEnhancer.enhanceWithPageAnchors(bookDocuments, baseCitations); + return buildPageAnchoredCitations(bookDocuments); }) .orElse(List.of()); } @@ -199,15 +199,67 @@ public List citationsForBookDocuments(List bookContextDocume if (bookContextDocuments == null || bookContextDocuments.isEmpty()) { return List.of(); } - RetrievalService.CitationOutcome citationOutcome = retrievalService.toCitations(bookContextDocuments); - if (citationOutcome.failedConversionCount() > 0) { + return buildPageAnchoredCitations(bookContextDocuments); + } + + /** + * Builds page-anchored citations without collapsing distinct source chunks before anchoring. + * + *

Each document is converted independently through the canonical citation converter, then only + * successfully converted documents are paired with their citation for page-anchor enhancement. This + * preserves the enhancer's one-to-one contract before presentation deduplication operates on final + * anchor-bearing URLs.

+ * + * @param bookContextDocuments retrieved Think Java documents used to ground a response + * @return citations whose PDF URLs include their estimated page anchors + */ + private List buildPageAnchoredCitations(List bookContextDocuments) { + List citationDocuments = new ArrayList<>(); + List citationsBeforePageAnchoring = new ArrayList<>(); + int failedCitationConversions = 0; + + for (Document bookContextDocument : bookContextDocuments) { + if (bookContextDocument == null) { + continue; + } + + RetrievalService.CitationOutcome singleDocumentCitationOutcome = + retrievalService.toCitations(List.of(bookContextDocument)); + failedCitationConversions += singleDocumentCitationOutcome.failedConversionCount(); + for (Citation documentCitation : singleDocumentCitationOutcome.citations()) { + citationDocuments.add(bookContextDocument); + citationsBeforePageAnchoring.add(documentCitation); + } + } + + if (failedCitationConversions > 0) { logger.warn( "Guided citation conversion had {} failure(s) out of {} documents", - citationOutcome.failedConversionCount(), + failedCitationConversions, bookContextDocuments.size()); } - List baseCitations = citationOutcome.citations(); - return pdfCitationEnhancer.enhanceWithPageAnchors(bookContextDocuments, baseCitations); + if (citationDocuments.isEmpty()) { + return List.of(); + } + return deduplicateCitationsByAnchoredUrl( + pdfCitationEnhancer.enhanceWithPageAnchors(citationDocuments, citationsBeforePageAnchoring)); + } + + /** + * Retains the first citation for each final, page-anchored URL in encounter order. + * + * @param pageAnchoredCitations citations after their PDF page fragments have been added + * @return citations without repeated final URLs + */ + private static List deduplicateCitationsByAnchoredUrl(List pageAnchoredCitations) { + Set retainedAnchoredCitationUrls = new LinkedHashSet<>(); + List deduplicatedPageAnchoredCitations = new ArrayList<>(); + for (Citation pageAnchoredCitation : pageAnchoredCitations) { + if (retainedAnchoredCitationUrls.add(pageAnchoredCitation.getUrl())) { + deduplicatedPageAnchoredCitations.add(pageAnchoredCitation); + } + } + return deduplicatedPageAnchoredCitations; } /** diff --git a/src/test/java/com/williamcallahan/javachat/service/GuidedLearningServiceCitationTest.java b/src/test/java/com/williamcallahan/javachat/service/GuidedLearningServiceCitationTest.java new file mode 100644 index 00000000..ff0c2904 --- /dev/null +++ b/src/test/java/com/williamcallahan/javachat/service/GuidedLearningServiceCitationTest.java @@ -0,0 +1,116 @@ +package com.williamcallahan.javachat.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.williamcallahan.javachat.config.AppProperties; +import com.williamcallahan.javachat.config.SystemPromptConfig; +import com.williamcallahan.javachat.model.Citation; +import com.williamcallahan.javachat.support.PdfCitationEnhancer; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.ai.document.Document; + +/** + * Verifies guided citations preserve PDF page anchors for independently retrieved book chunks. + */ +class GuidedLearningServiceCitationTest { + private static final String FIRST_BOOK_CHUNK_ID = "first-book-chunk"; + private static final int FIRST_BOOK_CHUNK_INDEX = 0; + private static final int FIRST_BOOK_CHUNK_PAGE = 1; + private static final String LAST_BOOK_CHUNK_ID = "last-book-chunk"; + private static final String SAME_PAGE_BOOK_CHUNK_ID = "same-page-book-chunk"; + private static final int SAME_PAGE_BOOK_CHUNK_INDEX = 1; + private static final String THINK_JAVA_PDF_URL = + "https://example.test/pdfs/Think%20Java%20-%202nd%20Edition%20Book.pdf"; + private static final String THINK_JAVA_SOURCE_CHUNK_TEXT = "Think Java source chunk"; + private static final String THINK_JAVA_TITLE = "Think Java"; + private static final String METADATA_CHUNK_INDEX = "chunkIndex"; + private static final String METADATA_TITLE = "title"; + private static final String METADATA_URL = "url"; + private static final String PDF_PAGE_URL_FRAGMENT_PREFIX = "#page="; + private static final String PDF_PAGE_ANCHOR_PREFIX = "page="; + private static final String PARSED_CHUNK_SAFE_NAME = "think-java"; + private static final String PARSED_CHUNK_FILE_PREFIX = PARSED_CHUNK_SAFE_NAME + "_"; + private static final String PARSED_CHUNK_FILE_SUFFIX = ".txt"; + private static final int PARSED_CHUNK_FILE_COUNT = 10; + private static final int TEST_PDF_PAGE_COUNT = 5; + private static final String TEST_JDK_VERSION = "25"; + + @Test + void citationsForBookDocumentsRetainsDistinctPageAnchorsAndDeduplicatesSamePage(@TempDir Path parsedChunkDirectory) + throws IOException { + createParsedChunkFiles(parsedChunkDirectory); + LocalStoreService localStoreService = mock(LocalStoreService.class); + when(localStoreService.toSafeName(THINK_JAVA_PDF_URL)).thenReturn(PARSED_CHUNK_SAFE_NAME); + when(localStoreService.getParsedDir()).thenReturn(parsedChunkDirectory); + GuidedLearningService guidedLearningService = new GuidedLearningService( + mock(GuidedTOCProvider.class), + retrievalService(), + mock(EnrichmentService.class), + mock(ChatService.class), + mock(SystemPromptConfig.class), + new FixedPagePdfCitationEnhancer(localStoreService, TEST_PDF_PAGE_COUNT), + new AppProperties(), + TEST_JDK_VERSION); + + List pageAnchoredCitations = guidedLearningService.citationsForBookDocuments(List.of( + thinkJavaChunk(FIRST_BOOK_CHUNK_ID, FIRST_BOOK_CHUNK_INDEX), + thinkJavaChunk(SAME_PAGE_BOOK_CHUNK_ID, SAME_PAGE_BOOK_CHUNK_INDEX), + thinkJavaChunk(LAST_BOOK_CHUNK_ID, PARSED_CHUNK_FILE_COUNT - 1))); + + assertEquals( + List.of( + THINK_JAVA_PDF_URL + PDF_PAGE_URL_FRAGMENT_PREFIX + FIRST_BOOK_CHUNK_PAGE, + THINK_JAVA_PDF_URL + PDF_PAGE_URL_FRAGMENT_PREFIX + TEST_PDF_PAGE_COUNT), + pageAnchoredCitations.stream().map(Citation::getUrl).toList()); + assertEquals( + List.of(PDF_PAGE_ANCHOR_PREFIX + FIRST_BOOK_CHUNK_PAGE, PDF_PAGE_ANCHOR_PREFIX + TEST_PDF_PAGE_COUNT), + pageAnchoredCitations.stream().map(Citation::getAnchor).toList()); + } + + private static RetrievalService retrievalService() { + return new RetrievalService( + mock(HybridSearchService.class), + new AppProperties(), + mock(RerankerService.class), + mock(DocumentFactory.class)); + } + + private static void createParsedChunkFiles(Path parsedChunkDirectory) throws IOException { + for (int chunkIndex = 0; chunkIndex < PARSED_CHUNK_FILE_COUNT; chunkIndex++) { + Files.createFile( + parsedChunkDirectory.resolve(PARSED_CHUNK_FILE_PREFIX + chunkIndex + PARSED_CHUNK_FILE_SUFFIX)); + } + } + + private static Document thinkJavaChunk(String documentId, int chunkIndex) { + return Document.builder() + .id(documentId) + .text(THINK_JAVA_SOURCE_CHUNK_TEXT) + .metadata(METADATA_URL, THINK_JAVA_PDF_URL) + .metadata(METADATA_TITLE, THINK_JAVA_TITLE) + .metadata(METADATA_CHUNK_INDEX, chunkIndex) + .build(); + } + + /** Supplies a deterministic page count while retaining the production anchor calculation. */ + private static final class FixedPagePdfCitationEnhancer extends PdfCitationEnhancer { + private final int pdfPageCount; + + private FixedPagePdfCitationEnhancer(LocalStoreService localStoreService, int pdfPageCount) { + super(localStoreService); + this.pdfPageCount = pdfPageCount; + } + + @Override + public int getThinkJavaPdfPages() { + return pdfPageCount; + } + } +} From 4f1adc575d4f6f2ca1ca2efe102d29c8ebb8285b Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 13:33:03 -0700 Subject: [PATCH 20/40] fix(streaming): report SSE transport failures Invoke the canonical error callback exactly once for non-abort fetch and reader failures while preserving rejection identity and silent cancellation. --- frontend/src/lib/services/sse.test.ts | 50 ++++++++++++++++++++++++++- frontend/src/lib/services/sse.ts | 16 ++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/services/sse.test.ts b/frontend/src/lib/services/sse.test.ts index d7a64bd1..b065609b 100644 --- a/frontend/src/lib/services/sse.test.ts +++ b/frontend/src/lib/services/sse.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { streamSse } from "./sse"; const SSE_STREAM_RESPONSE_STATUS = 200; +const FETCH_FAILURE_MESSAGE = "Network request failed"; +const STREAM_READ_FAILURE_MESSAGE = "Unable to read the SSE stream"; function createSseStreamResponse(sseWireText: string): Response { const encoder = new TextEncoder(); @@ -17,7 +19,7 @@ function createSseStreamResponse(sseWireText: string): Response { }); } -describe("streamSse abort handling", () => { +describe("streamSse transport handling", () => { afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); @@ -43,6 +45,24 @@ describe("streamSse abort handling", () => { expect(onError).not.toHaveBeenCalled(); }); + it("reports and rejects a non-abort fetch failure exactly once", async () => { + const fetchFailure = new Error(FETCH_FAILURE_MESSAGE); + const fetchMock = vi.fn().mockRejectedValue(fetchFailure); + vi.stubGlobal("fetch", fetchMock); + + const onText = vi.fn(); + const onError = vi.fn(); + + await expect( + streamSse("/api/test/stream", { hello: "world" }, { onText, onError }, "sse.test.ts"), + ).rejects.toBe(fetchFailure); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(onText).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith({ message: FETCH_FAILURE_MESSAGE }); + }); + it("treats AbortError during read as a cancellation (no onError)", async () => { const encoder = new TextEncoder(); const abortError = Object.assign(new Error("Aborted"), { name: "AbortError" }); @@ -72,6 +92,34 @@ describe("streamSse abort handling", () => { expect(onText).toHaveBeenCalledWith("Hello"); expect(onError).not.toHaveBeenCalled(); }); + + it("reports and rejects a stream-read failure exactly once", async () => { + const streamReadFailure = new Error(STREAM_READ_FAILURE_MESSAGE); + const sseStreamBody = new ReadableStream({ + start(streamController) { + streamController.error(streamReadFailure); + }, + }); + const fetchMock = vi.fn().mockResolvedValue( + new Response(sseStreamBody, { + status: SSE_STREAM_RESPONSE_STATUS, + statusText: "OK", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const onText = vi.fn(); + const onError = vi.fn(); + + await expect( + streamSse("/api/test/stream", { hello: "world" }, { onText, onError }, "sse.test.ts"), + ).rejects.toBe(streamReadFailure); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(onText).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith({ message: STREAM_READ_FAILURE_MESSAGE }); + }); }); describe("streamSse payload validation", () => { diff --git a/frontend/src/lib/services/sse.ts b/frontend/src/lib/services/sse.ts index 4b5a46c1..451ec42f 100644 --- a/frontend/src/lib/services/sse.ts +++ b/frontend/src/lib/services/sse.ts @@ -46,6 +46,11 @@ function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } +function reportSseFailure(callbacks: SseCallbacks, sseFailure: unknown): void { + const sseFailureMessage = sseFailure instanceof Error ? sseFailure.message : String(sseFailure); + callbacks.onError?.({ message: sseFailureMessage }); +} + function throwInvalidSseEvent(callbacks: SseCallbacks): never { const streamError: StreamError = { message: INVALID_SSE_EVENT_MESSAGE }; callbacks.onError?.(streamError); @@ -213,6 +218,7 @@ export async function streamSse( if (abortSignal?.aborted || isAbortError(fetchError)) { return; } + reportSseFailure(callbacks, fetchError); throw fetchError; } @@ -237,6 +243,7 @@ export async function streamSseGet( if (abortSignal?.aborted || isAbortError(fetchError)) { return; } + reportSseFailure(callbacks, fetchError); throw fetchError; } @@ -289,7 +296,14 @@ async function consumeSseStream( try { while (true) { - const { done: streamEnded, value: byteSegment } = await sseReader.read(); + const { done: streamEnded, value: byteSegment } = await sseReader + .read() + .catch((streamReadFailure: unknown): never => { + if (!abortSignal?.aborted && !isAbortError(streamReadFailure)) { + reportSseFailure(callbacks, streamReadFailure); + } + throw streamReadFailure; + }); if (streamEnded) { streamCompletedNormally = true; From 3392b95bf9239faf7f9a92e6754524bfebe72723 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 13:33:19 -0700 Subject: [PATCH 21/40] fix(ingestion): remove stale Java quick docsets Keep every selectable Java documentation set manifest-backed and reject legacy quick tokens that no longer have fetch or citation mappings. --- .../javachat/cli/DocumentationSetCatalog.java | 6 ------ .../cli/JavaApiDocumentationSourceParityTest.java | 8 +++++++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/cli/DocumentationSetCatalog.java b/src/main/java/com/williamcallahan/javachat/cli/DocumentationSetCatalog.java index e3379bc9..ff9aa402 100644 --- a/src/main/java/com/williamcallahan/javachat/cli/DocumentationSetCatalog.java +++ b/src/main/java/com/williamcallahan/javachat/cli/DocumentationSetCatalog.java @@ -27,10 +27,6 @@ final class DocumentationSetCatalog { private static final String DOCSET_SPRING_AI_COMPLETE_NAME = "Spring AI Complete"; private static final String DOCSET_SPRING_AI_COMPLETE_PATH = "spring-ai-complete"; - private static final String DOCSET_JAVA_24_QUICK_NAME = "Java 24 Quick"; - private static final String DOCSET_JAVA_24_QUICK_PATH = "java24"; - private static final String DOCSET_JAVA_25_QUICK_NAME = "Java 25 Quick"; - private static final String DOCSET_JAVA_25_QUICK_PATH = "java25"; private static final String DOCSET_SPRING_BOOT_QUICK_NAME = "Spring Boot Quick"; private static final String DOCSET_SPRING_BOOT_QUICK_PATH = "spring-boot"; private static final String DOCSET_SPRING_FRAMEWORK_QUICK_NAME = "Spring Framework Quick"; @@ -41,8 +37,6 @@ final class DocumentationSetCatalog { private static final List BASE_DOCUMENTATION_SETS = buildBaseDocumentationSets(); private static final List QUICK_DOCUMENTATION_SETS = List.of( - new DocumentationSet(DOCSET_JAVA_24_QUICK_NAME, DOCSET_JAVA_24_QUICK_PATH), - new DocumentationSet(DOCSET_JAVA_25_QUICK_NAME, DOCSET_JAVA_25_QUICK_PATH), new DocumentationSet(DOCSET_SPRING_BOOT_QUICK_NAME, DOCSET_SPRING_BOOT_QUICK_PATH), new DocumentationSet(DOCSET_SPRING_FRAMEWORK_QUICK_NAME, DOCSET_SPRING_FRAMEWORK_QUICK_PATH), new DocumentationSet(DOCSET_SPRING_AI_QUICK_NAME, DOCSET_SPRING_AI_QUICK_PATH)); diff --git a/src/test/java/com/williamcallahan/javachat/cli/JavaApiDocumentationSourceParityTest.java b/src/test/java/com/williamcallahan/javachat/cli/JavaApiDocumentationSourceParityTest.java index 5b6ac615..0e37992b 100644 --- a/src/test/java/com/williamcallahan/javachat/cli/JavaApiDocumentationSourceParityTest.java +++ b/src/test/java/com/williamcallahan/javachat/cli/JavaApiDocumentationSourceParityTest.java @@ -1,6 +1,7 @@ package com.williamcallahan.javachat.cli; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import com.williamcallahan.javachat.config.DocsSourceRegistry; import com.williamcallahan.javachat.config.DocsSourceRegistry.JavaApiDocumentationSource; @@ -9,6 +10,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Set; import org.junit.jupiter.api.Test; /** @@ -16,6 +18,8 @@ */ class JavaApiDocumentationSourceParityTest { + private static final Set LEGACY_QUICK_JAVA_DOCSET_TOKENS = Set.of("java24", "java25"); + @Test void projectsCanonicalJavaApiSourcesIntoCliCatalogAndFetchScript() throws IOException, InterruptedException { List canonicalJavaApiSources = DocsSourceRegistry.javaApiDocumentationSources(); @@ -23,11 +27,13 @@ void projectsCanonicalJavaApiSourcesIntoCliCatalogAndFetchScript() throws IOExce .map(javaApiDocumentationSource -> new DocumentationSet( javaApiDocumentationSource.displayName(), javaApiDocumentationSource.relativeMirrorPath())) .toList(); - List actualCliDocumentationSets = DocumentationSetCatalog.baseSets().stream() + List actualCliDocumentationSets = DocumentationSetCatalog.allSets().stream() .filter(expectedCliDocumentationSets::contains) .toList(); assertEquals(expectedCliDocumentationSets, actualCliDocumentationSets); + assertFalse(DocumentationSetCatalog.allSets().stream() + .anyMatch(documentationSet -> documentationSet.matchesAny(LEGACY_QUICK_JAVA_DOCSET_TOKENS))); Path fetchScriptPath = Path.of("scripts", "fetch_all_docs.sh").toAbsolutePath(); ProcessBuilder fetchSourceListingCommand = From 1f8489efb0f4db3360ab9f45b2454bff112cb22f Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 13:33:33 -0700 Subject: [PATCH 22/40] test(retrieval): preserve distinct unmapped local sources Prove that unresolved file URLs retain their raw identity because normalizing them to the shared redacted sentinel would collapse unrelated documents. --- .../service/RetrievalServiceTest.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java index 06f6fb4a..8bc0aeb0 100644 --- a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java @@ -114,6 +114,38 @@ void preservesDistinctSamePageChunksForRerankingAndDeduplicatesTheirCitations() assertEquals(0, citationOutcome.failedConversionCount()); } + @Test + void preservesDistinctUnmappedLocalDocumentsWithoutContentHashes() { + HybridSearchService hybridSearchService = mock(HybridSearchService.class); + RerankerService rerankerService = mock(RerankerService.class); + DocumentFactory documentFactory = mock(DocumentFactory.class); + AppProperties appProperties = new AppProperties(); + RetrievalService retrievalService = + new RetrievalService(hybridSearchService, appProperties, rerankerService, documentFactory); + + Document firstUnmappedLocalDocument = Document.builder() + .id("first-unmapped-local") + .text("First local document") + .metadata("url", "file:///unmapped/first.html") + .build(); + Document secondUnmappedLocalDocument = Document.builder() + .id("second-unmapped-local") + .text("Second local document") + .metadata("url", "file:///unmapped/second.html") + .build(); + List retrievalCandidates = List.of(firstUnmappedLocalDocument, secondUnmappedLocalDocument); + when(hybridSearchService.searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class))) + .thenReturn(new HybridSearchService.SearchOutcome(retrievalCandidates, List.of())); + when(rerankerService.rerank(anyString(), anyList(), anyInt())).thenAnswer(rerankerInvocation -> { + List deduplicatedCandidates = rerankerInvocation.getArgument(1); + return deduplicatedCandidates; + }); + + RetrievalService.RetrievalOutcome retrievalOutcome = retrievalService.retrieveOutcome("Local documentation"); + + assertEquals(retrievalCandidates, retrievalOutcome.documents()); + } + @Test void propagatesStrictHybridSearchFailure() { HybridSearchService hybridSearchService = mock(HybridSearchService.class); From 3f4d56735ffdc3409fe7aa503ba97e9c428f1b02 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 13:33:47 -0700 Subject: [PATCH 23/40] test(streaming): separate provider routing coverage Move routing and failure-classification tests into their focused owner so the streaming service suite remains below the repository monolith threshold. --- .../service/OpenAIStreamingServiceTest.java | 169 +--------------- .../OpenAiProviderRoutingServiceTest.java | 191 ++++++++++++++++++ 2 files changed, 194 insertions(+), 166 deletions(-) create mode 100644 src/test/java/com/williamcallahan/javachat/service/OpenAiProviderRoutingServiceTest.java diff --git a/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingServiceTest.java index e4afed13..31305dff 100644 --- a/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingServiceTest.java @@ -21,11 +21,6 @@ import com.openai.core.http.Headers; import com.openai.core.http.StreamResponse; import com.openai.errors.InternalServerException; -import com.openai.errors.NotFoundException; -import com.openai.errors.OpenAIIoException; -import com.openai.errors.PermissionDeniedException; -import com.openai.errors.RateLimitException; -import com.openai.errors.UnauthorizedException; import com.openai.models.ErrorObject; import com.openai.models.responses.ResponseCreateParams; import com.openai.models.responses.ResponseStreamEvent; @@ -37,7 +32,6 @@ import com.williamcallahan.javachat.application.streaming.StreamingFailureReporter; import com.williamcallahan.javachat.domain.prompt.StructuredPrompt; import com.williamcallahan.javachat.web.SseConstants; -import java.io.InterruptedIOException; import java.time.Duration; import java.util.List; import java.util.Objects; @@ -48,17 +42,14 @@ import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; import org.springframework.test.util.ReflectionTestUtils; -import reactor.core.Exceptions; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; /** - * Verifies primary-provider backoff and streaming failure classification. + * Verifies streaming failure reporting, pre-text provider retries, and completion validation. * - *

Backoff tests target {@link OpenAiProviderRoutingService#shouldBackoffPrimary} directly - * (package-private, same package). Streaming recovery tests exercise the public - * {@link OpenAIStreamingService#isRecoverableStreamingFailure} API that delegates to - * the routing service.

+ *

Provider routing policy is verified in {@link OpenAiProviderRoutingServiceTest} so this + * suite stays focused on SDK transport and stream-facing behavior.

*/ class OpenAIStreamingServiceTest { private final Logger serviceLogger = (Logger) LoggerFactory.getLogger(OpenAIStreamingService.class); @@ -85,12 +76,6 @@ void stopCapturingServiceLogs() { streamingFailureLogEvents.list.clear(); } - private OpenAiProviderRoutingService createRoutingService() { - RateLimitService rateLimitService = mock(RateLimitService.class); - return new OpenAiProviderRoutingService( - rateLimitService, 600, RateLimitService.ApiProvider.GITHUB_MODELS.getName()); - } - private OpenAIStreamingService createStreamingService() { RateLimitService rateLimitService = mock(RateLimitService.class); OpenAiRequestFactory requestFactory = @@ -101,112 +86,6 @@ private OpenAIStreamingService createStreamingService() { rateLimitService, requestFactory, providerRoutingService, new OpenAiStreamingFailureReporter()); } - @Test - void shouldBackoffPrimaryTreatsSdkIoAsBackoffEligible() { - OpenAiProviderRoutingService routingService = createRoutingService(); - assertTrue(routingService.shouldBackoffPrimary(new OpenAIIoException("io"))); - } - - @Test - void shouldBackoffPrimaryIgnoresCallerCancellationWrappedBySdkIo() { - OpenAiProviderRoutingService routingService = createRoutingService(); - InterruptedIOException interruptedRequest = new InterruptedIOException("request interrupted by caller timeout"); - OpenAIIoException cancelledCompletion = new OpenAIIoException("Request failed", interruptedRequest); - - assertFalse(routingService.shouldBackoffPrimary(cancelledCompletion)); - } - - @Test - void callerCancellationKeepsConfiguredPrimaryProviderEligible() { - RateLimitService rateLimitService = mock(RateLimitService.class); - when(rateLimitService.isProviderAvailable(RateLimitService.ApiProvider.OPENAI)) - .thenReturn(true); - when(rateLimitService.isProviderAvailable(RateLimitService.ApiProvider.GITHUB_MODELS)) - .thenReturn(true); - OpenAiProviderRoutingService routingService = - new OpenAiProviderRoutingService(rateLimitService, 600, RateLimitService.ApiProvider.OPENAI.getName()); - OpenAIClient openAiClient = mock(OpenAIClient.class); - OpenAIClient githubModelsClient = mock(OpenAIClient.class); - InterruptedIOException interruptedRequest = new InterruptedIOException("request interrupted by caller timeout"); - OpenAIIoException cancelledCompletion = new OpenAIIoException("Request failed", interruptedRequest); - - routingService.recordProviderFailure(RateLimitService.ApiProvider.OPENAI, cancelledCompletion); - - List availableProviders = - routingService.selectAvailableProviderCandidates(githubModelsClient, openAiClient); - assertEquals(2, availableProviders.size()); - assertEquals( - RateLimitService.ApiProvider.OPENAI, availableProviders.get(0).provider()); - } - - @Test - void shouldBackoffPrimaryTreats401AsBackoffEligible() { - OpenAiProviderRoutingService routingService = createRoutingService(); - Headers headers = Headers.builder().build(); - UnauthorizedException unauthorized = - UnauthorizedException.builder().headers(headers).build(); - assertTrue(routingService.shouldBackoffPrimary(unauthorized)); - } - - @Test - void shouldBackoffPrimaryTreats429AsBackoffEligible() { - OpenAiProviderRoutingService routingService = createRoutingService(); - Headers headers = Headers.builder().build(); - RateLimitException rateLimit = - RateLimitException.builder().headers(headers).build(); - assertTrue(routingService.shouldBackoffPrimary(rateLimit)); - } - - @Test - void shouldBackoffPrimaryTreats404AsBackoffEligible() { - OpenAiProviderRoutingService routingService = createRoutingService(); - Headers headers = Headers.builder().build(); - NotFoundException notFoundException = - NotFoundException.builder().headers(headers).build(); - assertTrue(routingService.shouldBackoffPrimary(notFoundException)); - } - - @Test - void shouldBackoffPrimaryDoesNotBackoffOnGenericRuntime() { - OpenAiProviderRoutingService routingService = createRoutingService(); - assertFalse(routingService.shouldBackoffPrimary(new IllegalArgumentException("no"))); - } - - @Test - void recoverableStreamingFailureTreatsReactorOverflowTypeAsRetryable() { - OpenAIStreamingService streamingService = createStreamingService(); - assertTrue(streamingService.isRecoverableStreamingFailure(Exceptions.failWithOverflow())); - } - - @Test - void recoverableStreamingFailureTreatsValidationErrorsAsNonRetryable() { - OpenAIStreamingService streamingService = createStreamingService(); - assertFalse( - streamingService.isRecoverableStreamingFailure(new IllegalArgumentException("bad request payload"))); - } - - @Test - void recoverableStreamingFailureTreatsNotFoundAsNonRetryable() { - OpenAIStreamingService streamingService = createStreamingService(); - Headers headers = Headers.builder().build(); - NotFoundException notFoundException = - NotFoundException.builder().headers(headers).build(); - assertFalse(streamingService.isRecoverableStreamingFailure(notFoundException)); - } - - @Test - void recoverableStreamingFailureRejectsAuthenticationAuthorizationAndRateLimitFailures() { - OpenAIStreamingService streamingService = createStreamingService(); - Headers headers = Headers.builder().build(); - - assertFalse(streamingService.isRecoverableStreamingFailure( - UnauthorizedException.builder().headers(headers).build())); - assertFalse(streamingService.isRecoverableStreamingFailure( - PermissionDeniedException.builder().headers(headers).build())); - assertFalse(streamingService.isRecoverableStreamingFailure( - RateLimitException.builder().headers(headers).build())); - } - @Test void recoverableStreamingFailureUnwrapsNestedTerminalContext() { OpenAIStreamingService streamingService = createStreamingService(); @@ -424,48 +303,6 @@ void nonEmptyTextDeltaDisablesPreTextRetry() { verify(visibleTextDelta).delta(); } - @Test - void primaryBackoffDoesNotHideOnlyConfiguredProvider() { - RateLimitService rateLimitService = mock(RateLimitService.class); - when(rateLimitService.isProviderAvailable(RateLimitService.ApiProvider.OPENAI)) - .thenReturn(true); - OpenAiProviderRoutingService routingService = - new OpenAiProviderRoutingService(rateLimitService, 600, RateLimitService.ApiProvider.OPENAI.getName()); - OpenAIClient openAiClient = mock(OpenAIClient.class); - - routingService.recordProviderFailure(RateLimitService.ApiProvider.OPENAI, new OpenAIIoException("io")); - - List availableProviders = - routingService.selectAvailableProviderCandidates(null, openAiClient); - - assertEquals(1, availableProviders.size()); - assertEquals( - RateLimitService.ApiProvider.OPENAI, availableProviders.get(0).provider()); - } - - @Test - void primaryBackoffUsesConfiguredAlternateProviderWhenAvailable() { - RateLimitService rateLimitService = mock(RateLimitService.class); - when(rateLimitService.isProviderAvailable(RateLimitService.ApiProvider.OPENAI)) - .thenReturn(true); - when(rateLimitService.isProviderAvailable(RateLimitService.ApiProvider.GITHUB_MODELS)) - .thenReturn(true); - OpenAiProviderRoutingService routingService = - new OpenAiProviderRoutingService(rateLimitService, 600, RateLimitService.ApiProvider.OPENAI.getName()); - OpenAIClient openAiClient = mock(OpenAIClient.class); - OpenAIClient githubModelsClient = mock(OpenAIClient.class); - - routingService.recordProviderFailure(RateLimitService.ApiProvider.OPENAI, new OpenAIIoException("io")); - - List availableProviders = - routingService.selectAvailableProviderCandidates(githubModelsClient, openAiClient); - - assertEquals(1, availableProviders.size()); - assertEquals( - RateLimitService.ApiProvider.GITHUB_MODELS, - availableProviders.get(0).provider()); - } - @Test void unavailableStreamDefersErrorSeverityToRequestBoundary() { OpenAIStreamingService streamingService = createStreamingService(); diff --git a/src/test/java/com/williamcallahan/javachat/service/OpenAiProviderRoutingServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/OpenAiProviderRoutingServiceTest.java new file mode 100644 index 00000000..a73b6a28 --- /dev/null +++ b/src/test/java/com/williamcallahan/javachat/service/OpenAiProviderRoutingServiceTest.java @@ -0,0 +1,191 @@ +package com.williamcallahan.javachat.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.openai.client.OpenAIClient; +import com.openai.core.http.Headers; +import com.openai.errors.NotFoundException; +import com.openai.errors.OpenAIIoException; +import com.openai.errors.PermissionDeniedException; +import com.openai.errors.RateLimitException; +import com.openai.errors.UnauthorizedException; +import java.io.InterruptedIOException; +import java.util.List; +import org.junit.jupiter.api.Test; +import reactor.core.Exceptions; + +/** + * Verifies provider ordering, primary backoff, and failure classification policy. + * + *

This suite targets routing directly so streaming transport tests remain focused on stream behavior.

+ */ +class OpenAiProviderRoutingServiceTest { + private static final long PRIMARY_BACKOFF_SECONDS = 600L; + + private OpenAiProviderRoutingService createRoutingService() { + RateLimitService rateLimitService = mock(RateLimitService.class); + return new OpenAiProviderRoutingService( + rateLimitService, PRIMARY_BACKOFF_SECONDS, RateLimitService.ApiProvider.GITHUB_MODELS.getName()); + } + + @Test + void shouldBackoffPrimaryTreatsSdkIoAsBackoffEligible() { + OpenAiProviderRoutingService routingService = createRoutingService(); + + assertTrue(routingService.shouldBackoffPrimary(new OpenAIIoException("io"))); + } + + @Test + void shouldBackoffPrimaryIgnoresCallerCancellationWrappedBySdkIo() { + OpenAiProviderRoutingService routingService = createRoutingService(); + InterruptedIOException interruptedRequest = new InterruptedIOException("request interrupted by caller timeout"); + OpenAIIoException cancelledCompletion = new OpenAIIoException("Request failed", interruptedRequest); + + assertFalse(routingService.shouldBackoffPrimary(cancelledCompletion)); + } + + @Test + void callerCancellationKeepsConfiguredPrimaryProviderEligible() { + RateLimitService rateLimitService = mock(RateLimitService.class); + when(rateLimitService.isProviderAvailable(RateLimitService.ApiProvider.OPENAI)) + .thenReturn(true); + when(rateLimitService.isProviderAvailable(RateLimitService.ApiProvider.GITHUB_MODELS)) + .thenReturn(true); + OpenAiProviderRoutingService routingService = new OpenAiProviderRoutingService( + rateLimitService, PRIMARY_BACKOFF_SECONDS, RateLimitService.ApiProvider.OPENAI.getName()); + OpenAIClient openAiClient = mock(OpenAIClient.class); + OpenAIClient githubModelsClient = mock(OpenAIClient.class); + InterruptedIOException interruptedRequest = new InterruptedIOException("request interrupted by caller timeout"); + OpenAIIoException cancelledCompletion = new OpenAIIoException("Request failed", interruptedRequest); + + routingService.recordProviderFailure(RateLimitService.ApiProvider.OPENAI, cancelledCompletion); + + List availableProviders = + routingService.selectAvailableProviderCandidates(githubModelsClient, openAiClient); + assertEquals( + List.of(RateLimitService.ApiProvider.OPENAI, RateLimitService.ApiProvider.GITHUB_MODELS), + availableProviders.stream() + .map(OpenAiProviderCandidate::provider) + .toList()); + } + + @Test + void shouldBackoffPrimaryTreats401AsBackoffEligible() { + OpenAiProviderRoutingService routingService = createRoutingService(); + Headers headers = Headers.builder().build(); + UnauthorizedException unauthorized = + UnauthorizedException.builder().headers(headers).build(); + + assertTrue(routingService.shouldBackoffPrimary(unauthorized)); + } + + @Test + void shouldBackoffPrimaryTreats429AsBackoffEligible() { + OpenAiProviderRoutingService routingService = createRoutingService(); + Headers headers = Headers.builder().build(); + RateLimitException rateLimit = + RateLimitException.builder().headers(headers).build(); + + assertTrue(routingService.shouldBackoffPrimary(rateLimit)); + } + + @Test + void shouldBackoffPrimaryTreats404AsBackoffEligible() { + OpenAiProviderRoutingService routingService = createRoutingService(); + Headers headers = Headers.builder().build(); + NotFoundException notFoundException = + NotFoundException.builder().headers(headers).build(); + + assertTrue(routingService.shouldBackoffPrimary(notFoundException)); + } + + @Test + void shouldBackoffPrimaryDoesNotBackoffOnGenericRuntime() { + OpenAiProviderRoutingService routingService = createRoutingService(); + + assertFalse(routingService.shouldBackoffPrimary(new IllegalArgumentException("no"))); + } + + @Test + void recoverableStreamingFailureTreatsReactorOverflowTypeAsRetryable() { + OpenAiProviderRoutingService routingService = createRoutingService(); + + assertTrue(routingService.isRecoverableStreamingFailure(Exceptions.failWithOverflow())); + } + + @Test + void recoverableStreamingFailureTreatsValidationErrorsAsNonRetryable() { + OpenAiProviderRoutingService routingService = createRoutingService(); + + assertFalse(routingService.isRecoverableStreamingFailure(new IllegalArgumentException("bad request payload"))); + } + + @Test + void recoverableStreamingFailureTreatsNotFoundAsNonRetryable() { + OpenAiProviderRoutingService routingService = createRoutingService(); + Headers headers = Headers.builder().build(); + NotFoundException notFoundException = + NotFoundException.builder().headers(headers).build(); + + assertFalse(routingService.isRecoverableStreamingFailure(notFoundException)); + } + + @Test + void recoverableStreamingFailureRejectsAuthenticationAuthorizationAndRateLimitFailures() { + OpenAiProviderRoutingService routingService = createRoutingService(); + Headers headers = Headers.builder().build(); + + assertFalse(routingService.isRecoverableStreamingFailure( + UnauthorizedException.builder().headers(headers).build())); + assertFalse(routingService.isRecoverableStreamingFailure( + PermissionDeniedException.builder().headers(headers).build())); + assertFalse(routingService.isRecoverableStreamingFailure( + RateLimitException.builder().headers(headers).build())); + } + + @Test + void primaryBackoffDoesNotHideOnlyConfiguredProvider() { + RateLimitService rateLimitService = mock(RateLimitService.class); + when(rateLimitService.isProviderAvailable(RateLimitService.ApiProvider.OPENAI)) + .thenReturn(true); + OpenAiProviderRoutingService routingService = new OpenAiProviderRoutingService( + rateLimitService, PRIMARY_BACKOFF_SECONDS, RateLimitService.ApiProvider.OPENAI.getName()); + OpenAIClient openAiClient = mock(OpenAIClient.class); + + routingService.recordProviderFailure(RateLimitService.ApiProvider.OPENAI, new OpenAIIoException("io")); + + List availableProviders = + routingService.selectAvailableProviderCandidates(null, openAiClient); + + assertEquals(1, availableProviders.size()); + assertEquals( + RateLimitService.ApiProvider.OPENAI, availableProviders.get(0).provider()); + } + + @Test + void primaryBackoffUsesConfiguredAlternateProviderWhenAvailable() { + RateLimitService rateLimitService = mock(RateLimitService.class); + when(rateLimitService.isProviderAvailable(RateLimitService.ApiProvider.OPENAI)) + .thenReturn(true); + when(rateLimitService.isProviderAvailable(RateLimitService.ApiProvider.GITHUB_MODELS)) + .thenReturn(true); + OpenAiProviderRoutingService routingService = new OpenAiProviderRoutingService( + rateLimitService, PRIMARY_BACKOFF_SECONDS, RateLimitService.ApiProvider.OPENAI.getName()); + OpenAIClient openAiClient = mock(OpenAIClient.class); + OpenAIClient githubModelsClient = mock(OpenAIClient.class); + + routingService.recordProviderFailure(RateLimitService.ApiProvider.OPENAI, new OpenAIIoException("io")); + + List availableProviders = + routingService.selectAvailableProviderCandidates(githubModelsClient, openAiClient); + + assertEquals(1, availableProviders.size()); + assertEquals( + RateLimitService.ApiProvider.GITHUB_MODELS, + availableProviders.get(0).provider()); + } +} From ce3a9efa585f9b5ace5a6a1eb8f0d4a278f72841 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:38:56 -0700 Subject: [PATCH 24/40] fix(config): validate Java API manifest locations --- .../config/JavaApiDocumentationManifest.java | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifest.java b/src/main/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifest.java index 563a6b7b..76a4a577 100644 --- a/src/main/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifest.java +++ b/src/main/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifest.java @@ -5,7 +5,11 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.net.URI; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -25,7 +29,14 @@ final class JavaApiDocumentationManifest { private static final String MANIFEST_RESOURCE = "/java-api-documentation-sources.manifest"; private static final String MANIFEST_DELIMITER = "|"; private static final String MANIFEST_DELIMITER_REGEX = "\\|"; + private static final String MIRROR_PATH_CURRENT_SEGMENT = "."; + private static final String MIRROR_PATH_PARENT_SEGMENT = ".."; + private static final String MIRROR_PATH_SEGMENT_SEPARATOR = "/"; + private static final String REMOTE_BASE_URL_HTTPS_SCHEME = "https"; private static final int MINIMUM_MANIFEST_LINE_COUNT = 2; + private static final int MANIFEST_ASCII_CONTROL_MAXIMUM = 0x1F; + private static final int MANIFEST_ASCII_DELETE_CHARACTER = 0x7F; + private static final char MIRROR_PATH_BACKSLASH = '\\'; private static final Pattern CANONICAL_UNSIGNED_INTEGER = Pattern.compile("(?:0|[1-9][0-9]*)"); private JavaApiDocumentationManifest() {} @@ -98,11 +109,61 @@ static void requireManifestText(String manifestText, String fieldName, boolean a if (!allowEmpty && manifestText.isEmpty()) { throw new IllegalArgumentException(fieldName + " cannot be blank"); } - if (!allowEmpty && hasBoundaryWhitespace(manifestText)) { + if (hasAsciiControlCharacter(manifestText)) { + throw new IllegalArgumentException(fieldName + " cannot contain control characters"); + } + if (hasBoundaryWhitespace(manifestText)) { throw new IllegalArgumentException(fieldName + " cannot have boundary whitespace"); } } + static void requireHttpsRemoteBaseUrl(String remoteBaseUrl) { + requireManifestText(remoteBaseUrl, "remoteBaseUrl", false); + if (!remoteBaseUrl.endsWith(MIRROR_PATH_SEGMENT_SEPARATOR)) { + throw new IllegalArgumentException("remoteBaseUrl must have a trailing slash"); + } + try { + URI remoteBaseUri = new URI(remoteBaseUrl).parseServerAuthority(); + if (!remoteBaseUri.isAbsolute() + || !REMOTE_BASE_URL_HTTPS_SCHEME.equals(remoteBaseUri.getScheme()) + || remoteBaseUri.getRawAuthority() == null + || remoteBaseUri.getRawAuthority().isEmpty() + || remoteBaseUri.getHost() == null) { + throw new IllegalArgumentException("remoteBaseUrl must be an absolute HTTPS URL with authority"); + } + } catch (URISyntaxException invalidRemoteBaseUrl) { + throw new IllegalArgumentException( + "remoteBaseUrl must be an absolute HTTPS URL with authority", invalidRemoteBaseUrl); + } + } + + static void requireNormalizedRelativeMirrorPath(String relativeMirrorPath) { + requireManifestText(relativeMirrorPath, "relativeMirrorPath", false); + if (relativeMirrorPath.indexOf(MIRROR_PATH_BACKSLASH) >= 0) { + throw new IllegalArgumentException("relativeMirrorPath cannot contain backslashes"); + } + + Path mirrorPath; + try { + mirrorPath = Path.of(relativeMirrorPath); + } catch (InvalidPathException invalidRelativeMirrorPath) { + throw new IllegalArgumentException("relativeMirrorPath must be a valid path", invalidRelativeMirrorPath); + } + if (mirrorPath.isAbsolute()) { + throw new IllegalArgumentException("relativeMirrorPath must be relative"); + } + if (!mirrorPath.equals(mirrorPath.normalize())) { + throw new IllegalArgumentException("relativeMirrorPath must be normalized"); + } + for (String mirrorPathSegment : relativeMirrorPath.split(MIRROR_PATH_SEGMENT_SEPARATOR, -1)) { + if (mirrorPathSegment.isEmpty() + || MIRROR_PATH_CURRENT_SEGMENT.equals(mirrorPathSegment) + || MIRROR_PATH_PARENT_SEGMENT.equals(mirrorPathSegment)) { + throw new IllegalArgumentException("relativeMirrorPath contains an invalid segment"); + } + } + } + static String serialize(JavaApiDocumentationSource source) { return String.join( MANIFEST_DELIMITER, @@ -179,6 +240,15 @@ private static boolean isAsciiWhitespace(char manifestCharacter) { || manifestCharacter == 0x0B; } + private static boolean hasAsciiControlCharacter(String manifestText) { + return manifestText.chars().anyMatch(JavaApiDocumentationManifest::isAsciiControlCharacter); + } + + private static boolean isAsciiControlCharacter(int characterCodePoint) { + return characterCodePoint <= MANIFEST_ASCII_CONTROL_MAXIMUM + || characterCodePoint == MANIFEST_ASCII_DELETE_CHARACTER; + } + private static IllegalStateException invalidLine(int manifestLineNumber, String problem) { return new IllegalStateException( "Canonical Java API documentation source manifest line " + manifestLineNumber + " " + problem); From 45a9a0fd1e7ebc4021826ea74723aabbc2078e2f Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:38:58 -0700 Subject: [PATCH 25/40] fix(config): protect Java API source projections --- .../javachat/config/DocsSourceRegistry.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java b/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java index 557d9ad2..39b4819d 100644 --- a/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java +++ b/src/main/java/com/williamcallahan/javachat/config/DocsSourceRegistry.java @@ -117,7 +117,7 @@ private DocsSourceRegistry() {} * Describes one complete Java API mirror projected from the canonical manifest. * * @param javaRelease Java release number used by retrieval provenance - * @param remoteBaseUrl authoritative Oracle Javadoc base URL + * @param remoteBaseUrl authoritative Javadoc base URL * @param relativeMirrorPath canonical path beneath {@code data/docs} * @param displayName operator-facing ingestion name * @param cutDirectories number of leading remote path segments removed during mirroring @@ -140,8 +140,8 @@ public record JavaApiDocumentationSource( if (parsedJavaRelease < 1) { throw new IllegalArgumentException("Java release must be positive"); } - JavaApiDocumentationManifest.requireManifestText(remoteBaseUrl, "remoteBaseUrl", false); - JavaApiDocumentationManifest.requireManifestText(relativeMirrorPath, "relativeMirrorPath", false); + JavaApiDocumentationManifest.requireHttpsRemoteBaseUrl(remoteBaseUrl); + JavaApiDocumentationManifest.requireNormalizedRelativeMirrorPath(relativeMirrorPath); JavaApiDocumentationManifest.requireManifestText(displayName, "displayName", false); if (cutDirectories < 0) { throw new IllegalArgumentException("Java API cut directories cannot be negative"); @@ -168,7 +168,7 @@ public String toManifestRow() { * @return immutable sources in manifest order */ public static List javaApiDocumentationSources() { - return JAVA_API_DOCUMENTATION_SOURCES; + return List.copyOf(JAVA_API_DOCUMENTATION_SOURCES); } private static Properties loadDocsSourceProperties() { From 7b81b34fc3d342e80642784b7932b4c91dc459cd Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:00 -0700 Subject: [PATCH 26/40] fix(ingestion): enforce canonical Java API selectors --- .../javachat/cli/DocumentationSet.java | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/cli/DocumentationSet.java b/src/main/java/com/williamcallahan/javachat/cli/DocumentationSet.java index b16ba010..c5a5978d 100644 --- a/src/main/java/com/williamcallahan/javachat/cli/DocumentationSet.java +++ b/src/main/java/com/williamcallahan/javachat/cli/DocumentationSet.java @@ -1,42 +1,63 @@ package com.williamcallahan.javachat.cli; +import com.williamcallahan.javachat.config.DocsSourceRegistry; import java.util.Locale; import java.util.Set; +import java.util.stream.Collectors; /** * Identifies a documentation set processed by the CLI ingestion pipeline. * *

The relative path is resolved under the configured docs root (for example, {@code data/docs}). - * The ID is a stable token used for filtering in {@code DOCS_SETS}.

+ * Manifest-backed Java API sets accept only their canonical relative mirror path; other sets retain + * their established filter tokens.

* * @param displayName user-facing name for logs * @param relativePath relative path under the docs root */ record DocumentationSet(String displayName, String relativePath) { - String docSetId() { + private static final Set JAVA_API_RELATIVE_MIRROR_PATHS = + DocsSourceRegistry.javaApiDocumentationSources().stream() + .map(javaApiDocumentationSource -> javaApiDocumentationSource.relativeMirrorPath()) + .collect(Collectors.toUnmodifiableSet()); + + String primarySelector() { + if (isManifestBackedJavaApiSet()) { + return relativePath; + } return normalizeToken(relativePath.replace('/', '-')); } - boolean matchesAny(final Set tokens) { - if (tokens == null || tokens.isEmpty()) { + boolean matchesSelectorTokens(final Set selectorTokens) { + if (selectorTokens == null || selectorTokens.isEmpty()) { return false; } + if (isManifestBackedJavaApiSet()) { + return selectorTokens.contains(relativePath); + } final String normalizedName = normalizeToken(displayName); final String normalizedPath = normalizeToken(relativePath); - final String normalizedId = docSetId(); - for (String token : tokens) { - if (token.equals(normalizedId) || token.equals(normalizedName) || token.equals(normalizedPath)) { + final String normalizedId = primarySelector(); + for (String selectorToken : selectorTokens) { + final String normalizedSelectorToken = normalizeToken(selectorToken); + if (normalizedSelectorToken.equals(normalizedId) + || normalizedSelectorToken.equals(normalizedName) + || normalizedSelectorToken.equals(normalizedPath)) { return true; } } return false; } - private static String normalizeToken(final String token) { - if (token == null) { + private boolean isManifestBackedJavaApiSet() { + return JAVA_API_RELATIVE_MIRROR_PATHS.contains(relativePath); + } + + private static String normalizeToken(final String selectorToken) { + if (selectorToken == null) { return ""; } - return token.trim().toLowerCase(Locale.ROOT); + return selectorToken.trim().toLowerCase(Locale.ROOT); } } From 3259bfa7e9f9c8d7d566cfdb793e53f251d697f1 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:01 -0700 Subject: [PATCH 27/40] fix(ingestion): preserve canonical selector case --- .../javachat/cli/DocumentProcessor.java | 49 +++++++++---------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/cli/DocumentProcessor.java b/src/main/java/com/williamcallahan/javachat/cli/DocumentProcessor.java index 0c71165b..f1fef7a3 100644 --- a/src/main/java/com/williamcallahan/javachat/cli/DocumentProcessor.java +++ b/src/main/java/com/williamcallahan/javachat/cli/DocumentProcessor.java @@ -69,6 +69,9 @@ public class DocumentProcessor { private static final String LOG_DOCSET_FILTER = "Doc set filter active"; private static final String LOG_DOCSET_SELECTED = "Doc sets selected: {} set(s)"; + private static final String DOCSET_ALL_SELECTOR = "all"; + private static final String DOCSET_FILTER_DELIMITER = ","; + private static final String SKIP_REASON_INVALID_PATH = "invalid path"; private static final String SKIP_REASON_DIR_MISSING = "directory not found"; private static final String SKIP_REASON_NO_ELIGIBLE_FILES = "no eligible files"; @@ -248,17 +251,17 @@ private List selectDocumentationSets(final EnvironmentConfig c } return DocumentationSetCatalog.baseSets(); } - final Set tokens = parseDocSetFilter(filter); - if (tokens.isEmpty() || tokens.contains("all")) { + final Set selectorTokens = parseDocSetFilter(filter); + if (selectorTokens.isEmpty() || selectorTokens.stream().anyMatch(DOCSET_ALL_SELECTOR::equalsIgnoreCase)) { return DocumentationSetCatalog.allSets(); } - final List selectedSets = new ArrayList<>(); - for (DocumentationSet docSet : DocumentationSetCatalog.allSets()) { - if (docSet.matchesAny(tokens)) { - selectedSets.add(docSet); + final List selectedDocumentationSets = new ArrayList<>(); + for (DocumentationSet documentationSet : DocumentationSetCatalog.allSets()) { + if (documentationSet.matchesSelectorTokens(selectorTokens)) { + selectedDocumentationSets.add(documentationSet); } } - if (selectedSets.isEmpty()) { + if (selectedDocumentationSets.isEmpty()) { throw new DocumentProcessingException(String.format( Locale.ROOT, "DOCS_SETS matched no documentation sets. Available doc sets: %s", @@ -266,38 +269,32 @@ private List selectDocumentationSets(final EnvironmentConfig c } if (LOGGER.isInfoEnabled()) { LOGGER.info(LOG_DOCSET_FILTER); - LOGGER.info(LOG_DOCSET_SELECTED, selectedSets.size()); + LOGGER.info(LOG_DOCSET_SELECTED, selectedDocumentationSets.size()); } - return selectedSets; + return selectedDocumentationSets; } private static Set parseDocSetFilter(final String filter) { - final Set tokens = new LinkedHashSet<>(); + final Set selectorTokens = new LinkedHashSet<>(); if (filter == null || filter.isBlank()) { - return tokens; + return selectorTokens; } - for (String token : filter.split(",")) { - final String normalized = normalizeToken(token); - if (!normalized.isBlank()) { - tokens.add(normalized); + for (String commaSeparatedSelector : filter.split(DOCSET_FILTER_DELIMITER)) { + final String selectorToken = commaSeparatedSelector.trim(); + if (!selectorToken.isBlank()) { + selectorTokens.add(selectorToken); } } - return tokens; + return selectorTokens; } - private static String formatDocSetSummary(final List docSets) { - return docSets.stream() - .map(docSet -> docSet.docSetId() + " (" + docSet.displayName() + ")") + private static String formatDocSetSummary(final List documentationSets) { + return documentationSets.stream() + .map(documentationSet -> + documentationSet.primarySelector() + " (" + documentationSet.displayName() + ")") .collect(Collectors.joining(", ")); } - private static String normalizeToken(final String token) { - if (token == null) { - return ""; - } - return token.trim().toLowerCase(Locale.ROOT); - } - private void logProcessingStats(final int processed, final long elapsedMillis) { if (!LOGGER.isInfoEnabled()) { return; From 5fe43c547c6b70898cf6eceff64ab4ec16383d56 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:02 -0700 Subject: [PATCH 28/40] fix(provider): classify OkHttp call timeouts --- .../service/OpenAiProviderRoutingService.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/service/OpenAiProviderRoutingService.java b/src/main/java/com/williamcallahan/javachat/service/OpenAiProviderRoutingService.java index e94e3651..330bee89 100644 --- a/src/main/java/com/williamcallahan/javachat/service/OpenAiProviderRoutingService.java +++ b/src/main/java/com/williamcallahan/javachat/service/OpenAiProviderRoutingService.java @@ -37,6 +37,14 @@ public class OpenAiProviderRoutingService { private static final String PROVIDER_SETTING_GITHUB_MODELS_ALT = "github-models"; private static final String PROVIDER_SETTING_GITHUB = "github"; + /** + * Identifies the whole-call timeout message emitted by OkHttp 4.12 {@code RealCall.timeoutExit}. + * + *

OpenAI Java 4.16 wraps the corresponding {@link InterruptedIOException} in an + * {@link OpenAIIoException}, which is a provider failure rather than caller cancellation.

+ */ + private static final String OK_HTTP_CALL_TIMEOUT_MESSAGE = "timeout"; + private static final int HTTP_REQUEST_TIMEOUT = 408; private static final int HTTP_CONFLICT = 409; private static final int HTTP_TOO_MANY_REQUESTS = 429; @@ -306,8 +314,9 @@ private boolean isCallerCancellation(Throwable throwable) { Thread.currentThread().interrupt(); return true; } - if (cancellationCandidate instanceof InterruptedIOException - && !(cancellationCandidate instanceof SocketTimeoutException)) { + if (cancellationCandidate instanceof InterruptedIOException interruptedIoException + && !(interruptedIoException instanceof SocketTimeoutException) + && !isOkHttpCallTimeout(interruptedIoException)) { return true; } String cancellationMessage = cancellationCandidate.getMessage(); @@ -320,6 +329,11 @@ private boolean isCallerCancellation(Throwable throwable) { return false; } + private static boolean isOkHttpCallTimeout(InterruptedIOException interruptedIoException) { + return interruptedIoException.getClass().equals(InterruptedIOException.class) + && OK_HTTP_CALL_TIMEOUT_MESSAGE.equals(interruptedIoException.getMessage()); + } + private boolean isPrimaryInBackoff() { return System.currentTimeMillis() < primaryBackoffUntilEpochMs; } From 2a84071261c8d0bdf2c1d9293114b6ca9a6fcce7 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:04 -0700 Subject: [PATCH 29/40] fix(retrieval): retain distinct redacted citations --- .../javachat/service/RetrievalService.java | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java b/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java index f1e967f1..34e7ae73 100644 --- a/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java +++ b/src/main/java/com/williamcallahan/javachat/service/RetrievalService.java @@ -36,6 +36,7 @@ public class RetrievalService { private static final String METADATA_TITLE = "title"; private static final String METADATA_PACKAGE = "package"; private static final String METADATA_HASH = "hash"; + private static final String FILE_URL_PREFIX = "file://"; private static final char URL_FRAGMENT_DELIMITER = '#'; private final HybridSearchService hybridSearchService; @@ -202,8 +203,8 @@ private List deduplicateByContentHashThenHashlessCanonicalUrl(List documents) { return new CitationOutcome(List.of(), 0); } List citations = new ArrayList<>(); - Set retainedCitationUrls = new HashSet<>(); + Set retainedCitationIdentities = new HashSet<>(); int failedConversionCount = 0; for (Document sourceDocument : documents) { if (sourceDocument == null) { @@ -294,9 +295,8 @@ public CitationOutcome toCitations(List documents) { String title = stringMetadataValue(sourceDocMetadata, METADATA_TITLE); String packageName = stringMetadataValue(sourceDocMetadata, METADATA_PACKAGE); String url = refineCitationUrl(rawUrl, sourceDocument.getText(), packageName); - String fragmentlessCitationSourceUrl = fragmentlessCitationSourceUrl(url); - if (!fragmentlessCitationSourceUrl.isBlank() - && !retainedCitationUrls.add(fragmentlessCitationSourceUrl)) { + String citationIdentity = citationIdentityFor(rawUrl, url); + if (!citationIdentity.isBlank() && !retainedCitationIdentities.add(citationIdentity)) { continue; } citations.add(new Citation(url, title, "", trimmedCitationSnippet(sourceDocument.getText()))); @@ -324,6 +324,19 @@ private static String fragmentlessCitationSourceUrl(String citationUrl) { return fragmentDelimiterIndex < 0 ? citationUrl : citationUrl.substring(0, fragmentDelimiterIndex); } + /** + * Keeps redacted display URLs from merging citations for separate unresolved local sources. + */ + private static String citationIdentityFor(String rawUrl, String citationUrl) { + String trimmedRawUrl = rawUrl.trim(); + if (trimmedRawUrl.startsWith(FILE_URL_PREFIX) + && DocsSourceRegistry.resolveLocalPath(trimmedRawUrl.substring(FILE_URL_PREFIX.length())) + .isEmpty()) { + return fragmentlessCitationSourceUrl(trimmedRawUrl); + } + return fragmentlessCitationSourceUrl(citationUrl); + } + /** * Refines a raw document URL through nested-type and member-anchor resolution, then canonicalizes. * @@ -357,7 +370,8 @@ private String safeMetadataValueForLogging(Map metadata, String key) return ""; } try { - return String.valueOf(metadataValue); + String metadataText = String.valueOf(metadataValue); + return METADATA_URL.equals(key) ? DocsSourceRegistry.normalizeDocUrl(metadataText) : metadataText; } catch (RuntimeException _) { return "[unprintable:" + metadataValue.getClass().getSimpleName() + "]"; } From 26318494e44b227adb702321efac51b547b6e943 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:05 -0700 Subject: [PATCH 30/40] fix(docs): validate canonical Java API source rows --- scripts/lib/documentation_sources.sh | 90 +++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 15 deletions(-) diff --git a/scripts/lib/documentation_sources.sh b/scripts/lib/documentation_sources.sh index 9d4f8966..34503c7c 100644 --- a/scripts/lib/documentation_sources.sh +++ b/scripts/lib/documentation_sources.sh @@ -24,6 +24,60 @@ has_boundary_whitespace() { [[ "$manifest_text" =~ ^[[:space:]] || "$manifest_text" =~ [[:space:]]$ ]] } +has_manifest_control_character() { + local manifest_text="$1" + local LC_ALL=C + [[ "$manifest_text" == *[[:cntrl:]]* ]] +} + +is_absolute_https_remote_base_url() { + local remote_base_url="$1" + if [[ "$remote_base_url" != https://* || "$remote_base_url" != */ ]]; then + return 1 + fi + if [[ "$remote_base_url" == *[[:space:]]* || "$remote_base_url" == *\\* ]]; then + return 1 + fi + + local remote_authority_and_path="${remote_base_url#https://}" + local remote_authority="${remote_authority_and_path%%[/?#]*}" + if [ -z "$remote_authority" ]; then + return 1 + fi + + local remote_host_and_port="${remote_authority##*@}" + if [ -z "$remote_host_and_port" ]; then + return 1 + fi + if [[ "$remote_host_and_port" == \[* ]]; then + [[ "$remote_host_and_port" =~ ^\[[0-9A-Fa-f:.]+\](:[0-9]+)?$ ]] + return + fi + [[ "$remote_host_and_port" =~ ^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*(:[0-9]+)?$ ]] +} + +is_normalized_relative_mirror_path() { + local relative_mirror_path="$1" + if [[ "$relative_mirror_path" == /* \ + || "$relative_mirror_path" == */ \ + || "$relative_mirror_path" == *//* \ + || "$relative_mirror_path" == *\\* ]]; then + return 1 + fi + + local IFS='/' + local -a mirror_path_segments + read -r -a mirror_path_segments <<< "$relative_mirror_path" + local mirror_path_segment + for mirror_path_segment in "${mirror_path_segments[@]}"; do + if [ -z "$mirror_path_segment" ] \ + || [ "$mirror_path_segment" = "." ] \ + || [ "$mirror_path_segment" = ".." ]; then + return 1 + fi + done +} + is_blank_manifest_line() { local manifest_line="$1" local LC_ALL=C @@ -110,13 +164,29 @@ load_java_api_documentation_sources() { echo "Java API source manifest line $manifest_line_number has an invalid Java release" >&2 return 1 fi - if [ -z "$remoteBaseUrl" ] || [ -z "$relativeMirrorPath" ] || [ -z "$displayName" ] \ - || has_boundary_whitespace "$remoteBaseUrl" \ - || has_boundary_whitespace "$relativeMirrorPath" \ - || has_boundary_whitespace "$displayName"; then + if [ -z "$remoteBaseUrl" ] || [ -z "$relativeMirrorPath" ] || [ -z "$displayName" ]; then echo "Java API source manifest line $manifest_line_number has a blank required field" >&2 return 1 fi + if has_boundary_whitespace "$remoteBaseUrl" \ + || has_boundary_whitespace "$relativeMirrorPath" \ + || has_boundary_whitespace "$displayName" \ + || has_boundary_whitespace "$rejectRegex" \ + || has_manifest_control_character "$remoteBaseUrl" \ + || has_manifest_control_character "$relativeMirrorPath" \ + || has_manifest_control_character "$displayName" \ + || has_manifest_control_character "$rejectRegex"; then + echo "Java API source manifest line $manifest_line_number has invalid text fields" >&2 + return 1 + fi + if ! is_absolute_https_remote_base_url "$remoteBaseUrl"; then + echo "Java API source manifest line $manifest_line_number has an invalid remote base URL" >&2 + return 1 + fi + if ! is_normalized_relative_mirror_path "$relativeMirrorPath"; then + echo "Java API source manifest line $manifest_line_number has an invalid relative mirror path" >&2 + return 1 + fi if ! is_canonical_manifest_integer "$cutDirectories"; then echo "Java API source manifest line $manifest_line_number has invalid cut directories" >&2 return 1 @@ -160,19 +230,9 @@ load_java_api_documentation_sources() { } append_java_api_fetch_sources() { - local docs_root="$1" local java_api_source_projection for java_api_source_projection in "${JAVA_API_SOURCE_PROJECTIONS[@]}"; do - local javaRelease - local remoteBaseUrl - local relativeMirrorPath - local displayName - local cutDirectories - local minimumHtmlFiles - local rejectRegex - local allowPartial - IFS='|' read -r javaRelease remoteBaseUrl relativeMirrorPath displayName cutDirectories minimumHtmlFiles rejectRegex allowPartial <<< "$java_api_source_projection" - DOC_SOURCES+=("$remoteBaseUrl|$docs_root/$relativeMirrorPath|$displayName|$cutDirectories|$minimumHtmlFiles|$rejectRegex|$allowPartial") + DOC_SOURCES+=("$java_api_source_projection") done } From 2a120a5ce927bc655f9ca3f197fa44af6324f3e8 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:05 -0700 Subject: [PATCH 31/40] fix(docs): dispatch exact Java API source rows --- scripts/fetch_all_docs.sh | 145 +++++++++++++++++++++++++------------- 1 file changed, 96 insertions(+), 49 deletions(-) diff --git a/scripts/fetch_all_docs.sh b/scripts/fetch_all_docs.sh index 7b6c597a..17e5fcec 100755 --- a/scripts/fetch_all_docs.sh +++ b/scripts/fetch_all_docs.sh @@ -183,30 +183,32 @@ validate_fetch_result() { return 0 } -# Fetches Oracle Javadoc using an explicit seed list derived from the Javadoc +# Fetches a manifest-governed Java API using an explicit seed list derived from the Javadoc # search indices. This avoids incomplete recursive crawls that miss pages. # # Arguments: -# $1 - Oracle Javadoc base URL +# $1 - Java API Javadoc base URL # $2 - target directory # $3 - human-readable name # $4 - --cut-dirs value # $5 - minimum required HTML files -# $6 - whether a validated partial mirror is accepted -fetch_oracle_javadoc_seed() { +# $6 - reject regex (optional) +# $7 - whether a validated partial mirror is accepted +fetch_java_api_javadoc_seed() { local url="$1" local target_dir="$2" local name="$3" local cut_dirs="$4" local min_files="$5" - local partial_mirror_allowed="$6" + local reject_regex="${6:-}" + local partial_mirror_allowed="$7" local seed_file="$target_dir/.oracle-javadoc-seed.txt" - log "${BLUE}ℹ Oracle Javadoc detected; generating explicit URL seed list...${NC}" + log "${BLUE}ℹ Java API Javadoc source; generating explicit URL seed list...${NC}" python3 "$SCRIPT_DIR/oracle_javadoc_seed.py" --base-url "$url" --output "$seed_file" 2>&1 | tee -a "$LOG_FILE" local seed_url_count seed_url_count="$(wc -l "$seed_file" | awk '{print $1}')" - log "${BLUE}ℹ Oracle Javadoc seed URLs: $seed_url_count${NC}" + log "${BLUE}ℹ Java API Javadoc seed URLs: $seed_url_count${NC}" local wget_seed_args=( --timestamping @@ -225,6 +227,9 @@ fetch_oracle_javadoc_seed() { --retry-connrefused --user-agent="java-chat-doc-fetcher/1.0" ) + if [ -n "$reject_regex" ]; then + wget_seed_args+=(--reject-regex="$reject_regex") + fi wget "${wget_seed_args[@]}" 2>&1 | tee -a "$LOG_FILE" local wget_exit_code=$? @@ -288,24 +293,27 @@ fetch_docs_mirror() { # Dispatches documentation fetching: performs pre-fetch housekeeping (existing # mirror check, quarantine, legacy cleanup), then delegates to the appropriate -# strategy — seed-list for Oracle Javadoc, wget --mirror for everything else. +# strategy — seed-list for manifest-governed Java APIs, wget --mirror for everything else. # -# Arguments (pipe-delimited config row): -# $1 - URL -# $2 - target directory -# $3 - human-readable name -# $4 - --cut-dirs value -# $5 - minimum required HTML files -# $6 - reject regex (optional) -# $7 - whether a nonempty partial mirror is retained for incremental reruns +# Arguments (canonical manifest order): +# $1 - Java release, blank only for non-Java documentation +# $2 - URL +# $3 - relative mirror path beneath DOCS_ROOT +# $4 - human-readable name +# $5 - --cut-dirs value +# $6 - minimum required HTML files +# $7 - reject regex (optional) +# $8 - whether a nonempty partial mirror is retained for incremental reruns fetch_docs() { - local url="$1" - local target_dir="$2" - local name="$3" - local cut_dirs="$4" - local min_files="$5" - local reject_regex="${6:-}" - local partial_mirror_allowed="$7" + local java_release="$1" + local url="$2" + local relative_mirror_path="$3" + local name="$4" + local cut_dirs="$5" + local min_files="$6" + local reject_regex="${7:-}" + local partial_mirror_allowed="$8" + local target_dir="$DOCS_ROOT/$relative_mirror_path" # Allow config-friendly placeholder for regex alternation without breaking our field delimiter. reject_regex="${reject_regex//__OR__/|}" @@ -336,34 +344,72 @@ fetch_docs() { cd "$target_dir" # ── Dispatch to strategy ── - if [[ "$url" == *"docs.oracle.com/en/java/javase/"*"/docs/api/" ]]; then + if [ -n "$java_release" ]; then if [ "$FORCE_REFRESH" != "true" ] && [ "$min_files" -gt 0 ] && [ "$existing_count" -ge "$min_files" ]; then log "${GREEN}✓ $name already fetched: $existing_count HTML files (minimum: $min_files)${NC}" return 0 fi - fetch_oracle_javadoc_seed "$url" "$target_dir" "$name" "$cut_dirs" "$min_files" "$partial_mirror_allowed" + fetch_java_api_javadoc_seed "$url" "$target_dir" "$name" "$cut_dirs" "$min_files" "$reject_regex" "$partial_mirror_allowed" else fetch_docs_mirror "$url" "$target_dir" "$name" "$cut_dirs" "$min_files" "$reject_regex" "$partial_mirror_allowed" fi } -# Projects one seven-field documentation source row into the fetch boundary. +# Projects one eight-field documentation source row into the fetch boundary. fetch_documentation_source() { local documentation_source_projection="$1" local fetch_projection_delimiters="${documentation_source_projection//[^|]/}" - if [ "${#fetch_projection_delimiters}" -ne 6 ]; then - log "${RED}✗ Documentation source projection must contain exactly seven fields${NC}" + if [ "${#fetch_projection_delimiters}" -ne 7 ]; then + log "${RED}✗ Documentation source projection must contain exactly eight fields${NC}" return 1 fi + local java_release local documentation_source_url - local mirror_directory + local relative_mirror_path local documentation_source_name local cut_directories local minimum_html_files local reject_regex local partial_mirror_allowed - IFS='|' read -r documentation_source_url mirror_directory documentation_source_name cut_directories minimum_html_files reject_regex partial_mirror_allowed <<< "$documentation_source_projection" + IFS='|' read -r java_release documentation_source_url relative_mirror_path documentation_source_name cut_directories minimum_html_files reject_regex partial_mirror_allowed <<< "$documentation_source_projection" + + if [ -z "$documentation_source_url" ] || [ -z "$relative_mirror_path" ] || [ -z "$documentation_source_name" ]; then + log "${RED}✗ Documentation source projection has a blank required field${NC}" + return 1 + fi + if has_boundary_whitespace "$documentation_source_url" \ + || has_boundary_whitespace "$relative_mirror_path" \ + || has_boundary_whitespace "$documentation_source_name" \ + || has_boundary_whitespace "$reject_regex" \ + || has_manifest_control_character "$documentation_source_url" \ + || has_manifest_control_character "$relative_mirror_path" \ + || has_manifest_control_character "$documentation_source_name" \ + || has_manifest_control_character "$reject_regex"; then + log "${RED}✗ Documentation source projection has invalid text fields${NC}" + return 1 + fi + if [ -n "$java_release" ] \ + && { ! is_canonical_manifest_integer "$java_release" || [ "$java_release" = "0" ]; }; then + log "${RED}✗ Documentation source Java release must be blank or a positive canonical integer${NC}" + return 1 + fi + if ! is_normalized_relative_mirror_path "$relative_mirror_path"; then + log "${RED}✗ Documentation source mirror path must be normalized and relative${NC}" + return 1 + fi + if [ -n "$java_release" ] && ! is_absolute_https_remote_base_url "$documentation_source_url"; then + log "${RED}✗ Java API documentation source URL must be an absolute HTTPS base URL${NC}" + return 1 + fi + if ! is_canonical_manifest_integer "$cut_directories"; then + log "${RED}✗ Documentation source cut-directories value must be a canonical integer${NC}" + return 1 + fi + if ! is_canonical_manifest_integer "$minimum_html_files" || [ "$minimum_html_files" = "0" ]; then + log "${RED}✗ Documentation source minimum HTML files must be a positive canonical integer${NC}" + return 1 + fi if [ "$partial_mirror_allowed" != "true" ] && [ "$partial_mirror_allowed" != "false" ]; then log "${RED}✗ Documentation source partial-mirror policy must be true or false${NC}" @@ -373,9 +419,9 @@ fetch_documentation_source() { echo "" log "Processing: $documentation_source_name" log "URL: $documentation_source_url" - log "Target: $mirror_directory" + log "Target: $DOCS_ROOT/$relative_mirror_path" - fetch_docs "$documentation_source_url" "$mirror_directory" "$documentation_source_name" "$cut_directories" "$minimum_html_files" "$reject_regex" "$partial_mirror_allowed" + fetch_docs "$java_release" "$documentation_source_url" "$relative_mirror_path" "$documentation_source_name" "$cut_directories" "$minimum_html_files" "$reject_regex" "$partial_mirror_allowed" } run_documentation_fetch() { @@ -386,32 +432,33 @@ fi parse_fetch_arguments "$@" # Documentation sources configuration -# Format: URL|TARGET_DIR|NAME|CUT_DIRS|MIN_FILES|REJECT_REGEX|ALLOW_PARTIAL +# Format: JAVA_RELEASE|URL|RELATIVE_MIRROR_PATH|NAME|CUT_DIRS|MIN_FILES|REJECT_REGEX|ALLOW_PARTIAL +# JAVA_RELEASE is blank for non-Java documentation sources. DOC_SOURCES=( # Spring Boot (current) - "${SPRING_BOOT_REFERENCE_BASE:-https://docs.spring.io/spring-boot/reference/}|$DOCS_ROOT/spring-boot-complete|Spring Boot Reference (current)|1|50||false" - "${SPRING_BOOT_API_BASE:-https://docs.spring.io/spring-boot/api/}|$DOCS_ROOT/spring-boot-complete|Spring Boot API (current)|1|7000||false" + "|${SPRING_BOOT_REFERENCE_BASE:-https://docs.spring.io/spring-boot/reference/}|spring-boot-complete|Spring Boot Reference (current)|1|50||false" + "|${SPRING_BOOT_API_BASE:-https://docs.spring.io/spring-boot/api/}|spring-boot-complete|Spring Boot API (current)|1|7000||false" # Spring AI # Stable reference (1.1.x) - avoid pulling versioned reference subtrees (including 2.0) and SNAPSHOT content. - "${SPRING_AI_REFERENCE_BASE:-https://docs.spring.io/spring-ai/reference/}|$DOCS_ROOT/spring-ai-reference|Spring AI Reference (stable)|1|80|/spring-ai/reference/[0-9]__OR__/spring-ai/reference/[^/]*SNAPSHOT|false" + "|${SPRING_AI_REFERENCE_BASE:-https://docs.spring.io/spring-ai/reference/}|spring-ai-reference|Spring AI Reference (stable)|1|80|/spring-ai/reference/[0-9]__OR__/spring-ai/reference/[^/]*SNAPSHOT|false" # 2.0 reference (milestone) - avoid SNAPSHOT content. - "${SPRING_AI_REFERENCE_2_BASE:-https://docs.spring.io/spring-ai/reference/2.0/}|$DOCS_ROOT/spring-ai-reference-2|Spring AI Reference (2.0)|1|80|/spring-ai/reference/[^/]*SNAPSHOT|false" + "|${SPRING_AI_REFERENCE_2_BASE:-https://docs.spring.io/spring-ai/reference/2.0/}|spring-ai-reference-2|Spring AI Reference (2.0)|1|80|/spring-ai/reference/[^/]*SNAPSHOT|false" # API docs (stable + 2.x). Keep these separate to avoid quarantine/validation conflicts. - "${SPRING_AI_API_STABLE_BASE:-https://docs.spring.io/spring-ai/docs/current/api/}|$DOCS_ROOT/spring-ai-api-stable|Spring AI API (stable)|1|200||false" - "${SPRING_AI_API_2_BASE:-https://docs.spring.io/spring-ai/docs/2.0.x/api/}|$DOCS_ROOT/spring-ai-api-2|Spring AI API (2.x)|1|200||false" + "|${SPRING_AI_API_STABLE_BASE:-https://docs.spring.io/spring-ai/docs/current/api/}|spring-ai-api-stable|Spring AI API (stable)|1|200||false" + "|${SPRING_AI_API_2_BASE:-https://docs.spring.io/spring-ai/docs/2.0.x/api/}|spring-ai-api-2|Spring AI API (2.x)|1|200||false" # Spring Framework (current) - avoid pulling older reference versions under /reference/6.x, etc. - "${SPRING_FRAMEWORK_REFERENCE_BASE:-https://docs.spring.io/spring-framework/reference/}|$DOCS_ROOT/spring-framework-complete|Spring Framework Reference (current)|1|3000|/spring-framework/reference/[0-9]__OR__/spring-framework/reference/[^/]*SNAPSHOT|false" - "${SPRING_FRAMEWORK_API_BASE:-https://docs.spring.io/spring-framework/docs/current/javadoc-api/}|$DOCS_ROOT/spring-framework-complete|Spring Framework Javadoc (current)|1|7000||false" + "|${SPRING_FRAMEWORK_REFERENCE_BASE:-https://docs.spring.io/spring-framework/reference/}|spring-framework-complete|Spring Framework Reference (current)|1|3000|/spring-framework/reference/[0-9]__OR__/spring-framework/reference/[^/]*SNAPSHOT|false" + "|${SPRING_FRAMEWORK_API_BASE:-https://docs.spring.io/spring-framework/docs/current/javadoc-api/}|spring-framework-complete|Spring Framework Javadoc (current)|1|7000||false" - "${JAVA25_RELEASE_NOTES_ISSUES_URL:-https://www.oracle.com/java/technologies/javase/25-relnote-issues.html}|$DOCS_ROOT/oracle/javase|Java 25 Release Notes Issues|3|1||false" - "${IBM_JAVA25_ARTICLE_URL:-https://developer.ibm.com/articles/java-whats-new-java25/}|$DOCS_ROOT/ibm/articles|IBM Java 25 Overview|1|1||false" - "${JETBRAINS_JAVA25_BLOG_URL:-https://blog.jetbrains.com/idea/2025/09/java-25-lts-and-intellij-idea/}|$DOCS_ROOT/jetbrains/idea/2025/09|JetBrains Java 25 Blog|3|1||false" + "|${JAVA25_RELEASE_NOTES_ISSUES_URL:-https://www.oracle.com/java/technologies/javase/25-relnote-issues.html}|oracle/javase|Java 25 Release Notes Issues|3|1||false" + "|${IBM_JAVA25_ARTICLE_URL:-https://developer.ibm.com/articles/java-whats-new-java25/}|ibm/articles|IBM Java 25 Overview|1|1||false" + "|${JETBRAINS_JAVA25_BLOG_URL:-https://blog.jetbrains.com/idea/2025/09/java-25-lts-and-intellij-idea/}|jetbrains/idea/2025/09|JetBrains Java 25 Blog|3|1||false" ) load_java_api_documentation_sources "$JAVA_API_SOURCES_MANIFEST" -append_java_api_fetch_sources "$DOCS_ROOT" +append_java_api_fetch_sources if [ "$LIST_JAVA_API_SOURCES" = "true" ]; then printf '%s\n' "${JAVA_API_SOURCE_PROJECTIONS[@]}" @@ -420,10 +467,10 @@ fi if [ "$INCLUDE_QUICK" = "true" ]; then DOC_SOURCES+=( - "${SPRING_BOOT_REFERENCE_BASE:-https://docs.spring.io/spring-boot/reference/}|$DOCS_ROOT/spring-boot|Spring Boot Quick (reference landing)|1|1||false" - "${SPRING_FRAMEWORK_REFERENCE_BASE:-https://docs.spring.io/spring-framework/reference/}|$DOCS_ROOT/spring-framework|Spring Framework Quick (reference landing)|1|1|/spring-framework/reference/[0-9]__OR__/spring-framework/reference/[^/]*SNAPSHOT|false" - "${SPRING_AI_REFERENCE_BASE:-https://docs.spring.io/spring-ai/reference/}|$DOCS_ROOT/spring-ai|Spring AI Quick (reference landing)|1|1|/spring-ai/reference/[0-9]__OR__/spring-ai/reference/[^/]*SNAPSHOT|false" - "${SPRING_AI_REFERENCE_2_BASE:-https://docs.spring.io/spring-ai/reference/2.0/}|$DOCS_ROOT/spring-ai-2|Spring AI Quick (2.0 landing)|1|1|/spring-ai/reference/[^/]*SNAPSHOT|false" + "|${SPRING_BOOT_REFERENCE_BASE:-https://docs.spring.io/spring-boot/reference/}|spring-boot|Spring Boot Quick (reference landing)|1|1||false" + "|${SPRING_FRAMEWORK_REFERENCE_BASE:-https://docs.spring.io/spring-framework/reference/}|spring-framework|Spring Framework Quick (reference landing)|1|1|/spring-framework/reference/[0-9]__OR__/spring-framework/reference/[^/]*SNAPSHOT|false" + "|${SPRING_AI_REFERENCE_BASE:-https://docs.spring.io/spring-ai/reference/}|spring-ai|Spring AI Quick (reference landing)|1|1|/spring-ai/reference/[0-9]__OR__/spring-ai/reference/[^/]*SNAPSHOT|false" + "|${SPRING_AI_REFERENCE_2_BASE:-https://docs.spring.io/spring-ai/reference/2.0/}|spring-ai-2|Spring AI Quick (2.0 landing)|1|1|/spring-ai/reference/[^/]*SNAPSHOT|false" ) fi From e08ed9d6e08a89ffe40b62c7fbdb5ee776763092 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:06 -0700 Subject: [PATCH 32/40] test(docs): enforce complete Java API fetch parity --- scripts/test_java_api_fetch_projection.sh | 119 +++++++++++++++------- 1 file changed, 83 insertions(+), 36 deletions(-) diff --git a/scripts/test_java_api_fetch_projection.sh b/scripts/test_java_api_fetch_projection.sh index d28aa76b..5b6ec80c 100755 --- a/scripts/test_java_api_fetch_projection.sh +++ b/scripts/test_java_api_fetch_projection.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Verifies that canonical Java API manifest rows retain their seven-field fetch projection. +# Verifies that canonical Java API manifest rows retain their eight-field fetch projection. set -euo pipefail @@ -33,7 +33,7 @@ fetch_docs() { JAVA_API_SOURCE_PROJECTIONS=() load_java_api_documentation_sources "$JAVA_API_SOURCES_MANIFEST" DOC_SOURCES=() -append_java_api_fetch_sources "$TEST_DOCS_ROOT" +append_java_api_fetch_sources if [ "${#JAVA_API_SOURCE_PROJECTIONS[@]}" -ne "${#DOC_SOURCES[@]}" ]; then fail_java_api_fetch_projection_test "fetch projections did not preserve the complete manifest order" @@ -43,41 +43,63 @@ for java_api_source_index in "${!JAVA_API_SOURCE_PROJECTIONS[@]}"; do manifest_projection="${JAVA_API_SOURCE_PROJECTIONS[$java_api_source_index]}" documentation_fetch_projection="${DOC_SOURCES[$java_api_source_index]}" fetch_projection_delimiters="${documentation_fetch_projection//[^|]/}" - if [ "${#fetch_projection_delimiters}" -ne 6 ]; then - fail_java_api_fetch_projection_test "Java API fetch projection at index $java_api_source_index must contain exactly seven fields" + if [ "${#fetch_projection_delimiters}" -ne 7 ]; then + fail_java_api_fetch_projection_test "Java API fetch projection at index $java_api_source_index must contain exactly eight fields" fi - manifest_projection_without_release="${manifest_projection#*|}" - expected_remote_base_url="${manifest_projection_without_release%%|*}" - manifest_projection_after_remote_base_url="${manifest_projection_without_release#*|}" - expected_relative_mirror_path="${manifest_projection_after_remote_base_url%%|*}" - expected_fetch_projection="$expected_remote_base_url|$TEST_DOCS_ROOT/$expected_relative_mirror_path|${manifest_projection_after_remote_base_url#*|}" - if [ "$documentation_fetch_projection" != "$expected_fetch_projection" ]; then + if [ "$documentation_fetch_projection" != "$manifest_projection" ]; then fail_java_api_fetch_projection_test "Java API fetch projection at index $java_api_source_index diverged from the canonical manifest row" fi fetch_execution_arguments=() fetch_documentation_source "$documentation_fetch_projection" > /dev/null - if [ "${#fetch_execution_arguments[@]}" -ne 7 ]; then - fail_java_api_fetch_projection_test "Java API fetch execution at index $java_api_source_index must receive exactly seven fields" + if [ "${#fetch_execution_arguments[@]}" -ne 8 ]; then + fail_java_api_fetch_projection_test "Java API fetch execution at index $java_api_source_index must receive exactly eight fields" fi actual_fetch_projection="$(IFS='|'; printf '%s' "${fetch_execution_arguments[*]}")" - if [ "$actual_fetch_projection" != "$documentation_fetch_projection" ]; then + if [ "$actual_fetch_projection" != "$manifest_projection" ]; then fail_java_api_fetch_projection_test "Java API fetch execution at index $java_api_source_index diverged from the appended projection" fi expected_allow_partial="${manifest_projection##*|}" manifest_projection_without_allow_partial="${manifest_projection%|*}" expected_reject_regex="${manifest_projection_without_allow_partial##*|}" - if [ "${fetch_execution_arguments[5]}" != "$expected_reject_regex" ]; then + if [ "${fetch_execution_arguments[6]}" != "$expected_reject_regex" ]; then fail_java_api_fetch_projection_test "Java API reject regex at index $java_api_source_index was not projected separately" fi - if [ "${fetch_execution_arguments[6]}" != "$expected_allow_partial" ]; then + if [ "${fetch_execution_arguments[7]}" != "$expected_allow_partial" ]; then fail_java_api_fetch_projection_test "Java API partial-mirror policy at index $java_api_source_index was not projected separately" fi done +generic_fetch_projection="|https://example.invalid/reference.html|generic/reference|Generic Reference|1|1||false" +fetch_execution_arguments=() +fetch_documentation_source "$generic_fetch_projection" > /dev/null +if [ "${#fetch_execution_arguments[@]}" -ne 8 ] \ + || [ -n "${fetch_execution_arguments[0]}" ] \ + || [ -n "${fetch_execution_arguments[6]}" ]; then + fail_java_api_fetch_projection_test "generic projection did not preserve empty Java-release and reject-regex fields" +fi + +fetch_execution_arguments=() +if fetch_documentation_source "https://example.invalid/|generic|Generic|1|1||false" > /dev/null; then + fail_java_api_fetch_projection_test "legacy seven-field projection was accepted" +fi +if [ "${#fetch_execution_arguments[@]}" -ne 0 ]; then + fail_java_api_fetch_projection_test "invalid seven-field projection reached fetch execution" +fi + +for invalid_relative_mirror_path in "../outside" "/absolute" 'backslash\path'; do + fetch_execution_arguments=() + if fetch_documentation_source "|https://example.invalid/|$invalid_relative_mirror_path|Invalid path|1|1||false" > /dev/null; then + fail_java_api_fetch_projection_test "unsafe mirror path reached fetch execution: $invalid_relative_mirror_path" + fi + if [ "${#fetch_execution_arguments[@]}" -ne 0 ]; then + fail_java_api_fetch_projection_test "unsafe mirror path invoked fetch execution: $invalid_relative_mirror_path" + fi +done + set -- # shellcheck source=fetch_all_docs.sh source "$FETCH_SCRIPT" @@ -127,70 +149,95 @@ quarantine_capture_file="$TEST_DOCS_ROOT/quarantine.log" quarantine_incomplete_dir() { printf 'quarantined\n' >> "$quarantine_capture_file" } +generic_dispatch_capture_file="$TEST_DOCS_ROOT/generic-dispatch" fetch_docs_mirror() { + printf '%s|%s|%s\n' "$1" "$2" "$7" > "$generic_dispatch_capture_file" return 0 } -if ! (fetch_docs "https://example.com/api/" "$TEST_DOCS_ROOT/partial" "Partial mirror" 1 10 "" true); then +DOCS_ROOT="$TEST_DOCS_ROOT" +if ! (fetch_docs "" "https://example.com/api/" "partial" "Partial mirror" 1 10 "" true); then fail_java_api_fetch_projection_test "allowPartial=true fetch dispatch failed" fi if [ -s "$quarantine_capture_file" ]; then fail_java_api_fetch_projection_test "allowPartial=true quarantined an incremental mirror" fi -if ! (fetch_docs "https://example.com/api/" "$TEST_DOCS_ROOT/complete-required" "Complete mirror" 1 10 "" false); then +if ! (fetch_docs "" "https://example.com/api/" "complete-required" "Complete mirror" 1 10 "" false); then fail_java_api_fetch_projection_test "allowPartial=false fetch dispatch failed" fi if [ ! -s "$quarantine_capture_file" ]; then fail_java_api_fetch_projection_test "allowPartial=false did not quarantine an incomplete mirror" fi -oracle_dispatch_capture_file="$TEST_DOCS_ROOT/oracle-dispatch-policy" -fetch_oracle_javadoc_seed() { - printf '%s\n' "$6" > "$oracle_dispatch_capture_file" +java_api_dispatch_capture_file="$TEST_DOCS_ROOT/java-api-dispatch" +fetch_java_api_javadoc_seed() { + printf '%s|%s|%s\n' "$1" "$6" "$7" > "$java_api_dispatch_capture_file" } if ! (fetch_docs \ - "https://docs.oracle.com/en/java/javase/21/docs/api/" \ - "$TEST_DOCS_ROOT/oracle-dispatch" \ + "21" \ + "https://example.invalid/javadoc/" \ + "java/java21-complete" \ "Java 21 API" \ 5 \ 10 \ "" \ true); then - fail_java_api_fetch_projection_test "Oracle Javadoc fetch dispatch failed" + fail_java_api_fetch_projection_test "manifest-governed Java API fetch dispatch failed" +fi +if [ "$(cat "$java_api_dispatch_capture_file")" != "https://example.invalid/javadoc/||true" ]; then + fail_java_api_fetch_projection_test "Java API dispatch did not preserve manifest identity independently of URL host" +fi + +if ! (fetch_docs \ + "" \ + "https://docs.oracle.com/en/java/javase/21/docs/api/" \ + "generic/oracle-shaped-url" \ + "Generic Oracle-shaped URL" \ + 5 \ + 10 \ + "" \ + true); then + fail_java_api_fetch_projection_test "generic source with an Oracle-shaped URL failed mirror dispatch" fi -if [ "$(cat "$oracle_dispatch_capture_file")" != "true" ]; then - fail_java_api_fetch_projection_test "Oracle Javadoc dispatch dropped the partial-mirror policy" +if [ "$(cat "$generic_dispatch_capture_file")" != "https://docs.oracle.com/en/java/javase/21/docs/api/|$TEST_DOCS_ROOT/generic/oracle-shaped-url|true" ]; then + fail_java_api_fetch_projection_test "URL text overrode blank Java API identity" fi set -- # shellcheck source=fetch_all_docs.sh source "$FETCH_SCRIPT" -LOG_FILE="$TEST_DOCS_ROOT/oracle-seed-fetch.log" -oracle_validation_capture_file="$TEST_DOCS_ROOT/oracle-validation-policy" +LOG_FILE="$TEST_DOCS_ROOT/java-api-seed-fetch.log" +java_api_validation_capture_file="$TEST_DOCS_ROOT/java-api-validation-policy" +java_api_wget_arguments_capture_file="$TEST_DOCS_ROOT/java-api-wget-arguments" python3() { : > "$5" } wget() { + printf '%s\n' "$@" > "$java_api_wget_arguments_capture_file" return 0 } validate_fetch_result() { - printf '%s\n' "$5" > "$oracle_validation_capture_file" + printf '%s\n' "$5" > "$java_api_validation_capture_file" } -mkdir -p "$TEST_DOCS_ROOT/oracle-validation" -if ! (cd "$TEST_DOCS_ROOT/oracle-validation" \ - && fetch_oracle_javadoc_seed \ +mkdir -p "$TEST_DOCS_ROOT/java-api-validation" +if ! (cd "$TEST_DOCS_ROOT/java-api-validation" \ + && fetch_java_api_javadoc_seed \ "https://docs.oracle.com/en/java/javase/21/docs/api/" \ - "$TEST_DOCS_ROOT/oracle-validation" \ + "$TEST_DOCS_ROOT/java-api-validation" \ "Java 21 API" \ 5 \ 10 \ + "excluded-path" \ true); then - fail_java_api_fetch_projection_test "Oracle Javadoc validation boundary failed" + fail_java_api_fetch_projection_test "Java API Javadoc validation boundary failed" +fi +if [ "$(cat "$java_api_validation_capture_file")" != "true" ]; then + fail_java_api_fetch_projection_test "Java API Javadoc validation dropped the partial-mirror policy" fi -if [ "$(cat "$oracle_validation_capture_file")" != "true" ]; then - fail_java_api_fetch_projection_test "Oracle Javadoc validation dropped the partial-mirror policy" +if ! grep -Fxq -- '--reject-regex=excluded-path' "$java_api_wget_arguments_capture_file"; then + fail_java_api_fetch_projection_test "Java API seed fetch dropped the manifest reject regex" fi run_summary_capture_file="$TEST_DOCS_ROOT/run-summary" @@ -211,4 +258,4 @@ if [ "$aggregated_fetched_count" -ne 0 ] || [ "$aggregated_partial_count" -le 0 fail_java_api_fetch_projection_test "a full fetch run did not aggregate retained partial mirrors separately from failures" fi -printf 'PASS: Java API fetch projections preserve partial mirrors without reporting completion.\n' +printf 'PASS: Java API fetch projections preserve all eight manifest fields and partial-mirror semantics.\n' From e61968f14858f803b0586963df9dd5f4f3e8bb64 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:06 -0700 Subject: [PATCH 33/40] test(config): reject unsafe Java API manifest values --- .../config/JavaApiDocumentationManifestTest.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/test/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifestTest.java b/src/test/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifestTest.java index 2453bade..f5b1caac 100644 --- a/src/test/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifestTest.java +++ b/src/test/java/com/williamcallahan/javachat/config/JavaApiDocumentationManifestTest.java @@ -56,10 +56,20 @@ void rejectsSharedInvalidManifestFixturesInJavaAndBash() throws IOException, Int .get(1) .substring(0, canonicalManifestLines.get(1).lastIndexOf('|'))), withColumn(canonicalManifestLines, 1, 1, " https://docs.oracle.com/invalid/"), + withColumn(canonicalManifestLines, 1, 1, "http://example.invalid/invalid/"), + withColumn(canonicalManifestLines, 1, 1, "https:///invalid/"), + withColumn(canonicalManifestLines, 1, 1, "https://example.invalid/invalid"), withColumn(canonicalManifestLines, 1, 2, ""), + withColumn(canonicalManifestLines, 1, 2, "java/./java21-complete"), + withColumn(canonicalManifestLines, 1, 2, "java/../java21-complete"), + withColumn(canonicalManifestLines, 1, 2, "/java/java21-complete"), + withColumn(canonicalManifestLines, 1, 2, "java\\java21-complete"), + withColumn(canonicalManifestLines, 1, 2, "java//java21-complete"), withColumn(canonicalManifestLines, 1, 4, "+5"), withColumn(canonicalManifestLines, 1, 4, "05"), withColumn(canonicalManifestLines, 1, 5, "2147483648"), + withColumn(canonicalManifestLines, 1, 6, "\u0001"), + withColumn(canonicalManifestLines, 1, 6, " reject-pattern"), withColumn(canonicalManifestLines, 1, 7, "TRUE"), withDuplicateRelease(canonicalManifestLines), withDuplicateMirrorPath(canonicalManifestLines)); From b95c521b1f7d95bc2b123573e71675415b89af91 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:07 -0700 Subject: [PATCH 34/40] test(ingestion): reject Java API selector aliases --- .../JavaApiDocumentationSourceParityTest.java | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/williamcallahan/javachat/cli/JavaApiDocumentationSourceParityTest.java b/src/test/java/com/williamcallahan/javachat/cli/JavaApiDocumentationSourceParityTest.java index 0e37992b..e75dba9e 100644 --- a/src/test/java/com/williamcallahan/javachat/cli/JavaApiDocumentationSourceParityTest.java +++ b/src/test/java/com/williamcallahan/javachat/cli/JavaApiDocumentationSourceParityTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.williamcallahan.javachat.config.DocsSourceRegistry; import com.williamcallahan.javachat.config.DocsSourceRegistry.JavaApiDocumentationSource; @@ -10,6 +11,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Locale; import java.util.Set; import org.junit.jupiter.api.Test; @@ -18,7 +20,7 @@ */ class JavaApiDocumentationSourceParityTest { - private static final Set LEGACY_QUICK_JAVA_DOCSET_TOKENS = Set.of("java24", "java25"); + private static final String LEGACY_JAVA_QUICK_SELECTOR_PREFIX = "java"; @Test void projectsCanonicalJavaApiSourcesIntoCliCatalogAndFetchScript() throws IOException, InterruptedException { @@ -32,8 +34,6 @@ void projectsCanonicalJavaApiSourcesIntoCliCatalogAndFetchScript() throws IOExce .toList(); assertEquals(expectedCliDocumentationSets, actualCliDocumentationSets); - assertFalse(DocumentationSetCatalog.allSets().stream() - .anyMatch(documentationSet -> documentationSet.matchesAny(LEGACY_QUICK_JAVA_DOCSET_TOKENS))); Path fetchScriptPath = Path.of("scripts", "fetch_all_docs.sh").toAbsolutePath(); ProcessBuilder fetchSourceListingCommand = @@ -71,4 +71,41 @@ void projectsCanonicalJavaApiSourcesIntoCliCatalogAndFetchScript() throws IOExce assertEquals(0, fetchProjectionTestExitCode, fetchProjectionTestOutput); } + + @Test + void acceptsOnlyCanonicalRelativeMirrorPathsForManifestBackedJavaApiSets() { + for (JavaApiDocumentationSource javaApiDocumentationSource : DocsSourceRegistry.javaApiDocumentationSources()) { + DocumentationSet catalogDocumentationSet = DocumentationSetCatalog.allSets().stream() + .filter(documentationSet -> + documentationSet.relativePath().equals(javaApiDocumentationSource.relativeMirrorPath())) + .findFirst() + .orElseThrow(); + + String canonicalRelativeMirrorPath = javaApiDocumentationSource.relativeMirrorPath(); + assertEquals(canonicalRelativeMirrorPath, catalogDocumentationSet.primarySelector()); + assertTrue(catalogDocumentationSet.matchesSelectorTokens(Set.of(canonicalRelativeMirrorPath))); + + List prohibitedSelectorAliases = List.of( + javaApiDocumentationSource.displayName(), + canonicalRelativeMirrorPath.replace('/', '-'), + javaApiDocumentationSource.javaRelease(), + LEGACY_JAVA_QUICK_SELECTOR_PREFIX + javaApiDocumentationSource.javaRelease(), + canonicalRelativeMirrorPath.toUpperCase(Locale.ROOT)); + for (String prohibitedSelectorAlias : prohibitedSelectorAliases) { + assertFalse( + catalogDocumentationSet.matchesSelectorTokens(Set.of(prohibitedSelectorAlias)), + "Canonical Java API selector accepted alias " + prohibitedSelectorAlias); + } + } + } + + @Test + void preservesEstablishedSelectorsForNonJavaDocumentationSets() { + DocumentationSet quickDocumentationSet = + DocumentationSetCatalog.quickSets().getFirst(); + + assertEquals(quickDocumentationSet.relativePath().replace('/', '-'), quickDocumentationSet.primarySelector()); + assertTrue(quickDocumentationSet.matchesSelectorTokens( + Set.of(quickDocumentationSet.displayName().toUpperCase(Locale.ROOT)))); + } } From 502bc8a3c02db3cd70813e90ab8ac2f110602a4b Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:09 -0700 Subject: [PATCH 35/40] test(provider): cover wrapped OkHttp call timeouts --- .../OpenAiProviderRoutingServiceTest.java | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/williamcallahan/javachat/service/OpenAiProviderRoutingServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/OpenAiProviderRoutingServiceTest.java index a73b6a28..32c00559 100644 --- a/src/test/java/com/williamcallahan/javachat/service/OpenAiProviderRoutingServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/OpenAiProviderRoutingServiceTest.java @@ -25,6 +25,9 @@ */ class OpenAiProviderRoutingServiceTest { private static final long PRIMARY_BACKOFF_SECONDS = 600L; + private static final String OPENAI_REQUEST_FAILED_MESSAGE = "Request failed"; + private static final String OK_HTTP_CALL_TIMEOUT_MESSAGE = "timeout"; + private static final String CALLER_INTERRUPTION_MESSAGE = "request interrupted by caller timeout"; private OpenAiProviderRoutingService createRoutingService() { RateLimitService rateLimitService = mock(RateLimitService.class); @@ -40,12 +43,31 @@ void shouldBackoffPrimaryTreatsSdkIoAsBackoffEligible() { } @Test - void shouldBackoffPrimaryIgnoresCallerCancellationWrappedBySdkIo() { + void shouldBackoffPrimaryIgnoresWrappedCallerInterruption() { OpenAiProviderRoutingService routingService = createRoutingService(); - InterruptedIOException interruptedRequest = new InterruptedIOException("request interrupted by caller timeout"); - OpenAIIoException cancelledCompletion = new OpenAIIoException("Request failed", interruptedRequest); - assertFalse(routingService.shouldBackoffPrimary(cancelledCompletion)); + assertFalse(routingService.shouldBackoffPrimary(wrappedCallerInterruption())); + } + + @Test + void shouldBackoffPrimaryTreatsWrappedOkHttpCallTimeoutAsBackoffEligible() { + OpenAiProviderRoutingService routingService = createRoutingService(); + + assertTrue(routingService.shouldBackoffPrimary(wrappedOkHttpCallTimeout())); + } + + @Test + void streamingFallbackEligibilityTreatsWrappedOkHttpCallTimeoutAsEligible() { + OpenAiProviderRoutingService routingService = createRoutingService(); + + assertTrue(routingService.isStreamingFallbackEligible(wrappedOkHttpCallTimeout())); + } + + @Test + void recoverableStreamingFailureTreatsWrappedOkHttpCallTimeoutAsRetryable() { + OpenAiProviderRoutingService routingService = createRoutingService(); + + assertTrue(routingService.isRecoverableStreamingFailure(wrappedOkHttpCallTimeout())); } @Test @@ -59,10 +81,8 @@ void callerCancellationKeepsConfiguredPrimaryProviderEligible() { rateLimitService, PRIMARY_BACKOFF_SECONDS, RateLimitService.ApiProvider.OPENAI.getName()); OpenAIClient openAiClient = mock(OpenAIClient.class); OpenAIClient githubModelsClient = mock(OpenAIClient.class); - InterruptedIOException interruptedRequest = new InterruptedIOException("request interrupted by caller timeout"); - OpenAIIoException cancelledCompletion = new OpenAIIoException("Request failed", interruptedRequest); - routingService.recordProviderFailure(RateLimitService.ApiProvider.OPENAI, cancelledCompletion); + routingService.recordProviderFailure(RateLimitService.ApiProvider.OPENAI, wrappedCallerInterruption()); List availableProviders = routingService.selectAvailableProviderCandidates(githubModelsClient, openAiClient); @@ -188,4 +208,14 @@ void primaryBackoffUsesConfiguredAlternateProviderWhenAvailable() { RateLimitService.ApiProvider.GITHUB_MODELS, availableProviders.get(0).provider()); } + + private OpenAIIoException wrappedOkHttpCallTimeout() { + return new OpenAIIoException( + OPENAI_REQUEST_FAILED_MESSAGE, new InterruptedIOException(OK_HTTP_CALL_TIMEOUT_MESSAGE)); + } + + private OpenAIIoException wrappedCallerInterruption() { + return new OpenAIIoException( + OPENAI_REQUEST_FAILED_MESSAGE, new InterruptedIOException(CALLER_INTERRUPTION_MESSAGE)); + } } From da3a1aacaf4de63d851441811b7313b6222bf722 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:11 -0700 Subject: [PATCH 36/40] test(retrieval): preserve redacted citation identity --- .../service/RetrievalServiceTest.java | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java index 8bc0aeb0..44dfcdbc 100644 --- a/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/RetrievalServiceTest.java @@ -115,23 +115,25 @@ void preservesDistinctSamePageChunksForRerankingAndDeduplicatesTheirCitations() } @Test - void preservesDistinctUnmappedLocalDocumentsWithoutContentHashes() { + void keepsDistinctUnmappedLocalDocumentsAndRedactsTheirCitations() { HybridSearchService hybridSearchService = mock(HybridSearchService.class); RerankerService rerankerService = mock(RerankerService.class); DocumentFactory documentFactory = mock(DocumentFactory.class); AppProperties appProperties = new AppProperties(); RetrievalService retrievalService = new RetrievalService(hybridSearchService, appProperties, rerankerService, documentFactory); + String firstUnmappedLocalUrl = "file:///unmapped/first.html"; + String secondUnmappedLocalUrl = "file:///unmapped/second.html"; Document firstUnmappedLocalDocument = Document.builder() .id("first-unmapped-local") .text("First local document") - .metadata("url", "file:///unmapped/first.html") + .metadata("url", firstUnmappedLocalUrl) .build(); Document secondUnmappedLocalDocument = Document.builder() .id("second-unmapped-local") .text("Second local document") - .metadata("url", "file:///unmapped/second.html") + .metadata("url", secondUnmappedLocalUrl) .build(); List retrievalCandidates = List.of(firstUnmappedLocalDocument, secondUnmappedLocalDocument); when(hybridSearchService.searchOutcome(anyString(), anyInt(), any(RetrievalConstraint.class))) @@ -142,8 +144,20 @@ void preservesDistinctUnmappedLocalDocumentsWithoutContentHashes() { }); RetrievalService.RetrievalOutcome retrievalOutcome = retrievalService.retrieveOutcome("Local documentation"); + RetrievalService.CitationOutcome citationOutcome = retrievalService.toCitations(retrievalOutcome.documents()); + String redactedLocalCitationUrl = DocsSourceRegistry.normalizeDocUrl(firstUnmappedLocalUrl); assertEquals(retrievalCandidates, retrievalOutcome.documents()); + assertEquals(2, citationOutcome.citations().size()); + assertEquals( + List.of(redactedLocalCitationUrl, redactedLocalCitationUrl), + citationOutcome.citations().stream() + .map(citation -> citation.getUrl()) + .toList()); + assertTrue(citationOutcome.citations().stream() + .map(citation -> citation.getUrl()) + .noneMatch(citationUrl -> citationUrl.contains("/unmapped/"))); + assertEquals(0, citationOutcome.failedConversionCount()); } @Test From aaec1e10390038ef6049e31525d507daa805b046 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:12 -0700 Subject: [PATCH 37/40] test(security): verify secure CSRF cookie lifecycle --- .../javachat/config/SecurityConfigTest.java | 48 ++++++++++++++++--- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/williamcallahan/javachat/config/SecurityConfigTest.java b/src/test/java/com/williamcallahan/javachat/config/SecurityConfigTest.java index 11cbe0ed..cd0c0ea7 100644 --- a/src/test/java/com/williamcallahan/javachat/config/SecurityConfigTest.java +++ b/src/test/java/com/williamcallahan/javachat/config/SecurityConfigTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -34,7 +35,10 @@ class SecurityConfigTest { private static final String LOGOUT_ENDPOINT = "/logout"; private static final String CSRF_COOKIE_NAME = "XSRF-TOKEN"; private static final String CSRF_HEADER_NAME = "X-XSRF-TOKEN"; + private static final String CSRF_COOKIE_ROOT_PATH = "/"; + private static final String CSRF_COOKIE_SAME_SITE_ATTRIBUTE = "SameSite"; private static final String CSRF_COOKIE_SAME_SITE_POLICY = "Lax"; + private static final int CSRF_COOKIE_DELETION_MAX_AGE_SECONDS = 0; private static final String CSRF_INVALID_MESSAGE = "CSRF token missing or invalid. Refresh the page and retry the request."; @@ -46,8 +50,7 @@ void acceptsCookieAndHeaderTokenWithoutServerSession() throws Exception { MvcResult csrfRefreshExchange = requestCsrfCookie(); Cookie csrfCookie = csrfRefreshExchange.getResponse().getCookie(CSRF_COOKIE_NAME); assertNotNull(csrfCookie); - assertEquals(CSRF_COOKIE_SAME_SITE_POLICY, csrfCookie.getAttribute("SameSite")); - assertFalse(csrfCookie.isHttpOnly()); + assertBrowserReadableCsrfCookieAttributes(csrfCookie); assertNull(csrfRefreshExchange.getRequest().getSession(false)); assertNull(csrfRefreshExchange.getResponse().getCookie("JSESSIONID")); @@ -69,6 +72,15 @@ void returnsJsonWhenCsrfTokenIsMissing() throws Exception { .andExpect(jsonPath("$.message").value(CSRF_INVALID_MESSAGE)); } + @Test + void issuesSecureBrowserReadableCsrfCookieOverHttps() throws Exception { + Cookie issuedCsrfCookie = requestSecureCsrfCookie().getResponse().getCookie(CSRF_COOKIE_NAME); + assertNotNull(issuedCsrfCookie); + + assertTrue(issuedCsrfCookie.getSecure()); + assertBrowserReadableCsrfCookieAttributes(issuedCsrfCookie); + } + @Test void rejectsMismatchedAndSingleSidedCsrfTokens() throws Exception { Cookie csrfCookie = requestCsrfCookie().getResponse().getCookie(CSRF_COOKIE_NAME); @@ -89,17 +101,21 @@ void rejectsMismatchedAndSingleSidedCsrfTokens() throws Exception { @Test void deletesCsrfCookieImmediatelyOnLogout() throws Exception { - Cookie csrfCookie = requestCsrfCookie().getResponse().getCookie(CSRF_COOKIE_NAME); - assertNotNull(csrfCookie); + Cookie issuedCsrfCookie = requestSecureCsrfCookie().getResponse().getCookie(CSRF_COOKIE_NAME); + assertNotNull(issuedCsrfCookie); - MvcResult logoutExchange = mockMvc.perform( - post(LOGOUT_ENDPOINT).cookie(csrfCookie).header(CSRF_HEADER_NAME, csrfCookie.getValue())) + MvcResult logoutExchange = mockMvc.perform(post(LOGOUT_ENDPOINT) + .secure(true) + .cookie(issuedCsrfCookie) + .header(CSRF_HEADER_NAME, issuedCsrfCookie.getValue())) .andExpect(status().is3xxRedirection()) .andReturn(); Cookie deletedCsrfCookie = logoutExchange.getResponse().getCookie(CSRF_COOKIE_NAME); assertNotNull(deletedCsrfCookie); - assertEquals(0, deletedCsrfCookie.getMaxAge()); + assertTrue(deletedCsrfCookie.getSecure()); + assertBrowserReadableCsrfCookieAttributes(deletedCsrfCookie); + assertEquals(CSRF_COOKIE_DELETION_MAX_AGE_SECONDS, deletedCsrfCookie.getMaxAge()); } private MvcResult requestCsrfCookie() throws Exception { @@ -108,6 +124,24 @@ private MvcResult requestCsrfCookie() throws Exception { .andReturn(); } + /** + * Requests a CSRF cookie through the HTTPS transport path so cookie security attributes are observable. + */ + private MvcResult requestSecureCsrfCookie() throws Exception { + return mockMvc.perform(get(CSRF_REFRESH_ENDPOINT).secure(true)) + .andExpect(status().isOk()) + .andReturn(); + } + + /** + * Verifies browser-visible attributes shared by issued and deleted CSRF cookies. + */ + private static void assertBrowserReadableCsrfCookieAttributes(Cookie csrfCookie) { + assertEquals(CSRF_COOKIE_ROOT_PATH, csrfCookie.getPath()); + assertEquals(CSRF_COOKIE_SAME_SITE_POLICY, csrfCookie.getAttribute(CSRF_COOKIE_SAME_SITE_ATTRIBUTE)); + assertFalse(csrfCookie.isHttpOnly()); + } + /** * Exposes a harmless state-changing route so the real security filter chain can be tested. */ From d8f81838b0e908aa5e02f461f0e758d78d505bb8 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:13 -0700 Subject: [PATCH 38/40] test(frontend): cover SSE failure contracts --- frontend/src/lib/services/sse.test.ts | 120 +++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/services/sse.test.ts b/frontend/src/lib/services/sse.test.ts index b065609b..5299189c 100644 --- a/frontend/src/lib/services/sse.test.ts +++ b/frontend/src/lib/services/sse.test.ts @@ -1,9 +1,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { streamSse } from "./sse"; +import { streamSse, streamSseGet } from "./sse"; const SSE_STREAM_RESPONSE_STATUS = 200; +const HTTP_SERVICE_UNAVAILABLE_STATUS = 503; const FETCH_FAILURE_MESSAGE = "Network request failed"; const STREAM_READ_FAILURE_MESSAGE = "Unable to read the SSE stream"; +const SERVER_EVENT_ERROR_MESSAGE = "The provider ended the stream"; +const STATUS_EVENT_MESSAGE = "Switching to an available provider"; +const STATUS_EVENT_PROVIDER = "openai"; +const MISSING_STREAM_BODY_MESSAGE = "No response body"; function createSseStreamResponse(sseWireText: string): Response { const encoder = new TextEncoder(); @@ -63,6 +68,95 @@ describe("streamSse transport handling", () => { expect(onError).toHaveBeenCalledWith({ message: FETCH_FAILURE_MESSAGE }); }); + it("reports and rejects a non-abort GET fetch failure exactly once", async () => { + const fetchFailure = new Error(FETCH_FAILURE_MESSAGE); + const fetchMock = vi.fn().mockRejectedValue(fetchFailure); + vi.stubGlobal("fetch", fetchMock); + + const onText = vi.fn(); + const onError = vi.fn(); + + await expect(streamSseGet("/api/test/stream", { onText, onError }, "sse.test.ts")).rejects.toBe( + fetchFailure, + ); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(onText).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith({ message: FETCH_FAILURE_MESSAGE }); + }); + + it("reports a non-OK GET response exactly once", async () => { + const serviceUnavailableMessage = `HTTP ${HTTP_SERVICE_UNAVAILABLE_STATUS}: Service Unavailable`; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(null, { + status: HTTP_SERVICE_UNAVAILABLE_STATUS, + statusText: "Service Unavailable", + }), + ), + ); + + const onText = vi.fn(); + const onError = vi.fn(); + + await expect( + streamSseGet("/api/test/stream", { onText, onError }, "sse.test.ts"), + ).rejects.toThrow(serviceUnavailableMessage); + + expect(onText).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith({ message: serviceUnavailableMessage }); + }); + + it("reports a missing GET response body exactly once", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(null, { + status: SSE_STREAM_RESPONSE_STATUS, + statusText: "OK", + }), + ), + ); + + const onText = vi.fn(); + const onError = vi.fn(); + + await expect( + streamSseGet("/api/test/stream", { onText, onError }, "sse.test.ts"), + ).rejects.toThrow(MISSING_STREAM_BODY_MESSAGE); + + expect(onText).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith({ message: MISSING_STREAM_BODY_MESSAGE }); + }); + + it("reports a valid server error event exactly once", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + createSseStreamResponse( + `event: error\ndata: {"message":"${SERVER_EVENT_ERROR_MESSAGE}"}\n\n`, + ), + ), + ); + + const onText = vi.fn(); + const onError = vi.fn(); + + await expect( + streamSseGet("/api/test/stream", { onText, onError }, "sse.test.ts"), + ).rejects.toThrow(SERVER_EVENT_ERROR_MESSAGE); + + expect(onText).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith({ message: SERVER_EVENT_ERROR_MESSAGE }); + }); + it("treats AbortError during read as a cancellation (no onError)", async () => { const encoder = new TextEncoder(); const abortError = Object.assign(new Error("Aborted"), { name: "AbortError" }); @@ -141,6 +235,30 @@ describe("streamSse payload validation", () => { expect(onText).toHaveBeenCalledWith("Hello"); }); + it("preserves provider from a valid wire-format status event", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + createSseStreamResponse( + `event: status\ndata: {"message":"${STATUS_EVENT_MESSAGE}","provider":"${STATUS_EVENT_PROVIDER}"}\n\n`, + ), + ), + ); + const onText = vi.fn(); + const onStatus = vi.fn(); + + await streamSse("/api/test/stream", {}, { onText, onStatus }, "sse.test.ts"); + + expect(onText).not.toHaveBeenCalled(); + expect(onStatus).toHaveBeenCalledOnce(); + expect(onStatus).toHaveBeenCalledWith({ + message: STATUS_EVENT_MESSAGE, + provider: STATUS_EVENT_PROVIDER, + }); + }); + it("rejects legacy raw text payloads", async () => { vi.spyOn(console, "error").mockImplementation(() => undefined); vi.stubGlobal( From 46650888d7a5a59c33e4385a9e29967c8b08a6fc Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:14 -0700 Subject: [PATCH 39/40] test(frontend): preserve chat stream error identity --- frontend/src/lib/services/chat.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/services/chat.test.ts b/frontend/src/lib/services/chat.test.ts index 881903af..327bc0af 100644 --- a/frontend/src/lib/services/chat.test.ts +++ b/frontend/src/lib/services/chat.test.ts @@ -16,13 +16,14 @@ describe("streamChat", () => { }); it("surfaces a recoverable stream failure without replaying the POST", async () => { - const streamFailure = { - message: "OverflowException: malformed response frame", + const streamFailure = new Error("OverflowException: malformed response frame"); + const streamError = { + message: streamFailure.message, details: "Malformed response frame at byte 512", }; streamSseMock.mockImplementationOnce(async (_url, _body, callbacks) => { - callbacks.onError?.(streamFailure); - throw new Error(streamFailure.message); + callbacks.onError?.(streamError); + throw streamFailure; }); const onChunk = vi.fn(); @@ -36,7 +37,7 @@ describe("streamChat", () => { onError, signal: streamAbortController.signal, }), - ).rejects.toThrow("OverflowException: malformed response frame"); + ).rejects.toBe(streamFailure); expect(streamSseMock).toHaveBeenCalledTimes(1); expect(streamSseMock).toHaveBeenCalledWith( @@ -52,7 +53,7 @@ describe("streamChat", () => { ); expect(onStatus).not.toHaveBeenCalled(); expect(onChunk).not.toHaveBeenCalled(); - expect(onError).toHaveBeenCalledWith(streamFailure); + expect(onError).toHaveBeenCalledWith(streamError); }); it("does not replay after a stream has emitted a chunk", async () => { From c11049b5943ca66b7594b42aecb88999be238b3b Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 14:39:15 -0700 Subject: [PATCH 40/40] test(frontend): preserve guided stream error identity --- frontend/src/lib/services/guided.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/services/guided.test.ts b/frontend/src/lib/services/guided.test.ts index cba8f2ae..77064d6a 100644 --- a/frontend/src/lib/services/guided.test.ts +++ b/frontend/src/lib/services/guided.test.ts @@ -17,13 +17,14 @@ describe("streamGuidedChat", () => { }); it("surfaces a recoverable stream failure without replaying the POST", async () => { - const streamFailure = { - message: "OverflowException: malformed response frame", + const streamFailure = new Error("OverflowException: malformed response frame"); + const streamError = { + message: streamFailure.message, details: "Malformed response frame at byte 512", }; streamSseMock.mockImplementationOnce(async (_url, _body, callbacks) => { - callbacks.onError?.(streamFailure); - throw new Error(streamFailure.message); + callbacks.onError?.(streamError); + throw streamFailure; }); const onChunk = vi.fn(); @@ -40,7 +41,7 @@ describe("streamGuidedChat", () => { onCitations, signal: streamAbortController.signal, }), - ).rejects.toThrow("OverflowException: malformed response frame"); + ).rejects.toBe(streamFailure); expect(streamSseMock).toHaveBeenCalledTimes(1); expect(streamSseMock).toHaveBeenCalledWith( @@ -57,7 +58,7 @@ describe("streamGuidedChat", () => { ); expect(onStatus).not.toHaveBeenCalled(); expect(onChunk).not.toHaveBeenCalled(); - expect(onError).toHaveBeenCalledWith(streamFailure); + expect(onError).toHaveBeenCalledWith(streamError); }); it("does not retry for non-recoverable rate-limit errors", async () => {