Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit af054e224d5cf9021fe1696411e7c60fd0e6bf55
parent 19e0f6b76ec44d4e137752be52f3c31a9f61f0bb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  9 Oct 2026 12:12:27 -0400

umtool: its own e2e ports per worktree, a hermetic mix.spec, `umtool window` notes its edits, two-block Note, SITES_DIR from the CLI's checkout, /sites is "articles"

Six debts from the articles + notes merge:

- Per-worktree e2e ports. umtool's `e2e` script now runs through the
  worktree injector (`worktree.mjs run`), so the documented
  `pnpm --filter umtool run e2e` gets the worktree's own ports. And the
  injector's index was the FIRST worktree root containing the cwd: every
  `.claude/worktrees/<agent>` is nested in the main checkout, so all of them
  got offset 0 — the main checkout's ports — for every suite. It is the most
  specific root now (indexForPath, tested).
- mix.spec's order dependence (FACTS: `:166`/`:201` red in every full run,
  green alone). Specs before it fetch windows through the editor stub into
  the fixture corpus's clips/, which the bench folds in with a project's
  clips-raw. The file now moves the corpus's clip windows aside for its
  tests and puts them back after; and the render-scratch test matches the
  option label exactly instead of as a substring.
- `umtool window` goes through the routes' edit guard: on a generated
  manifest each change is an `edit` note for the generator. The guard is
  lib/report/edit-guard.mjs now (plain JS, so the CLI runs the same one);
  guard.ts only types it.
- A selection that runs across two sections gets its Note button: it is
  anchored in the section it starts in, to that section's end (else the
  one it ends in, from its start). A spec covers it.
- The CLI finds SITES_DIR (and CHANNELS_DIR) from its OWN checkout:
  REPO_ROOT is the repo of `<repo>/umtool/bin/<cli>.mjs` when that is the
  entry script, so `umtool notes --all` run from a ~/reports workspace reads
  the corpus's sites. The app keeps the cwd walk. umtool/lib/*.test.mjs
  joins test:scripts.
- The nav entry and crumbs for /sites read "articles" (lowercase, like every
  umtool nav entry): the editor's /sites is the sites themselves. The URL
  is unchanged; sites.spec asserts the new name and that no "sites" link
  is left.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mpackage.json | 2+-
Mscripts/worktree.mjs | 26+++++++++++++++++++++-----
Mscripts/worktree.test.mjs | 18+++++++++++++++++-
Mumtool/app/sites/[site]/[report]/evidence/page.tsx | 4++--
Mumtool/app/sites/[site]/[report]/page.tsx | 4++--
Mumtool/app/sites/[site]/page.tsx | 4++--
Mumtool/app/sites/page.tsx | 2+-
Mumtool/bin/umtool.mjs | 19++++++++++++++++---
Mumtool/components/AppNav.tsx | 8+++++---
Mumtool/components/articles/anchorDom.ts | 38++++++++++++++++++++++++++++++--------
Mumtool/docs/cli.md | 19+++++++++++++++++--
Mumtool/e2e/article-notes.spec.ts | 37+++++++++++++++++++++++++++++++++++++
Mumtool/e2e/mix.spec.ts | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/e2e/sites.spec.ts | 4+++-
Mumtool/lib/paths.mjs | 32++++++++++++++++++++++++++++++--
Aumtool/lib/paths.test.mjs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/edit-guard.mjs | 46++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/edit-guard.test.mjs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/guard.ts | 36+++++++-----------------------------
Mumtool/package.json | 2+-
20 files changed, 446 insertions(+), 64 deletions(-)

