Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 21 additions & 16 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ npm run perf:shape # Compare analysis shape across tiers and stored projec
npm run perf:indexes # Check derived indexes and lookups against the records, after random edits
```

Only a template merge moves the dependency baseline `npm run lint:dependencies` checks against; refresh it in that same commit with `npm run template:baseline`, which writes both the copy and the `MERGED_TEMPLATE_COMMIT` id recorded beside it in [scripts/check-dependency-scope.cjs](scripts/check-dependency-scope.cjs). [README.md](README.md) has the full procedure.
Only a template merge moves the dependency baseline that `npm run lint:dependencies` checks against. Refresh it in that same commit with `npm run template:baseline`, which writes both the baseline (a copy of the template's `package.json`) and the `MERGED_TEMPLATE_COMMIT` id in [scripts/check-dependency-scope.cjs](scripts/check-dependency-scope.cjs). [README.md](README.md) has the full procedure.

## Architecture

Expand All @@ -44,8 +44,8 @@ This is a **Platform.Bible extension** for interlinear Bible text alignment. Pla

[src/main.ts](src/main.ts) β€” called by Platform.Bible on activation. Exports two lifecycle functions:

- `activate(context)` β€” stores the `ExecutionToken`, registers the `interlinearizer.mainWebView` WebView provider, command handlers, the `interlinearizer.onUndoFromNotification` and `interlinearizer.onAnalysisChanged` network event emitters, the `interlinearizer.continuousScroll` project settings validator, and the `onDidOpenWebView` / `onDidUpdateWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation.
- `deactivate()` β€” clears `openWebViewsByProject`, lets go of every book `bookService` keeps, and returns `true`.
- `activate(context)` β€” stores the `ExecutionToken`, registers the `interlinearizer.mainWebView` WebView provider, command handlers, network event emitters, project settings validators, and the `onDidOpenWebView` / `onDidUpdateWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation.
- `deactivate()` β€” clears `openWebViewsByProject`, closes every session, lets go of every book `bookService` keeps, and returns `true`.

`openWebViewsByProject` (`Map<string, string>`) tracks one open WebView ID per project to prevent duplicates; reopening an already-open project brings that tab to front via the `existingId` option.

Expand All @@ -72,11 +72,11 @@ The WebView root component is assigned to `globalThis.webViewComponent` (not exp

### Project modals

[src/components/ProjectModals.tsx](src/components/ProjectModals.tsx) β€” single mount point for all project-related dialogs, switching between `'select' | 'create' | 'metadata' | 'none'` states. The three modal components ([SelectInterlinearProjectModal](src/components/SelectInterlinearProjectModal.tsx), [CreateProjectModal](src/components/CreateProjectModal.tsx), [ProjectMetadataModal](src/components/ProjectMetadataModal.tsx)) call backend commands to list, create, update, and delete projects.
[src/components/modals/ProjectModals.tsx](src/components/modals/ProjectModals.tsx) β€” mount point for the project dialogs, switching on its `ModalState`. The dialogs it mounts call backend commands to list, create, update, and delete projects.

### Project storage

[src/services/projectStorage.ts](src/services/projectStorage.ts) β€” owns all `papi.storage` reads and writes for interlinearizer projects. Two serialization queues prevent interleaved read-modify-write races. Tests must call `resetQueuesForTesting()` between tests because module state is not cleared by `resetMocks`.
[src/services/projectStorage.ts](src/services/projectStorage.ts) β€” owns all `papi.storage` reads and writes for interlinearizer projects. Serialization queues prevent interleaved read-modify-write races. Tests must call `resetQueuesForTesting()` between tests because module state is not cleared by `resetMocks`.

Drafts are loaded and saved through [AnalysisRepository](src/types/analysis-repository.ts), the seam a later storage backend implements. [DraftRepository](src/services/draftRepository.ts) implements it over the draft envelope and its per-book shards, and rewrites only the books that changed. It imports nothing from PAPI and reaches storage only through a `KeyValueStorage`. That lets the perf harness run it in Node, straight over the storage files ([perf/storage.ts](perf/storage.ts)). [Pt9ImportRepository](src/services/pt9ImportRepository.ts) serves a Paratext 9 import through the same seam, read-only. Both answer the seam's lookups from an [AnalysisIndex](src/services/analysisIndex.ts) built from the records on first use. `DraftRepository` keeps it current through `apply`, and `checkIndexes` compares it with one built afresh.

Expand All @@ -86,15 +86,15 @@ Saved projects are listed through [ProjectSummaries](src/services/projectSummari

### Sessions

[AnalysisSessions](src/services/analysisSessions.ts) keeps the host's sessions, behind the `openSession`, `closeSession` and `setAnalysisLanguage` commands. A session is a handle on one source project's draft, paired with the interlinear project it saves to, or on a Paratext 9 import alone, read-only. A source has at most one session: opening another closes it. Each session runs its commands one at a time, through `run`, or `runEdit` for a command that changes stored analysis, which a read-only session refuses. An edit reaches the repository as a `ChangeSet` of whole-record puts and deletes, applied through the session's [EditHistory](src/services/editHistory.ts) so that undo can revert it; boundary changes ride in the change set's scope record. The WebView doesn't use sessions yet.
[AnalysisSessions](src/services/analysisSessions.ts) keeps the host's sessions, behind the `openSession`, `closeSession` and `setAnalysisLanguage` commands. A session is a handle on either one source project's draft, paired with the interlinear project it saves to, or a read-only Paratext 9 import. A source has at most one session: opening another closes it. Each session runs its commands one at a time through `run`. A command that changes stored analysis goes through `runEdit` instead, which a read-only session refuses. An edit reaches the repository as a `ChangeSet` of whole-record puts and deletes, applied through the session's [EditHistory](src/services/editHistory.ts) so that undo can revert it; boundary changes ride in the change set's scope record. The WebView doesn't use sessions yet.

### Components

Prefer Platform.Bible's `platform-bible-react` components (`Button`, `Input`, `Textarea`, `Label`, `Switch`, `RadioGroup`, `Popover`, `Tooltip`, etc.) over hand-rolled raw HTML (`<button>`, `<input>`, `<textarea>`, `<label>`, custom dropdowns) wherever an equivalent exists β€” this keeps the UI visually and behaviorally consistent with the host app and gives us theming and accessibility for free. Reserve raw elements for cases where the platform component can't preserve behavior the extension depends on (e.g. `field-sizing: content` gloss inputs); when you do, add a comment explaining why. When a raw element is replaced by a platform component in tests, extend `__mocks__/platform-bible-react.tsx` to stub the newly-used component.

Size icons inside a `Button` with `size-*` (e.g. `tw:size-3`), never `h-*`/`w-*` β€” `buttonVariants` forces any child SVG lacking a `size-` class to `size-4`, silently overriding `h-*`/`w-*`.

Modals go through [ModalShell](src/components/modals/ModalShell.tsx) rather than building their own overlay β€” it supplies the platform `Dialog`, which brings a focus trap, scroll lock, focus restore, and Escape-to-dismiss. Whether a modal passes `onClose` is the single switch governing every dismissal route: supplying it enables both Escape and outside-click, and a modal that is mid-submission passes none, so neither route can abandon in-flight work. Suppress it only for work that is genuinely being abandoned β€” a read-only load has nothing to abandon and leaves the modal dismissable. Modals tag their title with a `data-testid` because end-to-end tests locate them that way; never give the title an `id` instead, since that displaces the one the platform `Dialog` generates and the dialog then logs that its title is missing. The platform `Dialog` and `Popover` both render `role="dialog"` β€” select a modal by `[data-slot="dialog-content"]` when the two must be told apart.
Modals go through [ModalShell](src/components/modals/ModalShell.tsx) rather than building their own overlay β€” it supplies the platform `Dialog`, which brings a focus trap, scroll lock, focus restore, and Escape-to-dismiss. Whether a modal passes `onClose` is the single switch governing every dismissal route. Supplying it enables both Escape and outside-click. A modal that is mid-submission passes none, so neither route can abandon in-flight work. Withhold it only while dismissing would abandon real work; a read-only load has nothing to abandon, so its modal stays dismissable. Modals tag their title with a `data-testid` because end-to-end tests locate them that way; never give the title an `id` instead, since that displaces the one the platform `Dialog` generates and the dialog then logs that its title is missing. The platform `Dialog` and `Popover` both render `role="dialog"` β€” select a modal by `[data-slot="dialog-content"]` when the two must be told apart.

### Styling

Expand All @@ -113,9 +113,9 @@ Data flows from Platform.Bible's USJ (Unified Scripture JSON) format through two

The core types are:

- `InterlinearProject` β€” persisted envelope: id, createdAt, `modelVersion`, optional name/description, `sourceProjectId`, optional `targetProjectId`, `analysisLanguages`, `analysis: TextAnalysis`, and optional `links`. Only this is serialized to storage; the `Book` hierarchy is rebuilt from USJ on each load.
- `InterlinearProject` β€” persisted envelope: project metadata, source and optional target project ids, and the `analysis: TextAnalysis`. Only this is serialized to storage; the `Book` hierarchy is rebuilt from USJ on each load.
- `ActiveProject` β€” runtime pairing of `project: InterlinearProject` with reconstructed `source` and optional `target` books.
- `Book β†’ Segment β†’ Token` β€” the text hierarchy. Section headings are segments too (`Segment.heading`), filed under the verse before them as Paratext files them (or under verse 0 when ahead of a chapter's first verse), with no verse starts; they never merge or split with verse text. A heading within a verse's text splits that verse, and the text resuming after it is a segment keyed and flagged like a split's later piece. Text ahead of the first chapter or verse is kept apart as `Book.frontMatter`, tokenized but never segmented or displayed.
- `Book β†’ Segment β†’ Token` β€” the text hierarchy. Section headings are segments too (`Segment.heading`), with no verse starts. As in Paratext, each heading is filed under the verse before it, or under verse 0 when it precedes a chapter's first verse. Headings never merge or split with verse text. A heading within a verse's text splits that verse, and the text resuming after it is a segment keyed and flagged like a split's later piece. Text ahead of the first chapter or verse is kept apart as `Book.frontMatter`, tokenized but never segmented or displayed.
- `TextAnalysis` β€” flat analysis layer keyed by id (does **not** mirror text hierarchy)
- `TokenAnalysis / MorphemeAnalysis` β€” parse and 1:1 glosses; multiple analyses per token are allowed, distinguished by `status`
- `AlignmentLink` β€” directional links between source and target endpoints
Expand All @@ -130,6 +130,16 @@ Key invariants: `Segment.baselineText.slice(charStart, charEnd) === Token.surfac
- `@main` β†’ `src/main`
- `parsers/*` β†’ `src/parsers/*`

