···11+# Per-package findlib_index.json Implementation Plan
22+33+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
44+55+**Goal:** Write `p/<pkg>/<ver>/findlib_index.json` for each target package so `.mld` files can use one tag (`@x-ocaml.universe ../../p/yojson/3.0.0`) and everything is discoverable.
66+77+**Architecture:** day10's `assemble_jtw_output` already collects all the information (compiler hash, all META paths with content hashes). We add a step that writes a per-package findlib_index.json with paths relative to `p/<pkg>/<ver>/`. On the x-ocaml side, we add a pre-fetch of the findlib_index to extract the `compiler` field and auto-construct the worker URL.
88+99+**Tech Stack:** OCaml (day10 batch, x-ocaml.js/js_of_ocaml, findlibish)
1010+1111+**Design doc:** `docs/plans/2026-02-22-per-package-findlib-index-design.md`
1212+1313+---
1414+1515+### Task 1: Write per-package findlib_index.json in assemble_jtw_output
1616+1717+**Files:**
1818+- Modify: `day10/bin/jtw_gen.ml:360-372` (after existing findlib_index write)
1919+2020+**Step 1: Add per-package findlib_index generation**
2121+2222+After the existing `u/<hash>/findlib_index.json` write (line 371), add code to write
2323+`p/<pkg>/<ver>/findlib_index.json` for each target package. The target package is
2424+`_target_pkg` (currently ignored at line 260).
2525+2626+The meta_files paths need to be rebased from `u/<hash>/` to `p/<pkg>/<ver>/`.
2727+Currently paths look like `../../p/seq/base/85ab.../lib/seq/META` (relative to
2828+`u/<hash>/`). From `p/<pkg>/<ver>/`, the same file is at
2929+`../../../p/seq/base/85ab.../lib/seq/META` — one more `../` because we're one
3030+level deeper.
3131+3232+Exception: the target package's own META path is currently
3333+`../../p/<pkg>/<ver>/<hash>/lib/<name>/META`. From `p/<pkg>/<ver>/`, this becomes
3434+just `<hash>/lib/<name>/META`.
3535+3636+In `day10/bin/jtw_gen.ml`, after line 372 (`Os.write_to_file ... findlib_index`), add:
3737+3838+```ocaml
3939+ (* Write per-package findlib_index for the target package.
4040+ This provides a stable, human-readable entry point at
4141+ p/<pkg>/<ver>/findlib_index.json that contains all the same
4242+ information as the universe index but with paths relative to
4343+ p/<pkg>/<ver>/. *)
4444+ let target_name = OpamPackage.name_to_string _target_pkg in
4545+ let target_version = OpamPackage.version_to_string _target_pkg in
4646+ let target_prefix = "../../p/" ^ target_name ^ "/" ^ target_version ^ "/" in
4747+ let pkg_metas = List.map (fun meta_path ->
4848+ if String.length meta_path > String.length target_prefix
4949+ && String.sub meta_path 0 (String.length target_prefix) = target_prefix then
5050+ (* This package's own META — strip the ../../p/<pkg>/<ver>/ prefix *)
5151+ String.sub meta_path (String.length target_prefix)
5252+ (String.length meta_path - String.length target_prefix)
5353+ else
5454+ (* Other package's META — add one more ../ since we're at p/<pkg>/<ver>/
5555+ instead of u/<hash>/ *)
5656+ "../" ^ meta_path
5757+ ) sorted_metas in
5858+ let pkg_findlib_index = generate_findlib_index ~compiler:compiler_json pkg_metas in
5959+ let pkg_index_dir = Path.(jtw_output / "p" / target_name / target_version) in
6060+ Os.mkdir ~parents:true pkg_index_dir;
6161+ Os.write_to_file Path.(pkg_index_dir / "findlib_index.json") pkg_findlib_index
6262+```
6363+6464+Also rename `_target_pkg` to `target_pkg` at line 260:
6565+6666+```ocaml
6767+ List.iter (fun (target_pkg, solution, _ocaml_version, worker_output_dir) ->
6868+```
6969+7070+**Step 2: Build and verify**
7171+7272+Run:
7373+```bash
7474+cd /home/jons-agent/workspace/mono/day10 && eval $(opam env --switch=default) && dune build --root . bin/main.exe
7575+```
7676+Expected: builds without errors.
7777+7878+**Step 3: Run batch and check output**
7979+8080+Run the day10 batch for both yojson packages, then inspect:
8181+```bash
8282+cat /home/jons-agent/day10-multi-demo/p/yojson/3.0.0/findlib_index.json | python3 -m json.tool
8383+cat /home/jons-agent/day10-multi-demo/p/yojson/2.2.2/findlib_index.json | python3 -m json.tool
8484+```
8585+8686+Expected: JSON with `compiler` field (version + content_hash) and `meta_files` array
8787+where the target package's own META has a short relative path (just `<hash>/lib/...`)
8888+and all deps have `../../../p/...` paths.
8989+9090+**Step 4: Commit**
9191+9292+```bash
9393+git add day10/bin/jtw_gen.ml
9494+git commit -m "day10: write per-package findlib_index.json at p/<pkg>/<ver>/"
9595+```
9696+9797+---
9898+9999+### Task 2: Auto-construct worker URL from findlib_index compiler field in x-ocaml.js
100100+101101+**Files:**
102102+- Modify: `x-ocaml/src/x_ocaml.ml:26-64`
103103+104104+**Step 1: Understand the current flow**
105105+106106+Currently in `x_ocaml.ml`:
107107+1. `worker_url` is computed synchronously (lines 26-39) from `src-worker` attr or `x-ocaml-worker` meta
108108+2. `findlib_index` URL is computed (lines 56-62) from `x-ocaml-universe` meta
109109+3. `Backend.make` is called with both (line 64)
110110+111111+The problem: the worker URL must be known before creating the backend, but the
112112+compiler info is inside the findlib_index which hasn't been fetched yet.
113113+114114+**Step 2: Add synchronous pre-fetch of findlib_index**
115115+116116+The worker runs in a Web Worker, created synchronously. We need the worker URL
117117+before creating it. Solution: do a synchronous XHR fetch of findlib_index.json
118118+from x_ocaml.ml (main thread) to extract the compiler field, then construct the
119119+worker URL from it.
120120+121121+This is a small blocking fetch of a single JSON file (~200 bytes). It only happens
122122+when there's a `x-ocaml-universe` meta tag and no explicit `x-ocaml-worker` override.
123123+124124+In `x-ocaml/src/x_ocaml.ml`, replace lines 26-64 with:
125125+126126+```ocaml
127127+(** Synchronous XHR fetch — used only for the small findlib_index.json pre-fetch.
128128+ Returns None if the fetch fails. *)
129129+let sync_fetch_text url =
130130+ let xhr = Jv.new' (Jv.get Jv.global "XMLHttpRequest") [||] in
131131+ Jv.call xhr "open" [| Jv.of_string "GET"; Jv.of_string url; Jv.of_bool false |] |> ignore;
132132+ (try Jv.call xhr "send" [||] |> ignore with _ -> ());
133133+ let status = Jv.to_int (Jv.get xhr "status") in
134134+ if status = 200 then
135135+ Some (Jv.to_string (Jv.get xhr "responseText"))
136136+ else
137137+ None
138138+139139+let findlib_index_url =
140140+ match read_meta "x-ocaml-universe" with
141141+ | None -> None
142142+ | Some base ->
143143+ let base = if String.length base > 0 && base.[String.length base - 1] = '/'
144144+ then base else base ^ "/" in
145145+ Some (resolve_url (base ^ "findlib_index.json"))
146146+147147+(** Pre-fetch findlib_index.json to extract compiler info for worker URL.
148148+ Returns (worker_url_from_compiler, findlib_index_url) *)
149149+let compiler_from_findlib_index =
150150+ match findlib_index_url with
151151+ | None -> None
152152+ | Some url ->
153153+ match sync_fetch_text url with
154154+ | None -> None
155155+ | Some text ->
156156+ (try
157157+ let json = Yojson.Safe.from_string text in
158158+ let open Yojson.Safe.Util in
159159+ let compiler = json |> member "compiler" in
160160+ let version = compiler |> member "version" |> to_string in
161161+ let content_hash = compiler |> member "content_hash" |> to_string in
162162+ Some (version, content_hash)
163163+ with _ -> None)
164164+165165+let worker_url =
166166+ (* Priority: 1) explicit x-ocaml-worker meta, 2) compiler from findlib_index, 3) src-worker attr *)
167167+ match read_meta "x-ocaml-worker" with
168168+ | Some url -> url
169169+ | None ->
170170+ match compiler_from_findlib_index with
171171+ | Some (version, content_hash) ->
172172+ (* Construct worker URL relative to findlib_index's base.
173173+ findlib_index is at e.g. p/yojson/3.0.0/findlib_index.json
174174+ worker is at compiler/<ver>/<hash>/worker.js
175175+ From p/yojson/3.0.0/, that's ../../../compiler/<ver>/<hash>/worker.js *)
176176+ let fi_url = Option.get findlib_index_url in
177177+ let base_dir = match String.rindex_opt fi_url '/' with
178178+ | Some i -> String.sub fi_url 0 (i + 1)
179179+ | None -> fi_url
180180+ in
181181+ base_dir ^ "../../../compiler/" ^ version ^ "/" ^ content_hash ^ "/worker.js"
182182+ | None ->
183183+ match current_attribute "src-worker" with
184184+ | None ->
185185+ if backend_name = "builtin" then
186186+ failwith "x-ocaml script missing src-worker attribute"
187187+ else ""
188188+ | Some url -> Jstr.to_string url
189189+190190+let findlib_requires =
191191+ match read_meta "x-ocaml-packages" with
192192+ | None -> None
193193+ | Some s ->
194194+ let pkgs = List.filter (fun s -> s <> "")
195195+ (List.map String.trim (String.split_on_char ',' s)) in
196196+ if pkgs = [] then None else Some pkgs
197197+198198+let backend = Backend.make ~backend:backend_name ?extra_load ?findlib_requires
199199+ ?findlib_index:findlib_index_url worker_url
200200+```
201201+202202+Note: this requires `yojson` to be available in x-ocaml's compilation. Check
203203+whether it's already a dependency — if not, we need a lighter JSON parser or
204204+use Jv directly.
205205+206206+**Step 3: Check x-ocaml dependencies for yojson**
207207+208208+```bash
209209+grep -r "yojson\|Yojson" x-ocaml/src/dune x-ocaml/dune 2>/dev/null
210210+```
211211+212212+If yojson is NOT available, replace the OCaml JSON parsing with Jv calls:
213213+214214+```ocaml
215215+let compiler_from_findlib_index =
216216+ match findlib_index_url with
217217+ | None -> None
218218+ | Some url ->
219219+ match sync_fetch_text url with
220220+ | None -> None
221221+ | Some text ->
222222+ (try
223223+ let json = Jv.call (Jv.get Jv.global "JSON") "parse" [| Jv.of_string text |] in
224224+ let compiler = Jv.get json "compiler" in
225225+ if Jv.is_none compiler then None
226226+ else
227227+ let version = Jv.to_string (Jv.get compiler "version") in
228228+ let content_hash = Jv.to_string (Jv.get compiler "content_hash") in
229229+ Some (version, content_hash)
230230+ with _ -> None)
231231+```
232232+233233+This uses the browser's built-in JSON.parse — no OCaml JSON library needed.
234234+235235+**Step 4: Handle the worker URL base path correctly**
236236+237237+The worker URL construction assumes a known relationship between the
238238+findlib_index URL and the compiler directory. The `../../../` prefix works when
239239+findlib_index is at `p/<pkg>/<ver>/findlib_index.json` (3 levels deep from root).
240240+241241+But a more robust approach: resolve relative to the page's base URL or the
242242+findlib_index URL. The `resolve_url` helper already handles this. We should
243243+construct the relative path from the findlib_index's directory:
244244+245245+The findlib_index is fetched from an absolute URL like
246246+`https://host/site/p/yojson/3.0.0/findlib_index.json`.
247247+The compiler dir is `https://host/site/compiler/5.4.1/<hash>/worker.js`.
248248+Relative from the findlib_index's parent dir: `../../../compiler/5.4.1/<hash>/worker.js`.
249249+250250+This is correct for the day10 layout. For other layouts, the explicit
251251+`@x-ocaml.worker` override handles it.
252252+253253+**Step 5: Build x-ocaml**
254254+255255+```bash
256256+cd /home/jons-agent/workspace/mono && dune build x-ocaml/src/x_ocaml.bc.js
257257+```
258258+Expected: builds without errors.
259259+260260+**Step 6: Commit**
261261+262262+```bash
263263+git add x-ocaml/src/x_ocaml.ml
264264+git commit -m "x-ocaml: auto-discover worker URL from findlib_index compiler field"
265265+```
266266+267267+---
268268+269269+### Task 3: Update demo and test with per-package findlib_index
270270+271271+**Files:**
272272+- Modify: demo HTML files in `/home/jons-agent/day10-multi-demo/`
273273+- Modify: test runner `/home/jons-agent/day10-multi-demo/test/run_tests.js`
274274+275275+**Step 1: Rebuild day10 batch to generate per-package findlib_index files**
276276+277277+Run the day10 batch for both yojson packages. This regenerates the output
278278+including the new `p/<pkg>/<ver>/findlib_index.json` files.
279279+280280+**Step 2: Verify per-package findlib_index files exist**
281281+282282+```bash
283283+cat /home/jons-agent/day10-multi-demo/p/yojson/3.0.0/findlib_index.json | python3 -m json.tool
284284+cat /home/jons-agent/day10-multi-demo/p/yojson/2.2.2/findlib_index.json | python3 -m json.tool
285285+```
286286+287287+**Step 3: Create test .mld-style demo pages**
288288+289289+Create demo pages that use only `@x-ocaml.universe` (via meta tag) with NO
290290+explicit `x-ocaml-worker` — the worker URL should be auto-discovered from the
291291+findlib_index compiler field.
292292+293293+Update `demo_yojson3.html`:
294294+- Remove `<meta name="x-ocaml-worker" ...>`
295295+- Change `<meta name="x-ocaml-universe" ...>` to point at `p/yojson/3.0.0`
296296+297297+Update `demo_yojson2.html` similarly for `p/yojson/2.2.2`.
298298+299299+Deploy rebuilt x-ocaml.js to `_x-ocaml/`.
300300+301301+**Step 4: Run Playwright tests**
302302+303303+```bash
304304+cd /home/jons-agent/day10-multi-demo/test && node run_tests.js
305305+```
306306+Expected: 6/6 tests pass.
307307+308308+**Step 5: Commit**
309309+310310+```bash
311311+git commit -m "demo: use per-package findlib_index for worker auto-discovery"
312312+```