diff --git a/package.json b/package.json @@ -23,7 +23,7 @@ "heavy": "node scripts/queue-lock.mjs --heavy --", "wt": "node scripts/worktree.mjs", "e2e:sharded": "node scripts/run-sharded-e2e.mjs", - "test:scripts": "node --test scripts/*.test.mjs umtool/report-to-video/*.test.mjs umtool/lib/report/*.test.mjs umtool/lib/annotations/*.test.mjs umtool/lib/articles/*.test.mjs", + "test:scripts": "node --test scripts/*.test.mjs umtool/report-to-video/*.test.mjs umtool/lib/*.test.mjs umtool/lib/report/*.test.mjs umtool/lib/annotations/*.test.mjs umtool/lib/articles/*.test.mjs", "test": "pnpm -r --no-bail --no-sort --workspace-concurrency=1 run test; a=$?; pnpm run test:scripts; b=$?; [ $a -eq 0 ] && [ $b -eq 0 ]", "typecheck": "pnpm -r --no-bail --no-sort --workspace-concurrency=1 exec tsc --noEmit", "lint": "pnpm --filter export run lint", diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs @@ -76,12 +76,28 @@ function offsetForIndex(index) { // Index of the worktree containing `dir` (default: cwd) in the worktree list. function indexForDir(dir = process.cwd()) { const trees = listWorktrees(); - const target = realpath(dir); - for (let i = 0; i < trees.length; i++) { - const root = realpath(trees[i].path); - if (target === root || target.startsWith(root + path.sep)) return i; + return indexForPath( + trees.map((t) => realpath(t.path)), + realpath(dir), + ); +} + +// THE MOST SPECIFIC root containing `target`, by its index in `roots` (0 when +// none does). Not the first: a worktree NESTED in the main checkout -- every +// `.claude/worktrees/<agent>` is -- is also "inside" the main root, which +// comes first in the list, so a first-match gave every agent worktree the +// main checkout's ports (offset 0) and its e2e servers collided on them. +export function indexForPath(roots, target) { + let best = 0; + let bestLen = -1; + for (let i = 0; i < roots.length; i++) { + const root = roots[i]; + if ((target === root || target.startsWith(root + path.sep)) && root.length > bestLen) { + best = i; + bestLen = root.length; + } } - return 0; + return best; } // Read a simple KEY=VALUE file (e.g. .worktree-env) into an object. diff --git a/scripts/worktree.test.mjs b/scripts/worktree.test.mjs @@ -5,7 +5,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import path from "node:path"; -import { worktreeDirFor } from "./worktree.mjs"; +import { indexForPath, worktreeDirFor } from "./worktree.mjs"; const MAIN = "/home/u/Projects/yt-dlp-transcript-browser"; const SIBLING = path.dirname(MAIN); @@ -44,3 +44,19 @@ test("nothing escapes the sibling directory", () => { assert.equal(dir, path.join(SIBLING, "..-..-etc-passwd")); assert.equal(path.dirname(dir), SIBLING); }); + +test("a worktree nested in the main checkout gets its OWN index, not the main one's", () => { + // `.claude/worktrees/<agent>` lives INSIDE the main checkout. A first-match + // walk found the main root first and gave every such worktree offset 0 -- + // the main checkout's ports -- so two agents' umtool suites bound the same + // 3051/3052. + const roots = [MAIN, path.join(SIBLING, "feature-x"), path.join(MAIN, ".claude", "worktrees", "agent-1")]; + assert.equal(indexForPath(roots, MAIN), 0); + assert.equal(indexForPath(roots, path.join(MAIN, "umtool")), 0); + assert.equal(indexForPath(roots, path.join(SIBLING, "feature-x", "editor")), 1); + assert.equal(indexForPath(roots, path.join(MAIN, ".claude", "worktrees", "agent-1")), 2); + assert.equal(indexForPath(roots, path.join(MAIN, ".claude", "worktrees", "agent-1", "umtool")), 2); + // A sibling whose name only STARTS like the main root is not inside it. + assert.equal(indexForPath(roots, `${MAIN}-other`), 0); + assert.equal(indexForPath(roots, "/elsewhere"), 0); +}); diff --git a/umtool/app/sites/[site]/[report]/evidence/page.tsx b/umtool/app/sites/[site]/[report]/evidence/page.tsx @@ -43,9 +43,9 @@ export default async function EvidenceWalkPage({ return ( <div className="flex h-full flex-col"> <BrowseHeader - active="sites" + active="articles" crumbs={[ - { href: "/sites", label: "sites" }, + { href: "/sites", label: "articles" }, { href: `/sites/${site.siteId}`, label: site.siteId }, { href: `/sites/${site.siteId}/${reportId}`, label: reportId }, { label: "evidence" }, diff --git a/umtool/app/sites/[site]/[report]/page.tsx b/umtool/app/sites/[site]/[report]/page.tsx @@ -94,8 +94,8 @@ export default async function ArticlePage({ const header = ( <BrowseHeader - active="sites" - crumbs={[{ href: "/sites", label: "sites" }, { href: `/sites/${site.siteId}`, label: site.siteId }, { label: reportId }]} + active="articles" + crumbs={[{ href: "/sites", label: "articles" }, { href: `/sites/${site.siteId}`, label: site.siteId }, { label: reportId }]} note={`${notes.doc?.notes.filter((n) => n.status === "open").length ?? 0} open notes`} /> ); diff --git a/umtool/app/sites/[site]/page.tsx b/umtool/app/sites/[site]/page.tsx @@ -47,8 +47,8 @@ export default async function SitePage({ return ( <div className="flex h-full flex-col"> <BrowseHeader - active="sites" - crumbs={[{ href: "/sites", label: "sites" }, { label: row.title }]} + active="articles" + crumbs={[{ href: "/sites", label: "articles" }, { label: row.title }]} note={`${row.published} published · ${row.drafts} drafts · ${row.openNotes} open notes`} /> <main className="deck-main flex-1 space-y-6 p-4"> diff --git a/umtool/app/sites/page.tsx b/umtool/app/sites/page.tsx @@ -35,7 +35,7 @@ export default async function SitesPage({ searchParams }: { searchParams: Promis return ( <div className="flex h-full flex-col"> - <BrowseHeader active="sites" crumbs={[{ label: "sites" }]} note={`${all.length} sites · ${articles.length} articles · ${open} open notes`} /> + <BrowseHeader active="articles" crumbs={[{ label: "articles" }]} note={`${all.length} sites · ${articles.length} articles · ${open} open notes`} /> <main className="deck-main flex-1 p-4"> <div className="mb-3 flex flex-wrap items-center gap-1.5"> <span className="micro">site</span> diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs @@ -63,6 +63,7 @@ import { diffManifests, formatChange } from "../lib/report/manifest-diff.mjs"; import { EXPORT_FORMATS, exportProject } from "../lib/report/export.mjs"; import path from "node:path"; import { updateClip, updateStorage } from "../lib/report/manifest.mjs"; +import { withEditNotes } from "../lib/report/edit-guard.mjs"; import { hms } from "umtool-report-to-video/attribution"; import { buildSteps, checkSourcesSteps, PRESETS } from "../lib/report/driver.mjs"; import { openIndex, signRecord } from "../lib/projects/index-db.mjs"; @@ -795,9 +796,11 @@ async function cmdWindow() { try { // Through the SAME writer the bench uses: 2 dp, the CLI's own formatting, // tmp+rename, one .bak. A second implementation here is how the two would - // start disagreeing about a window. - const res = await updateClip(p.dir, clipId, patch); - if (json) return out({ ok: true, ...res }); + // start disagreeing about a window. And through the same GUARD the bench's + // routes use: on a generated manifest the change is also an `edit` note + // for the agent that generates it, or the next rebuild undoes it silently. + const { result: res, editNotes } = await withEditNotes(p, () => updateClip(p.dir, clipId, patch)); + if (json) return out({ ok: true, ...res, editNotes }); console.log( `${clipId}: ${res.before.start}–${res.before.end} -> ${res.entry.start}–${res.entry.end}`, ); @@ -826,6 +829,16 @@ async function cmdWindow() { if (patch.verdict !== undefined || patch.correction !== undefined) { console.log(` review: ${clipVerdict(res.entry)}`); } + if (editNotes) { + const n = editNotes.added + editNotes.updated; + console.log( + editNotes.errors.length + ? ` edit NOT noted (${editNotes.errors.join("; ")}) — ${editNotes.generatedBy} will overwrite it on the next rebuild` + : n || editNotes.deleted + ? ` edit noted for ${editNotes.generatedBy} (${editNotes.added} added, ${editNotes.updated} updated, ${editNotes.deleted} withdrawn) — port it into the generator's inputs` + : ` (generated by ${editNotes.generatedBy}; nothing changed)`, + ); + } console.log(`\nRun resolve-windows to see whether the widener agrees:`); console.log(` node umtool/report-to-video/resolve-windows.mjs ${p.dir}/video.manifest.json`); } catch (e) { diff --git a/umtool/components/AppNav.tsx b/umtool/components/AppNav.tsx @@ -6,7 +6,7 @@ import NavGroup from "./NavGroup"; // is waiting, the two benches that are not a project (mix, find), and the song // piles folded under one entry. // -// SEVEN visible entries (home, browse, decisions, sites, mix, find, song ▸), +// SEVEN visible entries (home, browse, decisions, articles, mix, find, song ▸), // and the cap is still NINE. A tenth wraps the header on // a laptop, and a nav that wraps stops reading as one row of places and starts // reading as a list. The next tool goes UNDER one of these, not beside them -- @@ -32,8 +32,10 @@ export default function AppNav({ active }: { active: string }) { // browse because that is where every decision it names gets settled. { href: "/browse/decisions", label: "decisions" }, // Every site's articles -- published and drafts -- with their notes, their - // evidence and the workspace they were written in. - { href: "/sites", label: "sites" }, + // evidence and the workspace they were written in. Named for what it lists: + // the editor's /sites is the sites themselves, and two "sites" a tab apart + // were two places with one name. The URL stays /sites. + { href: "/sites", label: "articles" }, { href: "/mix", label: "mix" }, // Every occurrence of a word across the corpus. It sits with browse because // what it retrieves is raw material for a build, not a pile to judge. diff --git a/umtool/components/articles/anchorDom.ts b/umtool/components/articles/anchorDom.ts @@ -82,17 +82,39 @@ export function wrapRange(root: Element, start: number, end: number, attrs: Reco return out; } -/** The block a selection lies in, and its offsets, or null (collapsed, or across blocks). */ +/** + * The block a selection lies in, and its offsets, or null (collapsed, or in no + * block of `container`). + * + * A selection that runs ACROSS blocks -- the end of one section into the next, + * which is what a drag past a paragraph does -- is noted in ONE of them: a text + * anchor names one section (lib/annotations/anchor.mjs), and that is where + * `umtool notes` finds it again. The block it starts in, from the start to the + * block's end, when that part has any text; else the block it ends in, from its + * beginning. It used to get no Note button at all. + */ export function selectionIn(container: Element): { block: Element; start: number; end: number; rect: DOMRect } | null { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null; const range = sel.getRangeAt(0); const el = (n: Node) => (n.nodeType === Node.ELEMENT_NODE ? (n as Element) : n.parentElement); - const a = el(range.startContainer)?.closest("[data-block]"); - const b = el(range.endContainer)?.closest("[data-block]"); - if (!a || a !== b || !container.contains(a)) return null; - const start = offsetOf(a, range.startContainer, range.startOffset); - const end = offsetOf(a, range.endContainer, range.endOffset); - if (end <= start) return null; - return { block: a, start, end, rect: range.getBoundingClientRect() }; + const within = (e: Element | null | undefined) => (e && container.contains(e) ? e : null); + const a = within(el(range.startContainer)?.closest("[data-block]")); + const b = within(el(range.endContainer)?.closest("[data-block]")); + const rect = range.getBoundingClientRect(); + if (a && a === b) { + const start = offsetOf(a, range.startContainer, range.startOffset); + const end = offsetOf(a, range.endContainer, range.endOffset); + return end > start ? { block: a, start, end, rect } : null; + } + if (a) { + const start = offsetOf(a, range.startContainer, range.startOffset); + const text = blockText(a).text; + if (text.slice(start).trim()) return { block: a, start, end: text.length, rect }; + } + if (b) { + const end = offsetOf(b, range.endContainer, range.endOffset); + if (blockText(b).text.slice(0, end).trim()) return { block: b, start: 0, end, rect }; + } + return null; } diff --git a/umtool/docs/cli.md b/umtool/docs/cli.md @@ -51,7 +51,13 @@ projects answering to one name is reported, never resolved by picking one. | `REPORTS_DIR` | `~/reports` (the parent of `SONG_REPORTS_DIR` when that is set) | | `UMTOOL_MEDIA_DIR` | unset = `REPORTS_DIR`: `out/` stays in each project. Set, each project's `out` is a link to the same path under it ([folders.md](folders.md)) | | `UMTOOL_CACHE_DIR` | `$XDG_CACHE_HOME/archilyzer/umtool`, else `~/.cache/archilyzer/umtool` (it was `<SONG_DIR>/.cache/umtool`) | -| `CHANNELS_DIR` | `$TRANSCRIPTS_DIR/channels`, else the checkout's `transcripts/channels` (found by walking up from the cwd to `pnpm-workspace.yaml`) | +| `CHANNELS_DIR` | `$TRANSCRIPTS_DIR/channels`, else the checkout's `transcripts/channels` | +| `SITES_DIR` | `$TRANSCRIPTS_DIR/sites`, else the checkout's `transcripts/sites` | + +"The checkout" is the one the CLI script itself lives in (`<repo>/umtool/bin/umtool.mjs`), +whatever directory it is run from — `umtool notes --all` from a report workspace under +`~/reports` reads the corpus's sites. The app (`next dev`/`start`) finds it by walking up +from its cwd to `pnpm-workspace.yaml`. | `VIDEO_ROOT` (`song/spec.mjs`, `song/video-dir.mjs`) | `~/reports/quartering-uh-song/videos` | ## `check` is the one to run before every build @@ -85,7 +91,16 @@ many projects it only checked the routing of, and points at `/browse/decisions`. ## `window` goes through the same writer the bench does 2 dp, the CLI's own formatting, tmp+rename, one `.bak`. A second implementation is -how the two would start disagreeing about where a clip ends. +how the two would start disagreeing about where a clip ends. And through the same guard +(`lib/report/edit-guard.mjs`): on a GENERATED manifest (`generatedBy`) each change is +also an `edit` note in the project's notes.json, for the agent to port into the +generator's inputs — the next rebuild would otherwise undo it without a trace: + +``` +$ umtool window polemic-x e1 --start 11 +e1: 10–20 -> 11–20 + edit noted for polemics/video/make-videos.py (1 added, 0 updated, 0 withdrawn) — port it into the generator's inputs +``` ``` $ umtool window ferret-rescue c01 --start 43.12 --end 61.48 --lock-end diff --git a/umtool/e2e/article-notes.spec.ts b/umtool/e2e/article-notes.spec.ts @@ -71,6 +71,43 @@ test("select text, Note, save: a mark on the quote, a note beside report.json", await expect(page.locator("mark[data-note]")).toHaveAttribute("data-active", "true"); }); +// A drag that runs past the end of a section into the next one: the note is +// anchored in the section it STARTED in, from there to that section's end (a +// text anchor names one section). It used to get no Note button at all. +test("a selection across two sections gets a Note, anchored in the first", async ({ page }) => { + await page.goto(PAGE); + await page.evaluate(() => { + const at = (block: string, text: string): [Text, number] => { + const root = document.querySelector(`[data-block="${block}"]`)!; + const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let n = w.nextNode() as Text | null; n; n = w.nextNode() as Text | null) { + const i = n.data.indexOf(text); + if (i >= 0) return [n, i]; + } + throw new Error(`no "${text}" in ${block}`); + }; + const [a, i] = at("first", "Nobody checked the claim"); + const [b, j] = at("later", "on a different show"); + const r = document.createRange(); + r.setStart(a, i); + r.setEnd(b, j + "on a different".length); + const s = getSelection()!; + s.removeAllRanges(); + s.addRange(r); + }); + await page.locator('[data-block="later"]').dispatchEvent("mouseup"); + await page.getByRole("button", { name: "Note", exact: true }).click(); + await page.getByLabel("note text").fill("This runs on."); + await page.getByRole("button", { name: "save note" }).click(); + + const anchor = JSON.parse(readFileSync(NOTES, "utf8")).notes[0].anchor; + expect(anchor).toMatchObject({ kind: "text", section: "first" }); + expect(anchor.quote.startsWith("Nobody checked the claim")).toBe(true); + expect(anchor.quote).toContain("for the record."); + expect(anchor.quote).not.toContain("different show"); + await expect(page.locator('[data-block="first"] mark[data-note]').first()).toBeVisible(); +}); + test("section, whole-article and citation notes; resolve, reopen, reply, filters", async ({ page }) => { await page.goto(PAGE); await page.getByRole("button", { name: "note on Later" }).click(); diff --git a/umtool/e2e/mix.spec.ts b/umtool/e2e/mix.spec.ts @@ -1,4 +1,7 @@ import { test, expect } from "@playwright/test"; +import { existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; // The mix bench, against SYNTHESISED tracks whose true answers are known in // advance (see make-fixture.mjs): @@ -15,6 +18,65 @@ import { test, expect } from "@playwright/test"; const BG = "bg.mp4"; const SONG = "song.mp4"; +// THE CORPUS'S CLIP WINDOWS, SET ASIDE FOR THIS FILE. +// +// The bench folds the corpus's clip windows (channels/<slug>/data/<id>/clips/, +// what the editor's fetch writes) in with a project's own clips-raw. The +// fixture is built once per run and specs before this one fetch windows +// through the editor stub (testchan/vid1 0-14, say), so which file a clip +// links to, and whether c02 is fetched at all, depended on what ran first: +// `:166` and `:201` failed in every full run and passed alone (FACTS, "mix.spec.ts +// IS ORDER-DEPENDENT"). These tests are about the project's clips-raw, so they +// start with the corpus windows moved aside, and put them back after, for the +// specs that follow. +const FIXTURE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", ".e2e-song"); +const CHANNELS = path.join(FIXTURE, "channels"); +const ASIDE = path.join(FIXTURE, "mix-spec-clips-aside"); + +function clipDirs(): string[] { + const out: string[] = []; + if (!existsSync(CHANNELS)) return out; + for (const slug of readdirSync(CHANNELS)) { + const data = path.join(CHANNELS, slug, "data"); + if (!existsSync(data)) continue; + for (const id of readdirSync(data)) { + const clips = path.join(data, id, "clips"); + if (existsSync(clips)) out.push(path.relative(CHANNELS, clips)); + } + } + return out; +} + +function restoreClips() { + if (!existsSync(ASIDE)) return; + for (const rel of clipDirsUnder(ASIDE)) { + const back = path.join(CHANNELS, rel); + if (existsSync(back)) rmSync(back, { recursive: true, force: true }); + mkdirSync(path.dirname(back), { recursive: true }); + renameSync(path.join(ASIDE, rel), back); + } + rmSync(ASIDE, { recursive: true, force: true }); +} + +function clipDirsUnder(root: string): string[] { + const out: string[] = []; + for (const slug of readdirSync(root)) { + const data = path.join(root, slug, "data"); + if (!existsSync(data)) continue; + for (const id of readdirSync(data)) out.push(path.join(slug, "data", id, "clips")); + } + return out; +} + +test.beforeAll(() => { + restoreClips(); // a run killed mid-file left some aside + for (const rel of clipDirs()) { + mkdirSync(path.dirname(path.join(ASIDE, rel)), { recursive: true }); + renameSync(path.join(CHANNELS, rel), path.join(ASIDE, rel)); + } +}); +test.afterAll(restoreClips); + test("the analysis finds a cue an envelope cannot see", async ({ request }) => { const r = await request.get(`/api/mix/track?path=${encodeURIComponent(BG)}`); expect(r.ok()).toBe(true); @@ -112,7 +174,9 @@ test("the bench loads, lists real tracks, and hides render scratch", async ({ pa await expect(page.getByRole("link", { name: "mix", exact: true })).toBeVisible(); const body = page.locator("select").first(); - await expect(body.locator("option", { hasText: "song.mp4" })).toHaveCount(1); + // The label exactly: `hasText` is a substring match, and a render another + // spec left (`…song.mp4`) counted as a second song. + await expect(body.locator("option", { hasText: /^song\.mp4$/ })).toHaveCount(1); // polytmp-*/ and poly-song-*.wav are working files, never offerable. await expect(page.locator("option").filter({ hasText: /poly-song-|polytmp-/ })).toHaveCount(0); }); diff --git a/umtool/e2e/sites.spec.ts b/umtool/e2e/sites.spec.ts @@ -20,7 +20,9 @@ test.beforeAll(async ({ playwright }) => { test("/sites lists every site, private first, with published and draft articles", async ({ page }) => { const res = await page.goto("/sites"); expect(res?.status()).toBe(200); - await expect(page.getByRole("link", { name: "sites", exact: true }).first()).toHaveAttribute("aria-current", "page"); + // The nav calls it "articles" (the editor's /sites is the sites themselves). + await expect(page.getByRole("link", { name: "articles", exact: true }).first()).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("link", { name: "sites", exact: true })).toHaveCount(0); const sites = page.locator("[data-site]"); await expect(sites).toHaveCount(2); diff --git a/umtool/lib/paths.mjs b/umtool/lib/paths.mjs @@ -4,7 +4,7 @@ // directory is read and which is written must not be able to differ between // `umtool ls` and the page it is supposed to describe. lib/paths.ts re-exports // everything here with types; nothing computes a root twice. -import { existsSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { lstat, realpath } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -199,7 +199,35 @@ export function findRepoRoot(start) { } } -export const REPO_ROOT = findRepoRoot(process.cwd()); +/** + * The checkout a umtool CLI belongs to -- `<repo>/umtool/bin/<cli>.mjs` as the + * entry script (`process.argv[1]`) -- or null for anything else (the Next + * server, a test runner, report-to-video's own CLIs). + * + * A CLI is run from wherever its user stands: `node ~/…/umtool/bin/umtool.mjs + * notes --all` from a report workspace under ~/reports has no + * pnpm-workspace.yaml above its cwd, so the cwd walk fell back to the cwd's + * parent and SITES_DIR (and CHANNELS_DIR) pointed at nothing. The script's own + * path names the checkout it is from. Only the bin/ entries: the server keeps + * the cwd walk, for the reason findRepoRoot gives. + * + * @param {string | undefined} entry + * @returns {string | null} + */ +export function cliRepoRoot(entry) { + if (!entry || !/[\\/]umtool[\\/]bin[\\/][^\\/]+\.mjs$/.test(entry)) return null; + let real; + try { + real = realpathSync(/* turbopackIgnore: true */ entry); + } catch { + return null; + } + // <repo>/umtool/bin/x.mjs -> <repo>, when <repo> is a checkout. + const repo = path.resolve(/* turbopackIgnore: true */ path.dirname(/* turbopackIgnore: true */ real), "..", ".."); + return existsSync(path.join(/* turbopackIgnore: true */ repo, "pnpm-workspace.yaml")) ? repo : null; +} + +export const REPO_ROOT = cliRepoRoot(process.argv[1]) ?? findRepoRoot(process.cwd()); export const CHANNELS_DIR = path.resolve( /* turbopackIgnore: true */ diff --git a/umtool/lib/paths.test.mjs b/umtool/lib/paths.test.mjs @@ -0,0 +1,69 @@ +// Where a umtool CLI finds the corpus: from its OWN checkout (the entry +// script's path), whatever directory it is run from. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { cliRepoRoot } from "./paths.mjs"; + +const PATHS = new URL("./paths.mjs", import.meta.url); + +function checkout() { + const root = mkdtempSync(path.join(tmpdir(), "umtool-paths-")); + writeFileSync(path.join(root, "pnpm-workspace.yaml"), "packages: []\n"); + mkdirSync(path.join(root, "umtool", "bin"), { recursive: true }); + return root; +} + +test("cliRepoRoot: a umtool/bin entry names its checkout; anything else is null", (t) => { + const root = checkout(); + t.after(() => rmSync(root, { recursive: true, force: true })); + const cli = path.join(root, "umtool", "bin", "umtool.mjs"); + writeFileSync(cli, ""); + assert.equal(cliRepoRoot(cli), root); + // The server, a test file, report-to-video's CLIs: the cwd walk decides. + assert.equal(cliRepoRoot(path.join(root, "node_modules", "next", "dist", "bin", "next")), null); + assert.equal(cliRepoRoot(path.join(root, "umtool", "report-to-video", "build-video.mjs")), null); + assert.equal(cliRepoRoot(undefined), null); + // A bin/ whose grandparent is not a checkout, and one that does not exist. + const loose = mkdtempSync(path.join(tmpdir(), "umtool-loose-")); + t.after(() => rmSync(loose, { recursive: true, force: true })); + mkdirSync(path.join(loose, "umtool", "bin"), { recursive: true }); + writeFileSync(path.join(loose, "umtool", "bin", "x.mjs"), ""); + assert.equal(cliRepoRoot(path.join(loose, "umtool", "bin", "x.mjs")), null); + assert.equal(cliRepoRoot(path.join(root, "umtool", "bin", "missing.mjs")), null); + // Reached through a link: the TARGET's checkout, not the link's. + const link = path.join(loose, "umtool", "bin", "linked.mjs"); + symlinkSync(cli, link); + assert.equal(cliRepoRoot(link), root); +}); + +test("a CLI run from outside its checkout reads that checkout's sites and channels", (t) => { + const root = checkout(); + t.after(() => rmSync(root, { recursive: true, force: true })); + const probe = path.join(root, "umtool", "bin", "probe.mjs"); + writeFileSync( + probe, + `const p = await import(${JSON.stringify(PATHS.href)});\n` + + "console.log(JSON.stringify({ repo: p.REPO_ROOT, sites: p.SITES_DIR, channels: p.CHANNELS_DIR }));\n", + ); + const away = mkdtempSync(path.join(tmpdir(), "umtool-away-")); + t.after(() => rmSync(away, { recursive: true, force: true })); + const env = { ...process.env }; + for (const k of ["SITES_DIR", "CHANNELS_DIR", "TRANSCRIPTS_DIR"]) delete env[k]; + const got = JSON.parse(execFileSync(process.execPath, [probe], { cwd: away, env, encoding: "utf8" })); + assert.deepEqual(got, { + repo: root, + sites: path.join(root, "transcripts", "sites"), + channels: path.join(root, "transcripts", "channels"), + }); + // SITES_DIR still wins when it is set. + const set = JSON.parse( + execFileSync(process.execPath, [probe], { cwd: away, env: { ...env, SITES_DIR: away }, encoding: "utf8" }), + ); + assert.equal(set.sites, away); +}); diff --git a/umtool/lib/report/edit-guard.mjs b/umtool/lib/report/edit-guard.mjs @@ -0,0 +1,46 @@ +// THE one wrapper every manifest writer goes through -- the routes (through +// lib/report/guard.ts, which types it) and the `umtool window` CLI alike. +// +// A manifest with `generatedBy` is rebuilt by its generator, and the rebuild +// overwrites edits made here (the banner on the project page, the bench and +// the On-screen section says so). The edit is still made; what this adds is a +// record of it: the manifest is read before and after the write, and every +// change becomes an `edit` note in the project's notes.json for the agent to +// port into the generator's inputs (./edit-notes.mjs). A hand-edited manifest +// (no `generatedBy`) is written exactly as before. +// +// The notes are written AFTER the manifest and never fail the write: the edit +// is saved either way, and `editNotes.errors` says when its note is not. +// +// Plain .mjs so the CLI can run it with bare node; the routes read it through +// guard.ts. +import { projectTargetFor } from "../annotations/targets.mjs"; +import { readManifest } from "../projects/report.mjs"; +import { editsBetween, recordEdits } from "./edit-notes.mjs"; + +/** + * @template T + * @param {{ id: string, dir: string, kind: string }} project + * @param {() => Promise<T>} write + * @param {{ reportsRoot?: string }} [opts] the reports root notes may live under (tests) + * @returns {Promise<{ result: T, editNotes: { generatedBy: string, added: number, updated: number, deleted: number, errors: string[] } | null }>} + */ +export async function withEditNotes(project, write, opts = {}) { + const before = await readManifest(project.dir); + const result = await write(); + const generatedBy = typeof before?.generatedBy === "string" ? before.generatedBy.trim() : ""; + if (!generatedBy) return { result, editNotes: null }; + const after = await readManifest(project.dir); + const edits = editsBetween(before, after); + if (!edits.length) return { result, editNotes: { generatedBy, added: 0, updated: 0, deleted: 0, errors: [] } }; + try { + const target = await projectTargetFor(project, opts.reportsRoot ? { reportsRoot: opts.reportsRoot } : undefined); + const counts = await recordEdits(target, edits, generatedBy); + return { result, editNotes: { generatedBy, ...counts } }; + } catch (e) { + return { + result, + editNotes: { generatedBy, added: 0, updated: 0, deleted: 0, errors: [e instanceof Error ? e.message : String(e)] }, + }; + } +} diff --git a/umtool/lib/report/edit-guard.test.mjs b/umtool/lib/report/edit-guard.test.mjs @@ -0,0 +1,74 @@ +// The manifest writers' guard (edit-guard.mjs) and `umtool window` through it: +// an edit to a GENERATED manifest is an `edit` note for its generator; a +// hand-edited manifest gets none. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { readNotes } from "../annotations/store.mjs"; +import { updateClip } from "./manifest.mjs"; +import { withEditNotes } from "./edit-guard.mjs"; + +const CLI = fileURLToPath(new URL("../../bin/umtool.mjs", import.meta.url)); + +async function project(manifest) { + const root = await mkdtemp(path.join(tmpdir(), "umtool-editguard-")); + const dir = path.join(root, "ws", "clipcut"); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "video.manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); + return { root, dir, project: { id: "ws/clipcut", dir, kind: "report-video" } }; +} + +const MANIFEST = (generated) => ({ + schemaVersion: 1, + slug: "clipcut", + ...(generated ? { generatedBy: "polemics/video/make-videos.py" } : {}), + timeline: [{ type: "clip", id: "e1", channel: "ch", video: "v1", start: 10, end: 20 }], +}); + +test("an edit to a generated manifest is written, and noted for its generator", async () => { + const { root, dir, project: p } = await project(MANIFEST(true)); + try { + const { result, editNotes } = await withEditNotes(p, () => updateClip(dir, "e1", { start: 12 }), { reportsRoot: root }); + assert.equal(result.entry.start, 12); + assert.deepEqual(editNotes, { generatedBy: "polemics/video/make-videos.py", added: 1, updated: 0, deleted: 0, errors: [] }); + const { doc } = await readNotes(path.join(dir, "notes.json")); + assert.equal(doc.notes.length, 1); + assert.deepEqual(doc.notes[0].anchor, { kind: "edit", entry: "e1", field: "start", from: 10, to: 12 }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("a hand-edited manifest is written with no note", async () => { + const { root, dir, project: p } = await project(MANIFEST(false)); + try { + const { editNotes } = await withEditNotes(p, () => updateClip(dir, "e1", { start: 12 }), { reportsRoot: root }); + assert.equal(editNotes, null); + await assert.rejects(readFile(path.join(dir, "notes.json"))); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("`umtool window` on a generated manifest says it noted the edit, and the note is there", async () => { + const { root, dir } = await project(MANIFEST(true)); + try { + const out = execFileSync(process.execPath, [CLI, "window", dir, "e1", "--start", "11", "--end", "21"], { + cwd: root, + env: { ...process.env, REPORTS_DIR: root }, + encoding: "utf8", + }); + assert.match(out, /e1: 10–20 -> 11–21/); + assert.match(out, /edit noted for polemics\/video\/make-videos\.py \(2 added/); + const { doc } = await readNotes(path.join(dir, "notes.json")); + assert.deepEqual(doc.notes.map((n) => n.anchor.field).sort(), ["end", "start"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/umtool/lib/report/guard.ts b/umtool/lib/report/guard.ts @@ -1,19 +1,10 @@ -import { projectTargetFor } from "@/lib/annotations/targets.mjs"; -import { readManifest } from "@/lib/projects/report.mjs"; -import { editsBetween, recordEdits } from "./edit-notes.mjs"; +import { withEditNotes as withEditNotesMjs } from "./edit-guard.mjs"; -// THE one wrapper every manifest writer's route goes through. -// -// A manifest with `generatedBy` is rebuilt by its generator, and the rebuild -// overwrites edits made here (the banner on the project page, the bench and -// the On-screen section says so). The edit is still made; what this adds is a -// record of it: the manifest is read before and after the write, and every -// change becomes an `edit` note in the project's notes.json for the agent to -// port into the generator's inputs (lib/report/edit-notes.mjs). A hand-edited -// manifest (no `generatedBy`) is written exactly as before. -// -// The notes are written AFTER the manifest and never fail the request: the -// edit is saved either way, and `editNotes.errors` says when its note is not. +// THE one wrapper every manifest writer's route goes through: the manifest is +// read before and after the write, and on a GENERATED manifest every change +// becomes an `edit` note for the agent that generates it. The implementation +// is ./edit-guard.mjs, plain JS so the `umtool window` CLI runs the same one; +// this file only types it for the routes. export type EditNotes = { generatedBy: string; added: number; updated: number; deleted: number; errors: string[] }; @@ -21,18 +12,5 @@ export async function withEditNotes<T>( project: { id: string; dir: string; kind: string }, write: () => Promise<T>, ): Promise<{ result: T; editNotes: EditNotes | null }> { - const before = await readManifest(project.dir); - const result = await write(); - const generatedBy = typeof before?.generatedBy === "string" ? before.generatedBy.trim() : ""; - if (!generatedBy) return { result, editNotes: null }; - const after = await readManifest(project.dir); - const edits = editsBetween(before, after); - if (!edits.length) return { result, editNotes: { generatedBy, added: 0, updated: 0, deleted: 0, errors: [] } }; - try { - const target = await projectTargetFor(project); - const counts = await recordEdits(target, edits, generatedBy); - return { result, editNotes: { generatedBy, ...counts } }; - } catch (e) { - return { result, editNotes: { generatedBy, added: 0, updated: 0, deleted: 0, errors: [e instanceof Error ? e.message : String(e)] } }; - } + return withEditNotesMjs(project, write) as Promise<{ result: T; editNotes: EditNotes | null }>; } diff --git a/umtool/package.json b/umtool/package.json @@ -8,7 +8,7 @@ "build": "next build", "start": "next start --port ${UMTOOL_PORT:-3050}", "typecheck": "tsc --noEmit", - "e2e": "node ../scripts/queue-lock.mjs --ports UMTOOL_E2E_PORT:3051,EDITOR_STUB_PORT:3052 -- playwright test" + "e2e": "node ../scripts/worktree.mjs run -- node ../scripts/queue-lock.mjs --ports UMTOOL_E2E_PORT:3051,EDITOR_STUB_PORT:3052 -- playwright test" }, "dependencies": { "class-variance-authority": "^0.7.1",