## Edge cases

Weigh an edge case's cost before handling it. Each one handled is paid for across code, tests, and documentation, in maintenance and in the context every agent loads to work here. Handle it fully only when that cost buys something:

- **Danger**: what actually goes wrong if it's unhandled? Lost or corrupted data and silently wrong output justify the cost; an awkward result in a state no real workflow reaches does not.
- **Use**: does a real user or caller produce it, or only a contrived input?
- **Blocking**: does the task at hand need it handled to work, or can it wait for a follow-up issue?

When it isn't worth handling, throw a clear error naming the unsupported case. A throw costs a line, marks the boundary, and turns a silently wrong result into a visible one that can be addressed once it matters.

## Testing

Jest with ts-jest, jsdom environment. PAPI is fully mocked in `__mocks__/`. Coverage is enforced at 100% on all `src/**` files (branches, functions, lines, statements), aside for select explicit exclusions.
Expand All @@ -155,22 +165,17 @@ This is purely about which branches may be explicitly ignored from coverage. It

### Mock internals

Key semantic properties of the mock setup:

- **`resetMocks: true`** β€” Mock implementations are cleared before every test. Each test must set up its own mocks (typically in `beforeEach`); never rely on state leaking from a prior test.
- **Backend vs. frontend exclusivity** β€” Backend tests use `papi-backend.ts`, WebView tests use `papi-frontend.ts` + `papi-frontend-react.ts`. Each mock file ends with `export {}` to be treated as a module.
- **`globalThis.webViewComponent` contract** β€” The WebView root component is assigned to the global (not exported). Tests must `require()` the module and read `globalThis.webViewComponent` to get the component.

Mock files:

- **`__mocks__/fileMock.ts`** β€” Stub static asset imports.
- **`__mocks__/lucide-react.tsx`** β€” Stubs icon components used in modals.
- **`__mocks__/papi-backend.ts`** β€” Mocks with jest fns. Re-exports internal jest fns on the default export as `__mock*` properties (e.g., `papi.__mockRegisterCommand`) so tests can assert on them without re-importing. See file for full list.
- **`__mocks__/papi-core.ts`** β€” Empty module; exists only for module resolution since `@papi/core` is types-only at runtime.
- **`__mocks__/papi-frontend.ts`** β€” Stubs `logger` (debug/error/info/warn as jest fns) and `papi.commands.sendCommand` / `papi.notifications.send`.
- **`__mocks__/papi-frontend.ts`** β€” Stubs `logger` and the `papi` services WebView code calls. See file for full list.
- **`__mocks__/papi-frontend-react.ts`** β€” Stubs PAPI React hooks.
- **`__mocks__/platform-bible-react.tsx`** β€” Stubs components with appropriate `data-testid` attributes. See file for test IDs.
- **`__mocks__/platform-bible-utils.ts`** β€” Stubs util functions.
- **`__mocks__/platform-bible-react-experimental.ts`** and **`__mocks__/platform-bible-utils-experimental.ts`** β€” Stub the `/experimental` entry points of those packages.
- **`__mocks__/styleInlineMock.ts`** and **`__mocks__/styleMock.ts`** β€” Stub `.scss?inline` and `.scss`.
- **`__mocks__/web-view-inline.ts`** β€” Stubs `*.web-view?inline` imports as a null-returning React component.
- **`src/__tests__/test-helpers.ts`** and **`src/__tests__/components/test-helpers.tsx`** β€” Shared fixture builders and component-test scaffolding. Read these before hand-rolling a token, segment, book, or project literal.
Expand Down
Loading