mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Compare commits
No commits in common. "main" and "3.4.3" have entirely different histories.
852 changed files with 44749 additions and 70851 deletions
|
|
@ -5,24 +5,33 @@ description: Use when updating Codex Panel for a new Codex CLI or app-server API
|
|||
|
||||
# Codex Panel App-Server Update
|
||||
|
||||
Use this skill when Codex Panel needs to follow Codex CLI or experimental `codex app-server` API changes.
|
||||
|
||||
## Ground Rules
|
||||
|
||||
- Treat `src/generated/app-server/` as generated output. Do not hand-edit generated bindings.
|
||||
- Use `docs/design.md` for app-server boundary and source-of-truth decisions before adding compatibility layers or product behavior.
|
||||
- Prefer current Codex CLI behavior over broad backward compatibility unless a concrete user need requires compatibility code.
|
||||
- Separate required compatibility fixes from optional cleanup or product improvements.
|
||||
- Do not rely on local Git hooks. Run required generation and verification commands explicitly.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Read the README Compatibility section and the relevant design and development guidance, then trace the affected app-server paths.
|
||||
2. Confirm the recorded, installed, and target Codex CLI versions. Check official release information when behavior is uncertain.
|
||||
3. Regenerate bindings with `npm run generate:app-server-types`.
|
||||
4. Review generated diffs for protocol changes that affect runtime behavior. If mechanical normalization is needed, update the normalization code in `scripts/generate-app-server-types.mjs` and regenerate instead of patching generated files by hand.
|
||||
5. Implement only the required compatibility changes and record the target version after validating it.
|
||||
6. Report optional cleanup or product follow-ups separately; do not implement them unless requested.
|
||||
1. Read the README Compatibility section, `docs/design.md`, `docs/development.md`, `package.json`, and app-server-related source around requests, notifications, threads, approvals, runtime settings, hooks, and model listing.
|
||||
2. Compare the README Compatibility table's `codex.testedCliVersion` with the target `codex --version`.
|
||||
3. Check official Codex CLI or app-server release information when the target version is newer or behavior is uncertain.
|
||||
4. Regenerate bindings with `npm run generate:app-server-types`.
|
||||
5. Review generated diffs for protocol changes that affect runtime behavior. If mechanical normalization is needed, update `scripts/normalize-generated-types.mjs` and regenerate instead of patching generated files by hand.
|
||||
6. Implement only the compatibility changes needed for the target Codex CLI version.
|
||||
7. Update the README Compatibility table's `codex.testedCliVersion` only after validating against that version.
|
||||
8. Identify optional follow-ups separately from required compatibility work:
|
||||
- API changes that allow removing compatibility shims, fallback code, or local workarounds.
|
||||
- New routes, notifications, fields, or lifecycle signals that could simplify existing implementation.
|
||||
- New app-server capabilities that could enable Codex Panel product improvements.
|
||||
9. Report optional follow-ups as proposals only, grouped separately from implemented required fixes unless the user explicitly asks to implement them.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run `npm run check` after regenerated bindings or compatibility changes.
|
||||
- Use the live Obsidian workflow when runtime behavior needs verification.
|
||||
- Report the tested Codex CLI version, changed compatibility behavior, and optional follow-ups.
|
||||
- If the change must be reflected in Obsidian, run `npm run build` and verify the plugin reload path separately.
|
||||
- Report the tested Codex CLI version, the generation command used, any app-server compatibility behavior that changed, and any optional simplification or feature proposals found.
|
||||
|
|
|
|||
|
|
@ -5,11 +5,15 @@ description: Use when validating Codex Panel inside a live Obsidian app with the
|
|||
|
||||
# Codex Panel Obsidian Dev
|
||||
|
||||
Use this skill for live Obsidian checks after building Codex Panel or when investigating behavior that unit tests cannot observe.
|
||||
|
||||
## Ground Rules
|
||||
|
||||
- Work from the Codex Panel repository root.
|
||||
- Use `docs/development.md` for the build and generated-asset expectations before live Obsidian validation.
|
||||
- Ask before altering or reloading the live Obsidian session, attaching a debugger, or running intrusive evaluation or input commands.
|
||||
- Prefer read-only inspection before state-changing commands.
|
||||
- Treat `obsidian plugin:reload id=codex-panel`, `obsidian reload`, `obsidian restart`, `obsidian devtools`, `obsidian eval`, `obsidian dev:cdp`, and `obsidian dev:mobile` as state-changing or intrusive. Ask the user before running them.
|
||||
- Prefer read-only inspection first: `plugin`, `commands`, `dev:errors`, `dev:dom`, `dev:css`, `dev:console`, `dev:screenshot`, `version`.
|
||||
- If `dev:console` reports that the debugger is not attached, ask before running `obsidian dev:debug on`.
|
||||
- Do not clear console or error buffers unless the user approves; clearing can destroy useful failure context.
|
||||
|
||||
## Workflow
|
||||
|
|
@ -20,13 +24,13 @@ description: Use when validating Codex Panel inside a live Obsidian app with the
|
|||
npm run build
|
||||
```
|
||||
|
||||
2. If approved, reload the plugin when the live session needs the new build:
|
||||
2. Before reloading the live plugin, ask the user whether it is OK to reload their current Obsidian session. If approved:
|
||||
|
||||
```bash
|
||||
obsidian plugin:reload id=codex-panel
|
||||
```
|
||||
|
||||
3. Open the relevant surface when needed:
|
||||
3. Open the relevant Codex Panel surface when needed:
|
||||
|
||||
```bash
|
||||
obsidian command id=codex-panel:open-panel
|
||||
|
|
@ -43,12 +47,54 @@ description: Use when validating Codex Panel inside a live Obsidian app with the
|
|||
obsidian dev:screenshot path=<scratch-path>/codex-panel.png
|
||||
```
|
||||
|
||||
5. Use focused Codex Panel-owned selectors found in the relevant implementation or styles.
|
||||
5. Use focused selectors for the behavior under test. Prefer stable Codex Panel classes such as `.codex-panel`, `.codex-panel__composer`, `.codex-panel-threads`, and `.codex-panel-chat-turn-diff`.
|
||||
|
||||
## Dynamic UI Investigations
|
||||
|
||||
When behavior depends on layout, asynchronous rendering, or virtualized DOM state, capture the visible state and relevant DOM metrics before and after the real input path, then again after rendering settles. Compare framework or data state with the DOM where possible. Remove temporary probes and instrumentation before final validation and re-check runtime errors.
|
||||
When checking behavior that depends on browser layout, asynchronous rendering, or virtualized DOM state, measure the live UI rather than inferring from unit tests alone.
|
||||
|
||||
- Capture both the user-visible state and the underlying DOM metrics. Useful fields include `scrollTop`, `scrollHeight`, `clientHeight`, element bounding rects, rendered element counts, inline styles, and relevant computed styles.
|
||||
- For virtualized or asynchronously rendered regions, compare framework state against DOM state where possible, such as virtualizer total size versus actual `scrollHeight`, rendered item count versus data count, or pre-event versus post-frame measurements.
|
||||
- Prefer real input paths for input-sensitive bugs. Use `obsidian dev:cdp method=Input.dispatchMouseEvent ...` or focused keyboard events when a synthetic `dispatchEvent()` may skip browser/Electron behavior.
|
||||
- Record measurements before the action, immediately after the action, after at least one animation frame, and after a short timeout when layout, markdown rendering, resize observers, or virtualizer settle loops may run later.
|
||||
- If injecting temporary DOM probes with `obsidian eval`, remove them before finishing and re-check `dev:errors`. Avoid leaving long-running eval promises; use bounded `setTimeout`-based scripts that resolve.
|
||||
- Treat console instrumentation as temporary. Remove debug logging before final validation, and search the touched files for probe markers or conflict markers before reporting.
|
||||
|
||||
## Common Checks
|
||||
|
||||
- Confirm the loaded version and enabled state:
|
||||
|
||||
```bash
|
||||
obsidian plugin id=codex-panel
|
||||
```
|
||||
|
||||
- List available Codex Panel commands:
|
||||
|
||||
```bash
|
||||
obsidian commands filter=codex-panel
|
||||
```
|
||||
|
||||
- Count rendered elements:
|
||||
|
||||
```bash
|
||||
obsidian dev:dom selector=.codex-panel__composer total
|
||||
```
|
||||
|
||||
- Inspect text, attributes, or computed styles:
|
||||
|
||||
```bash
|
||||
obsidian dev:dom selector=.codex-panel__composer text
|
||||
obsidian dev:dom selector=.codex-panel__composer attr=aria-label
|
||||
obsidian dev:dom selector=.codex-panel__composer css=display
|
||||
obsidian dev:css selector=.codex-panel__composer
|
||||
```
|
||||
|
||||
- Capture a screenshot for visual review:
|
||||
|
||||
```bash
|
||||
obsidian dev:screenshot path=<scratch-path>/codex-panel.png
|
||||
```
|
||||
|
||||
## Reporting
|
||||
|
||||
Report the observed behavior, runtime errors, intrusive actions taken, and evidence paths. State when an approval-gated check was skipped.
|
||||
Report the commands run, whether Obsidian was reloaded, key `dev:errors` or `dev:console` findings, and any screenshot path produced. If a command was skipped because it required user approval, state that plainly.
|
||||
|
|
|
|||
|
|
@ -5,23 +5,28 @@ description: Use when preparing, checking, committing, tagging, pushing, or repa
|
|||
|
||||
# Codex Panel Release
|
||||
|
||||
`docs/release.md` is the public procedure and source of truth for the release command sequence. This skill adds agent-facing gates, review duties, and failure handling.
|
||||
Use this skill when delegating Codex Panel release work to an agent. `docs/release.md` is the public procedure and source of truth for the release command sequence. This skill adds agent-facing gates, review duties, and failure handling.
|
||||
|
||||
## Ground Rules
|
||||
|
||||
- Read and follow `docs/release.md`; do not maintain a parallel release command sequence here.
|
||||
- Do not assume local Git hooks exist or ran. Run required verification commands explicitly.
|
||||
- Do not create GitHub Releases locally with `gh release create`; the tag-triggered GitHub Actions workflow owns release creation and asset attachment.
|
||||
- Treat generated release notes as a draft. Review the full release diff and keep one short, public-facing `## Changes` section without internal or procedural detail.
|
||||
- Keep internal validation notes, procedural details, and implementation reasoning out of release notes.
|
||||
- Release notes must be short, public-facing bullets under the single `## Changes` section required by `docs/release.md`.
|
||||
|
||||
## Delegation Procedure
|
||||
|
||||
1. Read `docs/release.md` and inspect the current version metadata and recent release notes.
|
||||
1. Read `docs/release.md`, `package.json`, `manifest.json`, `versions.json`, and existing `.github/release-notes/` files.
|
||||
2. Identify the target release version and the commit range since the previous released tag.
|
||||
3. Inspect the full release diff, run preparation as documented, and edit the draft for completeness, accuracy, and user-facing wording.
|
||||
4. Before committing, ask the user to approve the release version, reviewed release-note bullets, and included commit range.
|
||||
5. After approval, follow `docs/release.md` through preflight, tag, and push. Let GitHub Actions create or update the GitHub Release.
|
||||
3. Follow the preparation step in `docs/release.md`.
|
||||
4. Draft `.github/release-notes/X.Y.Z.md` from the full diff since the previous released tag, not only the latest commit.
|
||||
5. Before committing, ask the user to approve the release version, release-note bullets, and included commit range.
|
||||
6. After approval, follow `docs/release.md` for the commit, preflight, tag, and push sequence.
|
||||
7. After pushing, let GitHub Actions create or update the GitHub Release.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- If a release script or preflight fails, fix the cause and rerun the explicit failed command.
|
||||
- If the tag-triggered workflow fails before GitHub Release creation, use the repair procedure in `docs/release.md`.
|
||||
- If a GitHub Release already exists or was partially created, inspect the state before taking action; do not assume local asset upload is the right recovery path.
|
||||
|
|
|
|||
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
|
|
@ -4,8 +4,6 @@ updates:
|
|||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
|
|
@ -13,9 +11,6 @@ updates:
|
|||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
prefix-development: "chore(deps)"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
|
|
@ -29,6 +24,3 @@ updates:
|
|||
update-types:
|
||||
- "version-update:semver-minor"
|
||||
- "version-update:semver-major"
|
||||
- dependency-name: "typescript"
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
|
|
|
|||
3
.github/release-notes/3.4.4.md
vendored
3
.github/release-notes/3.4.4.md
vendored
|
|
@ -1,3 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Keep newly started or recently active threads visible and correctly ordered while Codex thread list snapshots catch up.
|
||||
5
.github/release-notes/3.4.5.md
vendored
5
.github/release-notes/3.4.5.md
vendored
|
|
@ -1,5 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Improve composer and message-stream scrolling behavior, including smoother manual scrolling.
|
||||
- Refine copied diagnostic details for clearer troubleshooting output.
|
||||
- Improve runtime settings state handling.
|
||||
4
.github/release-notes/3.4.6.md
vendored
4
.github/release-notes/3.4.6.md
vendored
|
|
@ -1,4 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Fixed Windows app-server launch when Codex is installed through npm and the configured executable is the `codex.cmd` shim (thanks @Lionel540, #16).
|
||||
- Fixed capability diagnostics so Codex Panel no longer loads the full app catalog during connection or `/tools`, avoiding sustained Codex app-server CPU and energy use.
|
||||
6
.github/release-notes/4.0.0.md
vendored
6
.github/release-notes/4.0.0.md
vendored
|
|
@ -1,6 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Added `@active-note` and `@selection` composer completions for bringing the current Obsidian editor context into a Codex turn, including selected Markdown text as additional turn context.
|
||||
- Added paste and drag-and-drop attachment support in the composer. Images are saved to the configured attachment folder as Obsidian embeds, while other files are inserted as wikilinks and sent to Codex as file mentions.
|
||||
- Improved selection rewrite diffs and fixed settings dynamic-section display issues.
|
||||
- Improved app-server diagnostics and cached metadata handling so transient probe failures and context changes do not wipe useful connection, tool, or thread information.
|
||||
4
.github/release-notes/4.0.1.md
vendored
4
.github/release-notes/4.0.1.md
vendored
|
|
@ -1,4 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Fixed wikilink completion so Tab keeps the cursor before the closing brackets for heading references, while Enter accepts the completed link.
|
||||
- Refreshed the README wording and screenshot.
|
||||
3
.github/release-notes/4.0.2.md
vendored
3
.github/release-notes/4.0.2.md
vendored
|
|
@ -1,3 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Added runtime permission details to the status panel, including the active profile, sandbox and network access, writable roots, approval policy, and reviewer.
|
||||
4
.github/release-notes/4.1.0.md
vendored
4
.github/release-notes/4.1.0.md
vendored
|
|
@ -1,4 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Added a `/permissions` slash command that shows the current permissions and approval settings.
|
||||
- Refined the status panel permissions display into Permissions and Approvals sections, with connection diagnostics labeled consistently.
|
||||
4
.github/release-notes/4.1.1.md
vendored
4
.github/release-notes/4.1.1.md
vendored
|
|
@ -1,4 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Fixed Windows connection failures when the configured Codex executable is an npm-installed command shim such as `codex.cmd`.
|
||||
- Refined runtime permission and approval status resolution for more consistent diagnostics.
|
||||
4
.github/release-notes/4.1.2.md
vendored
4
.github/release-notes/4.1.2.md
vendored
|
|
@ -1,4 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Let `/permissions` set the permission profile used for subsequent Codex turns, with composer completions for available profiles.
|
||||
- Updated app-server compatibility for Codex CLI 0.142.5.
|
||||
4
.github/release-notes/4.2.0.md
vendored
4
.github/release-notes/4.2.0.md
vendored
|
|
@ -1,4 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Added an opt-in **Reference active note on send** setting that references the current Obsidian note when sending from the composer without changing the prompt text.
|
||||
- Renamed sent-message context metadata to `Context · n items`, with implicit active-note context shown as `Active note · path/to/note.md`.
|
||||
4
.github/release-notes/4.3.0.md
vendored
4
.github/release-notes/4.3.0.md
vendored
|
|
@ -1,4 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Added `/clip <url> [message]` to save readable web page clippings as Markdown notes and send wikilink references from the composer.
|
||||
- Added web clipping settings for the saved note folder, filename template, and tags.
|
||||
5
.github/release-notes/4.3.1.md
vendored
5
.github/release-notes/4.3.1.md
vendored
|
|
@ -1,5 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Renamed active-note context to `@active` and updated related settings copy to refer to the active file consistently.
|
||||
- Improved thread matching for composer thread commands and thread search so title and ID matches rank more predictably.
|
||||
- Made saved vault paths for attachments, web clippings, and archived thread notes more consistent with their configured defaults.
|
||||
4
.github/release-notes/4.4.0.md
vendored
4
.github/release-notes/4.4.0.md
vendored
|
|
@ -1,4 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Add Obsidian tag completions in the composer after `#`.
|
||||
- Open Obsidian Search when clicking rendered tags in Codex Panel messages.
|
||||
5
.github/release-notes/4.4.1.md
vendored
5
.github/release-notes/4.4.1.md
vendored
|
|
@ -1,5 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Match Obsidian's tag index and global search behavior for tag completions and rendered tag clicks.
|
||||
- Show composer context as 0% before a thread starts instead of leaving it blank.
|
||||
- Harden saved Markdown output for web clips and archived threads with safer YAML frontmatter handling.
|
||||
6
.github/release-notes/4.5.0.md
vendored
6
.github/release-notes/4.5.0.md
vendored
|
|
@ -1,6 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Add an action to open subagent threads directly from agent summary messages.
|
||||
- Show reasoning effort descriptions in composer suggestions.
|
||||
- Update app-server compatibility for Codex CLI 0.143.0.
|
||||
- Polish toolbar and slash command wording, and fix composer suggestion matching.
|
||||
3
.github/release-notes/4.5.1.md
vendored
3
.github/release-notes/4.5.1.md
vendored
|
|
@ -1,3 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Polish chat panel wording around conversations, context, and composer guidance.
|
||||
6
.github/release-notes/4.5.2.md
vendored
6
.github/release-notes/4.5.2.md
vendored
|
|
@ -1,6 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Update app-server compatibility for Codex CLI 0.144.0 and improve reconnection, metadata, and MCP request handling.
|
||||
- Load thread history incrementally in Threads View while retaining complete thread-picker search results.
|
||||
- Fix web clips to save Defuddle's Markdown output and refresh collapsed-content controls after layout changes.
|
||||
- Prevent stale asynchronous UI updates after panels, settings, or the plugin close.
|
||||
4
.github/release-notes/4.6.0.md
vendored
4
.github/release-notes/4.6.0.md
vendored
|
|
@ -1,4 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Add `@today`, `@tomorrow`, and `@yesterday` composer completions that resolve through the configured Daily Notes or Periodic Notes folder and date format.
|
||||
- Improve `@active` and `@selection` suggestions with clearer note, range, and selected-text previews.
|
||||
3
.github/release-notes/4.6.1.md
vendored
3
.github/release-notes/4.6.1.md
vendored
|
|
@ -1,3 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Align DOM creation and daily-note date handling with Obsidian's bundled APIs for Community plugin review compatibility.
|
||||
5
.github/release-notes/4.7.0.md
vendored
5
.github/release-notes/4.7.0.md
vendored
|
|
@ -1,5 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Add `/btw` side chats that open an ephemeral conversation in a new panel without interrupting the source thread.
|
||||
- Open subagent threads as read-only panels and hide conversation actions that do not apply to them.
|
||||
- Improve reliability around Vault file destinations, web clipping, app-server connections, and asynchronous panel cleanup.
|
||||
4
.github/release-notes/4.7.1.md
vendored
4
.github/release-notes/4.7.1.md
vendored
|
|
@ -1,4 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Improve chat and side-chat stability during concurrent actions, panel restoration, and cleanup.
|
||||
- Prevent stale app-server data and rapid settings changes from overwriting newer state.
|
||||
5
.github/release-notes/5.0.0.md
vendored
5
.github/release-notes/5.0.0.md
vendored
|
|
@ -1,5 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Replace `/clip` with `/web <url> [message]` for temporary web context without saving a note. For durable archives—especially rendered or signed-in pages—use Obsidian Web Clipper and reference the saved note.
|
||||
- Improve hook controls so trusting a hook no longer enables it automatically, and only trusted unmanaged hooks can be enabled or disabled.
|
||||
- Prevent an in-flight automatic thread title from overwriting a newer manual rename.
|
||||
5
.github/release-notes/5.0.1.md
vendored
5
.github/release-notes/5.0.1.md
vendored
|
|
@ -1,5 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- You can now cancel an in-progress `/web` import and keep the original prompt to edit or resend it.
|
||||
- Changing the Codex executable in settings now reconnects open panels cleanly.
|
||||
- File links containing backticks are now preserved when exporting archived threads.
|
||||
5
.github/release-notes/5.0.2.md
vendored
5
.github/release-notes/5.0.2.md
vendored
|
|
@ -1,5 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Persistent threads now reconnect cleanly after temporary Codex disconnects or app-server setting changes, while temporary side chats no longer resume incorrectly.
|
||||
- Slash-command suggestions now immediately respect the restrictions for temporary side chats and subagent threads.
|
||||
- Thread rename and archive results are now kept within the Codex context where the operation started, preventing stale updates after settings change.
|
||||
3
.github/release-notes/5.0.3.md
vendored
3
.github/release-notes/5.0.3.md
vendored
|
|
@ -1,3 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Release assets now carry individually verifiable provenance attestations.
|
||||
3
.github/release-notes/5.0.4.md
vendored
3
.github/release-notes/5.0.4.md
vendored
|
|
@ -1,3 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Restored availability of downloadable install assets while upstream attestation verification catches up with GitHub's API changes.
|
||||
6
.github/release-notes/5.1.0.md
vendored
6
.github/release-notes/5.1.0.md
vendored
|
|
@ -1,6 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Added search to Codex Panel settings.
|
||||
- Large web, thread, selection, and vault references are now bounded predictably, report truncation when needed, and keep their context details after reopening a thread.
|
||||
- Paste and drop attachments now preserve their intended insertion point while files are being saved, with clearer composer editing state.
|
||||
- Improved reliability when switching threads, leaving running subagents, or changing runtime and app-server settings, preventing stale results and lost updates.
|
||||
6
.github/release-notes/5.1.1.md
vendored
6
.github/release-notes/5.1.1.md
vendored
|
|
@ -1,6 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Thread history now stays in sync across the chat toolbar, thread picker, and Threads view while loading older threads or refreshing.
|
||||
- Thread search now uses fuzzy title matching across the complete active history instead of matching thread IDs.
|
||||
- Running subagent summaries now show each agent's latest activity instead of only its initial status.
|
||||
- Opening the thread picker again now closes the previous picker, and accepted MCP links are preserved in conversation history.
|
||||
6
.github/release-notes/5.1.2.md
vendored
6
.github/release-notes/5.1.2.md
vendored
|
|
@ -1,6 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Thread rename and auto-name controls now prevent conflicting edits while work is in progress, and cancelling auto-name returns to the existing draft.
|
||||
- Actions with known unmet prerequisites are now disabled before execution, including unavailable auto-name and selection rewrite actions.
|
||||
- Thread operation errors no longer appear as entries in navigation lists.
|
||||
- Resumed conversations now hide Codex Panel's internal context metadata while preserving their context attachments.
|
||||
4
.github/release-notes/5.2.0.md
vendored
4
.github/release-notes/5.2.0.md
vendored
|
|
@ -1,4 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Thread references created with `/refer` now remain as readable, clickable links in conversation history and open the referenced thread in an available Codex Panel.
|
||||
- Opening an existing thread in a new panel is now faster because resume no longer waits for background metadata and thread-list refreshes.
|
||||
5
.github/release-notes/5.2.1.md
vendored
5
.github/release-notes/5.2.1.md
vendored
|
|
@ -1,5 +0,0 @@
|
|||
## Changes
|
||||
|
||||
- Updated compatibility for Codex CLI 0.145.0.
|
||||
- Subagent conversations that support replies can now be continued directly from their panel.
|
||||
- Fixed malformed links in exported thread archives.
|
||||
61
.github/workflows/check.yml
vendored
61
.github/workflows/check.yml
vendored
|
|
@ -11,76 +11,23 @@ permissions:
|
|||
|
||||
jobs:
|
||||
check:
|
||||
name: npm run check
|
||||
name: npm run check:ci
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect compatibility changes
|
||||
id: compatibility
|
||||
env:
|
||||
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
run: |
|
||||
if git diff --quiet "$BASE_SHA" "$HEAD_SHA" -- \
|
||||
.github/workflows/check.yml \
|
||||
README.md \
|
||||
manifest.json \
|
||||
package.json \
|
||||
package-lock.json \
|
||||
scripts/api-baseline.mjs \
|
||||
scripts/app-server-compatibility.mjs \
|
||||
scripts/generate-app-server-types.mjs \
|
||||
src/app-server/connection/compatibility.json \
|
||||
src/generated/app-server \
|
||||
versions.json; then
|
||||
echo "required=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "required=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 26
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Install recorded Codex CLI
|
||||
if: steps.compatibility.outputs.required == 'true'
|
||||
run: npm install --global "@openai/codex@$(node scripts/app-server-compatibility.mjs --tested-cli-version)"
|
||||
|
||||
- name: Check API baselines
|
||||
if: steps.compatibility.outputs.required == 'true'
|
||||
run: npm run api:baseline
|
||||
|
||||
- name: Check generated app-server bindings
|
||||
if: steps.compatibility.outputs.required == 'true'
|
||||
run: npm run generate:app-server-types:check
|
||||
|
||||
- name: Check pull request commit messages
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: npm run commitlint -- --from "$BASE_SHA" --to "$HEAD_SHA" --verbose
|
||||
|
||||
- name: Check pushed commit messages
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.before }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: npm run commitlint -- --from "$BASE_SHA" --to "$HEAD_SHA" --verbose
|
||||
|
||||
- name: Check release metadata
|
||||
run: npm run release:check
|
||||
|
||||
- name: Check
|
||||
run: npm run check
|
||||
run: npm run check:ci
|
||||
|
|
|
|||
34
.github/workflows/release.yml
vendored
34
.github/workflows/release.yml
vendored
|
|
@ -7,6 +7,8 @@ on:
|
|||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
|
@ -15,44 +17,34 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 26
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Install recorded Codex CLI
|
||||
run: npm install --global "@openai/codex@$(node scripts/app-server-compatibility.mjs --tested-cli-version)"
|
||||
|
||||
- name: Check API baselines
|
||||
run: npm run api:baseline
|
||||
|
||||
- name: Check generated app-server bindings
|
||||
run: npm run generate:app-server-types:check
|
||||
|
||||
- name: Check release version
|
||||
run: npm run release:check
|
||||
env:
|
||||
RELEASE_VERSION: ${{ github.ref_name }}
|
||||
|
||||
- name: Check release commit messages
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
PREVIOUS_TAG="$(node -p 'Object.keys(require("./versions.json")).at(-2)')"
|
||||
git rev-parse --verify "refs/tags/$PREVIOUS_TAG"
|
||||
npm run commitlint -- --from "$PREVIOUS_TAG" --to "$TAG" --verbose
|
||||
|
||||
- name: Run checks and build
|
||||
run: npm run check
|
||||
run: npm run check:ci
|
||||
|
||||
- name: Generate artifact attestations
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4
|
||||
with:
|
||||
subject-path: |
|
||||
main.js
|
||||
manifest.json
|
||||
styles.css
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,6 +1,4 @@
|
|||
node_modules/
|
||||
.pnpm-store/
|
||||
.stryker-tmp/
|
||||
.jj/
|
||||
.codex/
|
||||
main.js
|
||||
|
|
@ -9,6 +7,4 @@ data.json
|
|||
*.log
|
||||
.DS_Store
|
||||
dist/
|
||||
coverage/
|
||||
reports/
|
||||
tmp/
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
26
|
||||
8
.prettierignore
Normal file
8
.prettierignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules/
|
||||
.jj/
|
||||
.codex/
|
||||
main.js
|
||||
styles.css
|
||||
data.json
|
||||
dist/
|
||||
tmp/
|
||||
4
.prettierrc.json
Normal file
4
.prettierrc.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"printWidth": 140,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
13
AGENTS.md
13
AGENTS.md
|
|
@ -1,11 +1,6 @@
|
|||
This repository contains the Codex Panel Obsidian plugin.
|
||||
|
||||
## Working Principles
|
||||
|
||||
- Start from evidence. Reproduce defects before fixing them; for product changes, reconstruct the current workflow, user need, alternatives, and losses before deciding what should change. Treat theoretical failure modes and reviewer agreement as hypotheses, not evidence of meaningful user or operational harm.
|
||||
- Use design documents as context, not as a substitute for current behavior and user expectations. Prefer coherent user-facing behavior and clear ownership, and remove needless abstraction or obsolete compatibility instead of preserving it by default.
|
||||
- Treat implementation, tests, documentation, lint policy, and final history as one deliverable: inspect each for drift, but update only the durable contracts that changed. Documentation should record user-facing behavior, long-term design, or reusable workflow; do not add it merely to narrate an implementation change or duplicate tests and tooling.
|
||||
- Parallelize only substantial concerns that can be implemented and tested independently with little shared-file contention. Keep small, tightly coupled, sequential, or coordination-heavy work together.
|
||||
Jujutsu is the recommended local change-management workflow when available.
|
||||
|
||||
## What To Read
|
||||
|
||||
|
|
@ -15,8 +10,4 @@ This repository contains the Codex Panel Obsidian plugin.
|
|||
- Read `docs/release.md` for release preparation, release notes, preflight, tagging, pushing, and release repair.
|
||||
- Use the repo-local skills in `.agents/skills/` when a task matches a more specific workflow.
|
||||
|
||||
## Changes And Validation
|
||||
|
||||
- Jujutsu is the recommended local change-management workflow when available. Make each final change a coherent review unit with an honest description.
|
||||
- Before publishing, inspect and reorganize the graph when needed rather than preserving implementation chronology: normally fold corrective follow-ups into the concern they complete, split mixed changes, and keep a follow-up separate only when it remains meaningful on its own.
|
||||
- Use Conventional Commits for new commits and follow `docs/development.md` for repository rules and validation. Re-run relevant validation after history edits and before handoff or publication.
|
||||
When implementation, design, or workflow changes create drift from docs or lint policy, update the affected docs or lint settings in the same change and report that update.
|
||||
|
|
|
|||
68
README.md
68
README.md
|
|
@ -1,29 +1,27 @@
|
|||
# Codex Panel
|
||||
|
||||
Codex Panel brings Codex into an Obsidian sidebar. It keeps Codex threads beside your notes, helps you add vault context to prompts, and lets you handle approvals and file changes without switching windows.
|
||||
Codex Panel brings your existing Codex setup into an Obsidian sidebar, so you can work with Codex beside your notes without switching to a terminal or separate app window.
|
||||
|
||||
If the Codex CLI is already installed and authenticated, Codex Panel uses that local setup.
|
||||
|
||||

|
||||
If your Codex CLI or Codex.app environment is already configured, Codex Panel gives that setup a persistent Obsidian pane with note-aware links, threads, archives, and editor selections. It stays thin: runtime behavior continues to come from Codex, not from a separate plugin configuration layer.
|
||||
|
||||
## Why use it
|
||||
|
||||
- Keep Codex work next to the notes or projects it is about.
|
||||
- Bring notes, selections, links, and attachments into prompts.
|
||||
- Manage threads and respond to Codex requests inside Obsidian.
|
||||
- Review file changes, rewrite selected note text, and archive useful threads as notes.
|
||||
- Reuse your existing Codex configuration and credentials.
|
||||
- Work with persistent Codex threads in Obsidian panes, including multiple panels side by side and a sidebar thread list.
|
||||
- Compose with Obsidian wikilinks, rendered vault links, and Codex file mentions.
|
||||
- Review Codex file changes, rewrite selected note text, and archive useful threads without leaving the vault.
|
||||
|
||||
## How it works
|
||||
|
||||
Codex Panel starts `codex app-server` locally. Each open panel keeps its own active thread and draft, with Codex working from the vault root.
|
||||
Codex Panel runs `codex app-server` as a local child process and talks to it over stdio. Each open chat panel has its own app-server connection, active thread, pending requests, and composer draft. The vault root is passed to Codex as the working directory.
|
||||
|
||||
Codex Panel stores panel preferences only, not API keys or provider credentials.
|
||||
Models, reasoning defaults, sandboxing, approvals, MCP servers, hooks, skills, providers, network access, and thread history continue to come from Codex configuration. Codex Panel stores panel preferences only, not API keys or provider credentials. Data sent from the panel is handled by the configured Codex CLI according to your Codex configuration.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Obsidian desktop app 1.12.0 or newer.
|
||||
- Codex CLI installed, authenticated, and available as `codex`, or configured with an absolute executable path in Codex Panel settings.
|
||||
- A local vault where Codex is allowed to work.
|
||||
- A vault where Codex is allowed to work according to your Codex CLI configuration.
|
||||
|
||||
Codex Panel does not support Obsidian mobile.
|
||||
|
||||
|
|
@ -35,46 +33,48 @@ You can also open the plugin page directly: <https://community.obsidian.md/plugi
|
|||
|
||||
## Quick start
|
||||
|
||||
1. Open the note or project you want to work on.
|
||||
2. Run **Codex Panel: Open panel** from the command palette, or select the ribbon icon.
|
||||
3. In the composer, reference what matters and describe the outcome you want. For example: `@active Summarize this note and suggest the next three actions.`
|
||||
4. Follow the turn in the panel, respond to requests when needed, and review any file changes before continuing.
|
||||
1. Open the command palette and run **Codex Panel: Open panel**, or select the ribbon icon.
|
||||
2. If Obsidian cannot find `codex`, set **Settings -> Codex Panel -> Codex executable** to an absolute path such as `/opt/homebrew/bin/codex`.
|
||||
3. Ask Codex to work on the vault or project from the composer.
|
||||
4. Return to earlier work from **Codex Panel: Open thread...** or the Threads sidebar view.
|
||||
|
||||
If Obsidian cannot find `codex`, set **Settings -> Codex Panel -> Codex executable** to an absolute path such as `/opt/homebrew/bin/codex`.
|
||||
## Using Codex Panel
|
||||
|
||||
## Working with Codex Panel
|
||||
Each panel keeps its own active thread, so you can keep separate conversations side by side. Start a fresh chat when you need a clean thread, or reopen recent non-archived threads from the thread picker.
|
||||
|
||||
### Keep work separate or pick it up later
|
||||
The Threads sidebar gives you a vault-local view of Codex work in progress. It shows which threads are open, running, or waiting for input, and lets you resume, rename, auto-name, fork, roll back, compact, or archive threads. Forks open in a new right-sidebar pane so the source thread stays visible; fork-and-archive replaces the source panel with the forked thread.
|
||||
|
||||
A panel is an independent working surface with its own active thread and draft. Open multiple panels when different tasks should stay visible and separate. Regular Codex threads remain available after a panel closes; return to them from **Codex Panel: Open thread...**, panel history, or the Threads view.
|
||||
### Compose with vault context
|
||||
|
||||
For a one-off question alongside the current thread, use `/btw` to open a temporary side chat. To carry context between regular threads without merging their histories, use `/refer <thread> <message>`. The submitted message keeps a normal link to the referenced thread; clicking it opens the thread in an available Codex Panel, creating one when needed.
|
||||
The composer understands Obsidian and Codex context together. Wikilink suggestions come from Obsidian file search and recent notes; sent wikilinks resolve to Codex file mentions when possible; rendered vault file links open back in Obsidian.
|
||||
|
||||
### Bring in the right context
|
||||
Completions are also available for slash commands, enabled skills such as `$skill-name`, recent threads, models, and supported reasoning efforts. Use `/help` in the composer for the current slash command list. You can reference another non-archived thread without leaving the active panel, or use `/refer` when you want Codex to include recent turns from another thread in a message.
|
||||
|
||||
The composer lets you point Codex to relevant material without pasting it into the prompt. Type wikilinks to reference vault files while keeping their names readable. Use `@active` for the active file or `@selection` for the current Markdown selection.
|
||||
While a turn is running, the panel streams assistant messages, reasoning, commands, tool calls, hooks, file changes, plan updates, and agent activity. You can answer Codex questions, approve commands, respond to MCP and file requests, send steering messages, or interrupt the turn when the composer is empty. If a thread has an active Codex goal, the panel shows it above the message stream and lets you edit, pause, resume, or clear it.
|
||||
|
||||
When Obsidian Daily Notes or the daily section of Periodic Notes is enabled, `@today`, `@tomorrow`, and `@yesterday` resolve through its folder and date format. Paste or drop files to save them in the configured attachment folder and reference them from the same prompt. For material outside the vault, `/web <url> [message]` fetches readable page content and attaches it to the next turn without creating a note.
|
||||
### Review edits in Obsidian
|
||||
|
||||
### Guide a running turn
|
||||
Codex file changes can be reviewed in an Obsidian diff view with a changed-files list and copy-diff action.
|
||||
|
||||
As a turn runs, the panel keeps the conversation together with requests that need your attention. You can answer questions, approve or reject actions, steer the turn with another message, or interrupt it. Plans, goals, tool activity, and agent work remain available when you need to inspect how the task is progressing.
|
||||
For note-local edits, select Markdown text and run **Codex Panel: Rewrite selection**. Codex Panel sends the current editor buffer as context, receives a replacement, and shows a selection-scoped diff before applying it.
|
||||
|
||||
### Review and keep the result
|
||||
### Adjust a thread
|
||||
|
||||
When Codex changes files, open the Obsidian diff view to review the changes and copy the patch. For a focused edit, select Markdown text and run **Codex Panel: Rewrite selection** to request a replacement and review a selection-scoped diff before applying it.
|
||||
Use the toolbar, composer status row, or slash commands to inspect context usage and usage limits, toggle Plan mode, fast mode, and approval auto-review, and set model or reasoning effort for subsequent turns when the connected Codex version supports those controls.
|
||||
|
||||
Archive a finished thread with or without saving it as a Markdown note. Saved notes use the configured folder, filename template, and tags; archived threads can later be restored or permanently deleted from settings.
|
||||
When troubleshooting, use the toolbar status panel or diagnostic slash commands to inspect the active thread, connection, Codex CLI, app-server capabilities, discovered hooks, and runtime debug details.
|
||||
|
||||
Use `/help` for the current slash command list.
|
||||
### Keep useful threads as notes
|
||||
|
||||
Threads can be archived as Markdown notes in your vault with a configurable folder, filename template, and tags. You can also archive without saving, then restore or permanently delete archived threads from settings.
|
||||
|
||||
## Compatibility
|
||||
|
||||
| Key | Version | Policy |
|
||||
| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `manifest.minAppVersion` | `1.12.0` | Minimum Obsidian desktop version declared for plugin loading. |
|
||||
| `obsidian` API types | `1.12.3` | TypeScript API package used for compile-time checks; kept in the same minor as `manifest` baseline. |
|
||||
| `codexAppServer.testedCliVersion` | `0.145.0` | Exact CLI patch used to generate and verify bindings; compatibility is tracked by minor version. |
|
||||
| Key | Version | Policy |
|
||||
| ------------------------ | --------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `manifest.minAppVersion` | `1.12.0` | Minimum Obsidian desktop version declared for plugin loading. |
|
||||
| `obsidian` API types | `1.12.3` | TypeScript API package used for compile-time checks; kept in the same minor as `manifest` baseline. |
|
||||
| `codex.testedCliVersion` | `0.142.0` | Track app-server compatibility by Codex CLI minor version. |
|
||||
|
||||
Codex Panel depends on the experimental `codex app-server` API.
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 133 KiB |
221
biome.jsonc
221
biome.jsonc
|
|
@ -1,221 +0,0 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"plugins": [
|
||||
// Project-wide source shape and placement.
|
||||
{
|
||||
"path": "./scripts/grit/source-shape/no-handwritten-reexports.grit",
|
||||
"includes": ["**/*.{ts,tsx,mjs}"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/source-shape/no-responsibility-root-module-files.grit",
|
||||
"includes": ["**/src/{app-server,domain}/*.{ts,tsx}", "**/src/features/{chat,threads}/*.{ts,tsx}"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/source-shape/no-misplaced-tsx.grit",
|
||||
"includes": [
|
||||
"**/src/**/*.tsx",
|
||||
"!**/src/{settings,shared/ui}/**",
|
||||
"!**/src/features/chat/{panel,ui}/**",
|
||||
"!**/src/features/{selection-rewrite,threads-view,turn-diff}/*.dom.tsx",
|
||||
"!**/src/shared/dom/*.dom.tsx",
|
||||
"!**/src/shared/obsidian/*.obsidian.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/source-shape/no-self-referential-initializer-callback.grit",
|
||||
"includes": ["**/*.{ts,tsx,mjs}"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/source-shape/no-unsafe-iterator-value.grit",
|
||||
"includes": ["**/*.{ts,tsx,mjs}"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/source-shape/no-uncontrolled-preact-form-state.grit",
|
||||
"includes": ["**/src/**/*.tsx"]
|
||||
},
|
||||
|
||||
// Cross-cutting source-layer import boundaries.
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-domain-outer-layer-imports.grit",
|
||||
"includes": ["**/src/domain/**/*.ts", "**/src/features/chat/domain/**/*.ts"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-lower-level-feature-imports.grit",
|
||||
"includes": ["**/src/app-server/**/*.ts", "**/src/shared/**/*.{ts,tsx}"]
|
||||
},
|
||||
|
||||
// App-server and generated protocol boundaries.
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-app-server-subfolder-root-imports.grit",
|
||||
"includes": ["**/src/app-server/**/*.ts"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-app-server-connection-boundary-imports.grit",
|
||||
"includes": [
|
||||
"**/src/app-server/protocol/**/*.ts",
|
||||
"**/src/domain/**/*.ts",
|
||||
"**/src/shared/**/*.{ts,tsx}",
|
||||
"**/src/features/selection-rewrite/**/*.{ts,tsx}",
|
||||
"!**/src/features/selection-rewrite/app-server-transport.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-app-server-protocol-boundary-imports.grit",
|
||||
"includes": ["**/src/**/*.{ts,tsx}", "!**/src/app-server/**", "!**/src/features/chat/app-server/mappers/thread-stream/turn-items.ts"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-chat-turn-item-non-turn-protocol-imports.grit",
|
||||
"includes": ["**/src/features/chat/app-server/mappers/thread-stream/turn-items.ts"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-generated-app-server-boundary-imports.grit",
|
||||
"includes": [
|
||||
"**/src/**/*.{ts,tsx}",
|
||||
"!**/src/app-server/connection/**",
|
||||
"!**/src/app-server/protocol/server-requests.ts",
|
||||
"!**/src/app-server/protocol/thread.ts",
|
||||
"!**/src/app-server/protocol/turn.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/runtime/no-app-server-direct-rpcs.grit",
|
||||
"includes": ["**/src/**/*.{ts,tsx}", "!**/src/app-server/**", "!**/src/features/chat/app-server/**"]
|
||||
},
|
||||
// Workspace and feature coordination boundaries.
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-chat-workspace-boundary-imports.grit",
|
||||
"includes": ["**/src/features/chat/**/*.{ts,tsx}"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-feature-workspace-boundary-imports.grit",
|
||||
"includes": ["**/src/features/**/*.{ts,tsx}", "!**/src/features/chat/**"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-workspace-chat-internal-imports.grit",
|
||||
"includes": ["**/src/workspace/**/*.ts"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-thread-workflow-app-server-imports.grit",
|
||||
"includes": ["**/src/features/threads/workflows/**/*.ts"]
|
||||
},
|
||||
|
||||
// Chat feature layer boundaries.
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-chat-application-outer-layer-imports.grit",
|
||||
"includes": ["**/src/features/chat/application/**/*.ts"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-chat-app-server-outer-layer-imports.grit",
|
||||
"includes": ["**/src/features/chat/app-server/**/*.ts"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-chat-host-rendering-layer-imports.grit",
|
||||
"includes": ["**/src/features/chat/host/**/*.ts"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-chat-panel-runtime-boundary-imports.grit",
|
||||
"includes": ["**/src/features/chat/panel/**/*.{ts,tsx}"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-chat-presentation-outer-layer-imports.grit",
|
||||
"includes": ["**/src/features/chat/presentation/**/*.ts"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-chat-ui-outer-layer-imports.grit",
|
||||
"includes": ["**/src/features/chat/ui/**/*.{ts,tsx}"]
|
||||
},
|
||||
|
||||
// Chat state and runtime ownership.
|
||||
{
|
||||
"path": "./scripts/grit/runtime/no-state-module-side-effects.grit",
|
||||
"includes": ["**/src/features/chat/application/state/**/*.ts"]
|
||||
},
|
||||
|
||||
// DOM bridge and Preact root boundaries.
|
||||
{
|
||||
"path": "./scripts/grit/dom/no-implicit-dom-bridges.grit",
|
||||
"includes": ["**/src/**/*.{ts,tsx}", "!**/src/**/*.{dom,obsidian,measure}.{ts,tsx}"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/dom/no-dom-events-imports.grit",
|
||||
"includes": ["**/src/**/*.{ts,tsx}", "!**/src/**/*.{dom,obsidian,measure}.{ts,tsx}"]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/dom/no-preact-root-imports.grit",
|
||||
"includes": ["**/src/**/*.{ts,tsx}", "!**/src/**/*.{dom,obsidian}.{ts,tsx}"]
|
||||
},
|
||||
|
||||
// CSS source policy.
|
||||
{
|
||||
"path": "./scripts/grit/no-restricted-css-policy.grit",
|
||||
"includes": ["**/src/styles/**/*.css"]
|
||||
}
|
||||
],
|
||||
"files": {
|
||||
"includes": [
|
||||
"*.config.mjs",
|
||||
"*.config.ts",
|
||||
"*.css",
|
||||
"*.json",
|
||||
"*.jsonc",
|
||||
"*.mjs",
|
||||
"scripts/**/*.{json,jsonc}",
|
||||
"scripts/**/*.grit",
|
||||
"scripts/**/*.mjs",
|
||||
"src/**/*.{css,ts,tsx}",
|
||||
"tests/**/*.{mjs,ts,tsx}",
|
||||
"!src/generated"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"lineWidth": 140
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"noDuplicateClasses": "on",
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"domains": {
|
||||
"react": "recommended",
|
||||
"test": "recommended"
|
||||
},
|
||||
"rules": {
|
||||
"preset": "recommended",
|
||||
"complexity": {
|
||||
"useLiteralKeys": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"reportUnnecessaryDependencies": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"nursery": {
|
||||
"noExcessiveSelectorClasses": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"maxClasses": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
"style": {
|
||||
"noCommonJs": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noImportCycles": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export default {
|
||||
defaultIgnores: false,
|
||||
extends: ["@commitlint/config-conventional"],
|
||||
ignores: [(message) => /^Merge pull request #\d+ from \S+/.test(message)],
|
||||
rules: {
|
||||
"subject-case": [0],
|
||||
"type-enum": [2, "always", ["build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "test"]],
|
||||
},
|
||||
};
|
||||
|
|
@ -6,87 +6,61 @@ This document records durable design direction. User-facing behavior belongs in
|
|||
|
||||
## Product Boundary
|
||||
|
||||
Keep the panel thin. Codex Panel owns the Obsidian experience around Codex: panels, composing, vault-aware link handoff, approvals and user input, file-change review, archive export, panel preferences, diagnostics, and selection rewrite.
|
||||
Keep the panel thin. Codex Panel owns the Obsidian experience around Codex: panels, thread display, composing, approvals and user input, vault-aware link handoff, archive export, settings for panel preferences, diagnostics, and selection rewrite review.
|
||||
|
||||
Codex owns runtime behavior and thread state: models, credentials, sandboxing, approval policy, MCP servers, hooks, providers, agent-initiated network access, thread history, archived state, goals, and runtime settings. The panel should read and update that state through `codex app-server`, not redefine it.
|
||||
Codex owns runtime policy and thread truth. Model defaults, sandboxing, approvals, MCP servers, hooks, providers, network access, thread history, archived threads, effective configuration, goals, and runtime settings should come from Codex through `codex app-server`.
|
||||
|
||||
The panel may provide a narrow management surface when direct configuration of Codex-owned state is impractical and app-server still owns the semantics. Hook controls serve this role: help users recognize and manage hooks they added or changed without presenting the panel as a code-audit surface.
|
||||
|
||||
The panel may acquire prompt context through an explicit user action. `/web` fetches the requested page through Obsidian and attaches the extracted content as untrusted turn context; it is not a search surface or agent network policy.
|
||||
|
||||
Panel-acquired context remains reference material rather than current user authorization. Send its bounded payload as untrusted app-server context without adding Panel-owned metadata envelopes to Codex history. Apply physical byte and part limits at the app-server boundary; keep source-specific selection and truncation policy with the context producer. Keep read-only compatibility for envelopes written by older Panel versions isolated at history projection boundaries.
|
||||
|
||||
Vault file references are prompt handoff data, not durable thread metadata. Keep their display state local; ordinary wikilinks remain represented by the user-authored message text. Thread references persist as ordinary, human-readable `codex://threads/` Markdown links, while their bounded transcript payload remains ephemeral turn context.
|
||||
|
||||
Panel settings should store only panel-specific preferences. Do not mirror Codex configuration in Obsidian settings just to display or inspect it.
|
||||
Panel settings should store only panel-specific preferences. Do not duplicate Codex configuration into Obsidian settings just because a value is useful to display or inspect.
|
||||
|
||||
## Sources of Truth
|
||||
|
||||
The repository checkout is the source of truth for implementation, version control, and release work. Ignored release assets are generated outputs, even when Obsidian loads them at runtime.
|
||||
|
||||
`codex app-server` is the source of truth for Codex state. Panel-side caches exist to keep the UI stable across transient failures; failed reads or stale panels must not become authoritative empty state.
|
||||
|
||||
The app-server API is experimental. The project tracks the supported Codex CLI minor and favors a clean current flow over broad old-protocol compatibility.
|
||||
The app-server API is experimental. The project tracks the supported Codex CLI minor and favors a clean current flow over broad old-protocol compatibility. Current optional and nullable fields are still part of the contract and must be normalized before display.
|
||||
|
||||
Runtime controls should express visible user intent for the active thread rather than copy Codex configuration. Diagnostics should expose only actionable troubleshooting facts.
|
||||
Runtime settings should be panel-owned projections of user-visible controls and pending user intent, not mirrors of Codex runtime configuration. Codex-owned policy structures should stay at the app-server boundary unless the panel owns a concrete workflow for them. Diagnostics should include only actionable troubleshooting facts, not broad raw config snapshots kept because they are available. Transport semantics belong at the app-server boundary; config values should not be treated as synonymous with transport payload values.
|
||||
|
||||
An app-server context is the pair of Codex executable and Vault root. Replacing it must invalidate context-bound connections and work before publishing the new context, keep events attributed to their source context, and discard old runtime metadata. Preserve last-known-good state only across transient failures within the same context.
|
||||
|
||||
Vault root is the Panel-owned workspace root for that context. Panel operations always use it as the thread working directory and do not project mutable protocol thread cwd values into panel state.
|
||||
Fast mode is a panel runtime intent, not an arbitrary service tier override. The app-server still owns service tier ids and defaults, so the panel should express Fast mode through the app-server's supported runtime setting rather than redefining tier semantics itself.
|
||||
|
||||
## Code Boundaries
|
||||
|
||||
Raw app-server protocol belongs at the app-server boundary. Boundary code should adapt protocol payloads into panel-owned domain models or small projections before those values reach features, workspace coordination, settings, or UI.
|
||||
|
||||
Application workflows should depend on feature-owned contracts rather than app-server clients, RPC details, connection checks, vault-path wiring, or raw protocol projections.
|
||||
Generated app-server protocol types should stay behind `src/app-server/`. Services at that boundary adapt protocol payloads into panel-owned domain models or small projections before data reaches features, workspace coordination, settings, or UI.
|
||||
|
||||
Turn stream conversion is the main exception: raw app-server stream payloads may be consumed at the conversion boundary because the event set is broad and changes with Codex. The converter should still reduce them into panel-owned display and diagnostic models before they reach chat state or UI.
|
||||
|
||||
Server request adapters should turn app-server requests into coarse panel models before they reach pending request state. The UI handles user-facing intent; app-server-specific decisions and response payloads stay boundary-owned.
|
||||
Server request adapters should normalize method-specific app-server requests into coarse panel models before they reach pending request state. The UI should handle user-facing intent; app-server-specific decisions and response payloads should remain boundary-owned.
|
||||
|
||||
Source modules should be organized by reason to change, not by the single Obsidian plugin entrypoint. Boundaries should stay close to the state, lifecycle, or external API they own.
|
||||
|
||||
Feature-to-feature imports are acceptable when the imported feature owns a real capability. Generic helpers belong in `src/shared/`, generated-independent meaning belongs in `src/domain/`, and protocol adaptation belongs in `src/app-server/`.
|
||||
|
||||
Do not hide complexity behind forwarding layers. Add an abstraction only when it owns a lifecycle, boundary, state transition, or reusable domain capability.
|
||||
|
||||
## UI Ownership
|
||||
|
||||
Runtime UI composition is Preact-owned. Preact components should render the panel shell, toolbar, thread stream, composer, and request controls.
|
||||
Runtime UI composition is Preact-owned. Preact components should render the panel shell, toolbar, message stream, composer, and request controls.
|
||||
|
||||
Obsidian and app-server boundaries stay outside Preact components. External lifecycles, app-server connections, editor/workspace APIs, and rendering bridges belong in boundary modules.
|
||||
Obsidian and app-server boundaries stay outside Preact components. `ItemView` classes, plugin lifecycle, app-server connections, controllers, `MarkdownRenderer`, editor APIs, notices, and workspace operations belong in boundary modules.
|
||||
|
||||
Chat-visible state should have one authoritative owner. Components should consume narrow projections of that state rather than mirror it into another reactive store.
|
||||
Chat-visible state belongs in `ChatStateStore` and named reducer actions. Signals and components may project that state, but they should not become parallel sources of truth for turns, pending requests, runtime settings, history cursors, or open details.
|
||||
|
||||
TanStack Query is the single panel-side owner of cached app-server resources. Features may project authoritative event results into that state, but should not introduce parallel cache or synchronization mechanisms. Partial read models are reconciled at explicit lifecycle boundaries rather than kept globally and continuously consistent.
|
||||
Preact Signals are a shell-local projection adapter, not a second state system. Surface projections should read narrow shell-state contracts instead of making components or presenters depend on broad reducer slices. Domain, application, host, presentation, and component modules should keep using pure selectors, reducer actions, and explicit props rather than importing signals directly.
|
||||
|
||||
Thread lifecycle changes should be projected from completed operation facts into the shared read model. When one user action changes multiple visible projections, publish them coherently without delaying live operational state or coupling independent panels.
|
||||
|
||||
Reads with different completeness requirements have separate lifecycles. Complete operation-local reads must not replace bounded shared history, and transient activity should come from its owning live state rather than forcing history refreshes.
|
||||
|
||||
Imperative UI bridges are allowed only where the host platform requires them. They should remain narrow boundary adapters, not a second UI composition system inside Preact-owned surfaces.
|
||||
Imperative DOM bridges are allowed when an external API or measurement problem requires an `HTMLElement`. They should not become a second UI composition system inside Preact-owned surfaces.
|
||||
|
||||
## Interaction Principles
|
||||
|
||||
Multiple panels are separate Obsidian leaves. Treat each panel as its own Codex working surface with independent connection, thread, turn state, composer, and pending requests.
|
||||
|
||||
Long-running actions must preserve the user intent that started them. Panel-local results may affect a panel only while that intent still owns its target; Codex facts completed in the current app-server context remain shared truth even if the initiating panel has moved on.
|
||||
|
||||
Coordinate conflicting work at the narrowest shared semantic owner. Independent panels should not block one another, and stale reads or writes must not overwrite newer shared state. Coalesce work only when replacement is part of the operation's meaning.
|
||||
|
||||
Foreground reveal and focus are the narrow workspace-wide exception: the latest user intent wins without serializing unrelated panel or app-server work. Cleanup obligations created by a committed state transition must survive the UI action that initiated them.
|
||||
|
||||
Do not use explanatory copy as the default remedy for an unclear or incoherent interface. Before adding instructions, warnings, status text, validation messages, confirmations, or diagnostics, determine why the interface does not communicate the necessary behavior through its structure and state. Prefer improving information hierarchy, labels, defaults, available choices, affordances, validation timing, progress indication, and feedback. Add text only when irreducible information remains and it materially helps the user decide, act, or recover; do not expose implementation concepts merely because they explain the current behavior.
|
||||
|
||||
Subagent threads opened from agent activity remain persistent and restorable but stay outside ordinary thread history. Use the app-server direct-input capability when available, falling back to read-only subagent panels for older servers, while preserving parent and agent provenance for other specialized behavior.
|
||||
|
||||
Mode-derived restrictions for the active panel thread must remain consistent across actions and UI. Keep connection state, turn busy state, and operations targeting another listed thread in their owning workflows.
|
||||
|
||||
Thread history and other app-server resources should follow app-server semantics. Panel-side views are read models over app-server snapshots and lifecycle events; stale or partial refreshes must not overwrite newer state. Obsidian integrations such as archive note export are convenience views, not replacements for Codex history.
|
||||
|
||||
Routine thread lists should load a bounded recent set and paginate older threads on demand instead of eagerly fetching complete inventories.
|
||||
Thread history, archived state, forks, and catalog data should follow app-server semantics. Obsidian integrations such as archive note export are convenience views of Codex state, not replacements for Codex history.
|
||||
|
||||
Selection rewrite is intentionally scoped to a focused edit-and-review workflow. Avoid expanding it into a broader writing assistant without a separate design decision.
|
||||
|
||||
Server requests should become panel UI only when the user can naturally answer them in context. Unknown or unsupported requests should stay diagnostic instead of pretending to be normal conversation text.
|
||||
|
||||
Thread stream display should separate primary conversation from diagnostic detail and progress/status. Preserve stable item identity across history, streaming, and rendering updates.
|
||||
Message stream display should separate primary conversation from diagnostic detail and progress/status. Preserve stable item identity across history, streaming, and rendering updates.
|
||||
|
||||
Codex Panel UI should feel native inside Obsidian. Prefer Obsidian variables, standard classes, and side-panel patterns. Add custom visual treatment only when Codex-specific state would otherwise be hard to read.
|
||||
|
||||
|
|
@ -94,8 +68,8 @@ Codex Panel UI should feel native inside Obsidian. Prefer Obsidian variables, st
|
|||
|
||||
Tests should protect user expectations, app-server/panel responsibility boundaries, and state-transition invariants.
|
||||
|
||||
Prefer tests for visible behavior and state-transition invariants across panels, threads, requests, streams, and display fallbacks.
|
||||
Prefer tests for visible behavior: independent panels, thread-scoped resets, pending request handling, approval flows, readable transcript/detail/status grouping, scroll preservation, and display fallbacks for structured app-server values.
|
||||
|
||||
Avoid tests that freeze incidental implementation details unless those details directly protect a user-visible invariant.
|
||||
Avoid tests that freeze incidental implementation details such as exact DOM nesting, render counts, node reuse, helper decomposition, or no-op array updates unless those details directly protect a user-visible invariant.
|
||||
|
||||
Panel tests may define how received structured values are displayed, retained, or normalized. They should not redefine Codex-owned runtime policy, model lists, sandbox behavior, approval policy, or thread history semantics.
|
||||
|
|
|
|||
|
|
@ -6,42 +6,16 @@ Use this document for day-to-day implementation mechanics: commands, generated f
|
|||
|
||||
```sh
|
||||
npm ci
|
||||
npm run fix
|
||||
npm run format
|
||||
npm run check
|
||||
npm run test:coverage
|
||||
npm run test:mutation
|
||||
npm run build
|
||||
```
|
||||
|
||||
Use the Node.js version in `.node-version`.
|
||||
|
||||
Use `npm run fix` for mechanical cleanup, review its diff, and run `npm run check` before handoff.
|
||||
|
||||
Use `npm run test:coverage` to identify source modules and branches that lack exercised behavior. It reports every authored TypeScript source file, including files not imported by tests, while excluding generated app-server bindings. Open `coverage/index.html` to inspect line-level gaps. Coverage is diagnostic and has no pass/fail threshold; prioritize user-visible behavior and state-transition invariants rather than raising the aggregate percentage.
|
||||
|
||||
Use `npm run test:mutation` for an exploratory mutation test of domain-owned logic, app-server protocol normalization, thread-stream event mapping, and the chat root reducer. Review surviving mutants individually instead of treating the aggregate score as a quality gate: add tests for meaningful behavior gaps, simplify equivalent or redundant code, and leave mutants alone when neither change improves the durable contract. The run ignores expensive module-initialization mutants, is intentionally manual, and writes its ignored HTML report to `reports/mutation/mutation.html`.
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Use [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) for new commits:
|
||||
|
||||
```text
|
||||
feat(composer): add daily note context suggestions
|
||||
fix: prevent manual titles from being overwritten
|
||||
```
|
||||
|
||||
Use one of `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, or `test`. Scopes are optional. Keep the description concise and omit a trailing period; capitalization is unrestricted. Use `!` before the colon or a `BREAKING CHANGE:` footer for a disruptive change.
|
||||
|
||||
CI checks commits introduced by pull requests and direct pushes; GitHub-generated pull-request merge commits are exempt. To check a range locally, run `npm run commitlint -- --from <base> --to <head> --verbose`.
|
||||
|
||||
Use focused scripts while iterating, but run `npm run check` before handoff. CI and release preflight use the same check.
|
||||
|
||||
Keep rule suppressions local and include the Obsidian-specific reason when a native Obsidian UI pattern intentionally diverges from a generic browser rule.
|
||||
|
||||
Grit policy cases protect matcher semantics rather than diagnostic wording, source locations, or the exact shape of Biome configuration. Keep one valid and invalid source case for each policy, and change a case only when that policy's accepted or rejected source shape intentionally changes.
|
||||
Run `npm run format` after edits and before `npm run check` so Prettier-only issues are fixed upfront. `npm run check` runs TypeScript type checking, unit tests, lint checks, Prettier check, CSS verification, and the production esbuild bundle. `npm run check:ci` uses the same checks without local caches.
|
||||
|
||||
## Generated and Loaded Files
|
||||
|
||||
`main.js`, `styles.css`, `data.json`, and `node_modules/` are ignored by Git. `main.js` and `styles.css` are still the files Obsidian loads, so run `npm run build` before live Obsidian validation if you have not already run `npm run check` after the source change.
|
||||
`main.js`, `styles.css`, `data.json`, and `node_modules/` are ignored by Git. `main.js` and `styles.css` are still the files Obsidian loads, so run `npm run build` after source changes.
|
||||
|
||||
CSS is authored in `src/styles/` and generated into the ignored root `styles.css` release asset. Use `npm run build:styles` when only regenerating CSS; it also verifies the authored CSS order before writing `styles.css`.
|
||||
|
||||
|
|
@ -49,45 +23,61 @@ The app-server TypeScript bindings in `src/generated/app-server/` are generated
|
|||
|
||||
```sh
|
||||
npm run generate:app-server-types
|
||||
npm run generate:app-server-types:check
|
||||
npm run check
|
||||
```
|
||||
|
||||
Do not hand-edit the bindings. `src/app-server/connection/compatibility.json` records their CLI patch and generation arguments; the check command regenerates and compares them without replacing tracked files.
|
||||
The generation script uses `codex app-server generate-ts --experimental` because the panel depends on experimental app-server fields. Do not hand-edit generated bindings.
|
||||
|
||||
## Source Layout
|
||||
|
||||
The source tree is organized by implementation ownership:
|
||||
|
||||
- `src/main.ts` registers Obsidian views, commands, settings, and lifecycle hooks.
|
||||
- `src/app-server/` owns the app-server boundary, protocol adapters, connection/client code, app-server services, and generated payload adaptation.
|
||||
- `src/features/chat/` owns the main Codex chat surface, including panel orchestration, app-server event handling, composer behavior, chat state, message display, pending requests, runtime controls, and thread actions. `src/features/chat/app-server/` is chat-local integration code that routes app-server projections into chat-owned state and display models.
|
||||
- `src/features/thread-picker/` owns the Obsidian thread picker modal and its thread search/opening behavior.
|
||||
- `src/features/threads/` contains thread-related services shared by chat, the thread picker, and the Threads view, including archive export settings, thread operations, and title generation.
|
||||
- `src/features/threads-view/` owns the dedicated Obsidian thread list view, including app-server thread list rendering and live open-panel snapshot aggregation.
|
||||
- `src/features/selection-rewrite/` owns the Markdown editor selection rewrite command, popover, prompt/output handling, and ephemeral rewrite thread runner.
|
||||
- `src/workspace/` owns Obsidian workspace coordination across Codex surfaces, including panel discovery/opening, open-panel snapshots, and thread surface broadcasts.
|
||||
- `src/shared/` contains feature-neutral helpers, including reusable DOM pieces and unified diff display helpers shared by chat and selection rewrite.
|
||||
- `src/settings/` contains Obsidian settings models, settings-tab rendering, and app-server-backed dynamic settings data.
|
||||
- `src/domain/` contains panel-owned, generated-independent meaning models and pure derivations shared by app-server, features, workspace, and settings code. Domain code must not import app-server protocol, generated bindings, feature modules, UI, or Obsidian APIs.
|
||||
- `src/styles/` contains the authored CSS modules and `order.json` concatenation order. `scripts/build-styles.mjs` concatenates them into the ignored root `styles.css`, which remains the Obsidian release asset.
|
||||
|
||||
## Placement Rules
|
||||
|
||||
Follow the ownership boundaries in `docs/design.md` and the import and source-shape lint rules. Put behavior where its reason to change lives, and update the lint policy and its tests when intentionally changing a boundary.
|
||||
Keep new code near the state or API it owns. A feature may import another feature only for a capability that feature owns. Feature-neutral behavior belongs in `src/shared/`, `src/domain/`, or `src/app-server/`.
|
||||
|
||||
Keep generated protocol types behind app-server adapters and expose panel-owned contracts or projections to features. Keep direct DOM work in explicit bridge, measurement, or rendering modules; use the established filename suffixes for those boundaries and reserve `.tsx` for rendering.
|
||||
Generated app-server types should stay behind `src/app-server/` or chat-local app-server integration modules. If a domain, shared, settings, workspace, or UI module needs app-server data, add or reuse a panel-owned projection at the boundary instead of importing generated payload types directly.
|
||||
|
||||
## CSS Rules
|
||||
Chat panel-visible state belongs in `ChatStateStore` and should flow through named reducer actions and the shell-state adapter. Use Preact Signals only in `src/features/chat/panel/shell-state.tsx`; lint enforces this boundary. When a surface needs fewer dependencies, add or reuse a named shell-state projection instead of importing `@preact/signals` elsewhere.
|
||||
|
||||
CSS should stay native to Obsidian. Prefer Obsidian variables and Codex Panel tokens for color, typography, spacing, and layout dimensions instead of hardcoded values.
|
||||
|
||||
Keep selectors shallow and scoped to Codex Panel classes. Add new authored CSS files to `src/styles/order.json`, and remove unused or test-only classes.
|
||||
Use imperative DOM writes only for explicit bridge modules, Obsidian-owned API boundaries, or rendering and measurement code that cannot be expressed cleanly as Preact components.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
Name modules by owned responsibility. Use lifecycle or boundary nouns only when the object owns that lifecycle or boundary, and passive-data names for values.
|
||||
Name modules by responsibility: use `Controller` only for stateful lifecycle/control surfaces, `Handler` for inbound event or request entrypoints, `Actions` for caller-facing command or callback bundles without lifecycle ownership, `Coordinator` for stateful background or cross-surface coordination, `Service` for reusable domain capabilities, `Presenter` for UI state projection, `Renderer` for render-only UI contracts, and `Host`/`Ports` for dependency boundary objects. Use `State`, `Snapshot`, `Projection`, `ViewModel`, `Options`, `Context`, `Result`, `Target`, `Capabilities`, or `ActionTargets` for data/value objects; do not use `Actions` for passive data. Use boundary/infrastructure nouns such as `Client`, `Transport`, `Cache`, `Store`, `Catalog`, `Manager`, `Bridge`, `Tracker`, `Session`, `Runtime`, `Provider`, or `Adapter` only when the object owns that concrete boundary or lifecycle role.
|
||||
|
||||
Prefer functions and factories. Reserve classes for mutable resource ownership, external class APIs, and `Error` types.
|
||||
Prefer functions and factory-created objects over classes. Use a class only when it owns mutable lifecycle/resource state, extends or implements an external class-based API such as Obsidian views/modals/tabs, or represents an `Error`; do not use a class merely to group pure functions or dependencies.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Build before Obsidian validation. Obsidian loads ignored root assets, not the TypeScript or authored CSS sources directly.
|
||||
- Keep generated app-server types behind `src/app-server/` unless `docs/design.md` documents a boundary exception.
|
||||
- Preserve last-known-good app-server state on refresh failure. Do not turn disconnected reads into authoritative empty thread lists, settings snapshots, hook inventories, or diagnostics.
|
||||
- Normalize optional and nullable app-server values before display. Users should not see raw `undefined`, `null`, protocol enum gaps, or fallback labels that imply Panel owns a Codex runtime setting.
|
||||
- Do not use DOM order as thread history state. Thread stream DOM is a presentation surface, and delayed Markdown rendering can change heights after initial render.
|
||||
- Route chat-visible state through `ChatStateStore` when it must synchronize with app-server notifications, turns, pending requests, runtime settings, history cursors, or open details.
|
||||
- Do not use DOM order as message history state. Message stream DOM is a presentation surface, and delayed Markdown rendering can change heights after initial render.
|
||||
|
||||
## API Baselines
|
||||
|
||||
Use the local API baseline report when checking whether the development environment matches the supported API policy:
|
||||
|
||||
```sh
|
||||
npm run api:baseline
|
||||
```
|
||||
|
||||
Obsidian runtime compatibility is declared through `manifest.json` and `versions.json`. The `obsidian` npm package provides compile-time TypeScript API definitions; it is not runtime validation for an Obsidian app-version matrix. Because the project does not run app-version smoke tests, keep the API type package in the same minor as `manifest.minAppVersion` and use the latest patch in that minor for local type checking. `npm run api:baseline` exits non-zero when the local environment or recorded baselines drift. Raise `manifest.minAppVersion` only when intentionally adopting a newer Obsidian app/API minor.
|
||||
|
||||
Codex app-server compatibility is managed by CLI minor version. `src/app-server/connection/compatibility.json` is the source of truth for the exact generation patch and app-server capabilities; README displays that patch.
|
||||
|
||||
Local compatibility work should run `npm run api:baseline` and `npm run generate:app-server-types:check`; CI runs the same verification with the recorded CLI.
|
||||
Codex app-server compatibility is managed by Codex CLI minor version. README records the tested Codex CLI patch version, and the baseline check verifies that the local `codex --version` is in the same minor before app-server binding or compatibility work.
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@ GitHub Releases attach only `main.js`, `manifest.json`, and `styles.css` as Obsi
|
|||
|
||||
Release work is Jujutsu-first in a colocated Git repository; Git is still used to push the tag that triggers the GitHub Release workflow. If the checkout has not been initialized for Jujutsu yet, run `jj git init --colocate` and `jj bookmark track main --remote=origin` once.
|
||||
|
||||
Plugin versions are for Obsidian distribution, not a library API compatibility contract. Use patch releases for workflow-preserving changes, minor releases for visible capabilities or raised runtime baselines, and major releases for disruptive workflow, settings, storage, or support-policy changes.
|
||||
Plugin versions use SemVer-shaped numbers for Obsidian distribution, but they are not a library API compatibility contract. Prefer patch releases for fixes, dependency updates, internal changes, and compatibility refreshes that preserve existing workflows. Prefer minor releases for user-visible capabilities, settings, workflow additions, or supported-runtime baseline changes. Reserve major releases for disruptive workflow, settings, storage, or support-policy changes.
|
||||
|
||||
Create a release by preparing the next version, reviewing and editing the generated release notes, committing the release changes, then running the preflight before pushing the matching tag:
|
||||
Create a release by preparing the next version, editing the generated release notes, committing the release changes, then running the preflight before pushing the matching tag:
|
||||
|
||||
```sh
|
||||
npm run release:prepare -- X.Y.Z
|
||||
# Review and edit .github/release-notes/X.Y.Z.md.
|
||||
# Edit .github/release-notes/X.Y.Z.md.
|
||||
jj status
|
||||
jj commit -m "chore(release): X.Y.Z"
|
||||
jj commit -m "Bump version to X.Y.Z"
|
||||
jj bookmark move main --to @-
|
||||
npm run release:preflight
|
||||
jj tag set X.Y.Z -r main
|
||||
|
|
@ -20,10 +20,8 @@ jj git push --remote origin --bookmark main
|
|||
git push origin X.Y.Z
|
||||
```
|
||||
|
||||
`release:prepare` updates version files and drafts `## Changes` from selected Conventional Commits since the previous version. Use those commits to locate candidate changes, then compare the resulting user-visible behavior with the previous release before choosing the version. Review the draft against the full diff for complete, user-facing notes; consolidate and reorder entries as needed, and replace an empty placeholder.
|
||||
|
||||
Run `release:preflight` after the release commit is on `main`; it validates the commit range and release readiness.
|
||||
`release:prepare` updates the version files and creates a `## Changes` release notes template. `release:preflight` verifies the local Jujutsu/Git state, release metadata, API baselines, lockfile, and the same non-cached `npm run check:ci` validation used by CI after the release commit is on `main`.
|
||||
|
||||
Release notes should normally include only user-facing changes. Internal implementation changes, validation details, and release procedure notes should be omitted when minor; when they are important enough to mention, group them into at most one concise bullet.
|
||||
|
||||
The release notes file must contain a single `## Changes` section. The tag-triggered workflow validates and publishes the install assets. If it fails before creating the GitHub Release, fix the commit, move the local tag with `jj tag set --allow-move -r main X.Y.Z`, then update the remote tag with `git push --force origin X.Y.Z`.
|
||||
The release workflow runs `npm ci`, `npm run release:check`, `npm run check:ci`, attaches the install assets, and generates GitHub artifact attestations for them. The release notes file is required and must contain a single `## Changes` section. If a tag-triggered release fails before creating the GitHub Release, fix the commit, move the local tag with `jj tag set --allow-move -r main X.Y.Z`, then update the remote tag with `git push --force origin X.Y.Z`.
|
||||
|
|
|
|||
|
|
@ -1,34 +1,266 @@
|
|||
import json from "@eslint/json";
|
||||
import js from "@eslint/js";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import { PlainTextParser } from "eslint-plugin-obsidianmd/dist/lib/plainTextParser.js";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import codexPanelEslintPlugin from "./scripts/lint/eslint-plugin-codex-panel.mjs";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const sourceTypeScriptFiles = ["src/**/*.{ts,tsx}"];
|
||||
const typeScriptFiles = ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"];
|
||||
const nodeJavaScriptFiles = ["*.mjs", "scripts/**/*.mjs"];
|
||||
const typeScriptConfigFiles = ["*.config.ts"];
|
||||
const lintedTypeScriptFiles = [...typeScriptFiles, ...typeScriptConfigFiles];
|
||||
const unsafeTypeScriptRules = {
|
||||
"@typescript-eslint/no-unsafe-argument": "error",
|
||||
"@typescript-eslint/no-unsafe-assignment": "error",
|
||||
"@typescript-eslint/no-unsafe-call": "error",
|
||||
"@typescript-eslint/no-unsafe-member-access": "error",
|
||||
};
|
||||
const projectTypeScriptRules = {
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
...unsafeTypeScriptRules,
|
||||
"react-hooks/exhaustive-deps": "error",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
};
|
||||
const testTypeScriptRelaxations = {
|
||||
"@typescript-eslint/no-deprecated": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "off",
|
||||
"@typescript-eslint/require-await": "off",
|
||||
};
|
||||
const preactFormRestrictions = [
|
||||
{
|
||||
selector: "JSXAttribute[name.name=/^(defaultValue|defaultChecked)$/]",
|
||||
message: "Keep Preact form state explicit with controlled value or checked props.",
|
||||
},
|
||||
];
|
||||
const removedChatStateEscapeHatchRestrictions = [
|
||||
{
|
||||
selector: "Literal[value='state/patched']",
|
||||
message: "Use a named ChatAction instead of reintroducing the generic state patch escape hatch.",
|
||||
},
|
||||
];
|
||||
const chatSignalAdapterRestrictions = [
|
||||
{
|
||||
selector: "ImportDeclaration[source.value='@preact/signals']",
|
||||
message: "Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
|
||||
},
|
||||
];
|
||||
const chatDomainLayerRestrictions = [
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value=/^(?:\\.\\.\\/)+(?:app-server|application|host|panel|presentation|ui)(?:\\/|$)|^src\\/features\\/chat\\/(?:app-server|application|host|panel|presentation|ui)(?:\\/|$)/]",
|
||||
message:
|
||||
"Keep chat/domain as Panel-owned meaning models and pure derivations; app-server, application, host, panel, presentation, and UI layers may depend on domain, not the reverse.",
|
||||
},
|
||||
];
|
||||
const uiRootImportRestrictions = [
|
||||
{
|
||||
selector: "ImportDeclaration[source.value=/shared\\/ui\\/ui-root$/]",
|
||||
message: "Import the Preact root adapter only from explicit root bridge files.",
|
||||
},
|
||||
];
|
||||
const generatedAppServerSourceImportPatterns = importBoundaryPatterns("generated/app-server", "src/generated/app-server", 6);
|
||||
const generatedAppServerTestImportPatterns = importBoundaryPatterns("src/generated/app-server", "src/generated/app-server", 6);
|
||||
const lowerLevelFeatureImportPatterns = importBoundaryPatterns("features", "src/features", 6);
|
||||
const appServerConnectionImportPatterns = importBoundaryPatterns("app-server/connection", "src/app-server/connection", 6);
|
||||
const appServerProtocolConnectionImportPatterns = [...appServerConnectionImportPatterns, "../connection", "../connection/**"];
|
||||
const nonAppServerBannedAppServerProtocolModules = [
|
||||
"catalog",
|
||||
"diagnostics",
|
||||
"file-change",
|
||||
"initialization",
|
||||
"request-input",
|
||||
"runtime-config",
|
||||
"runtime-metrics",
|
||||
"runtime-policy",
|
||||
"server-requests",
|
||||
"thread",
|
||||
"thread-goal",
|
||||
"thread-settings",
|
||||
"turn",
|
||||
"turn-history",
|
||||
];
|
||||
const nonAppServerBannedAppServerProtocolImportPatterns = nonAppServerBannedAppServerProtocolModules.flatMap((moduleName) =>
|
||||
importBoundaryPatterns(`app-server/protocol/${moduleName}`, `src/app-server/protocol/${moduleName}`, 6),
|
||||
);
|
||||
const chatAppServerProtocolBoundaryBannedModules = nonAppServerBannedAppServerProtocolModules.filter((moduleName) => moduleName !== "turn");
|
||||
const chatAppServerProtocolBoundaryBannedImportPatterns = chatAppServerProtocolBoundaryBannedModules.flatMap((moduleName) =>
|
||||
importBoundaryPatterns(`app-server/protocol/${moduleName}`, `src/app-server/protocol/${moduleName}`, 6),
|
||||
);
|
||||
const chatAppServerRequestBoundaryBannedModules = nonAppServerBannedAppServerProtocolModules.filter(
|
||||
(moduleName) => moduleName !== "server-requests",
|
||||
);
|
||||
const chatAppServerRequestBoundaryBannedImportPatterns = chatAppServerRequestBoundaryBannedModules.flatMap((moduleName) =>
|
||||
importBoundaryPatterns(`app-server/protocol/${moduleName}`, `src/app-server/protocol/${moduleName}`, 6),
|
||||
);
|
||||
const generatedAppServerThreadImportRestrictions = [
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value=/generated\\/app-server\\/v2\\/Thread$/] ImportSpecifier[imported.name='Thread'][local.name='Thread']",
|
||||
message: "Import generated app-server Thread as AppServerThread, or use the Panel-owned domain Thread model.",
|
||||
},
|
||||
];
|
||||
const turnProtocolGeneratedImportRestrictions = [
|
||||
{
|
||||
selector: "ImportDeclaration[source.value=/generated\\/app-server\\//]:not([source.value=/generated\\/app-server\\/v2\\/ThreadItem$/])",
|
||||
message: "Only generated ThreadItem is allowed in the turn protocol exception. Model other turn payload shapes locally.",
|
||||
},
|
||||
];
|
||||
const serverRequestsProtocolGeneratedImportRestrictions = [
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value=/generated\\/app-server\\//]:not([source.value=/generated\\/app-server\\/(?:RequestId|ServerRequest)$/])",
|
||||
message: "Only generated RequestId and ServerRequest are allowed in the server request protocol exception.",
|
||||
},
|
||||
];
|
||||
const chatAppServerProtocolBoundaryFiles = [
|
||||
"src/features/chat/app-server/inbound/notification-plan.ts",
|
||||
"src/features/chat/app-server/mappers/message-stream/turn-items.ts",
|
||||
];
|
||||
const chatAppServerRequestBoundaryFiles = [
|
||||
"src/features/chat/app-server/inbound/handler.ts",
|
||||
"src/features/chat/app-server/inbound/routing.ts",
|
||||
];
|
||||
const unsafeIteratorRestrictions = [
|
||||
{
|
||||
selector: "MemberExpression[property.name='value'][object.type='CallExpression'][object.callee.property.name='next']",
|
||||
message: "Avoid reading iterator.next().value directly; use for...of or inspect the typed IteratorResult first.",
|
||||
},
|
||||
];
|
||||
const pureChatStateRestrictions = [
|
||||
{
|
||||
selector: "CallExpression[callee.object.name='Date'][callee.property.name='now']",
|
||||
message: "Keep chat state transforms deterministic; generate IDs or timestamps at an application or UI boundary.",
|
||||
},
|
||||
{
|
||||
selector: "NewExpression[callee.name='Date']",
|
||||
message: "Keep chat state transforms deterministic; pass dates in from an application or UI boundary.",
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.object.name='Math'][callee.property.name='random']",
|
||||
message: "Keep chat state transforms deterministic; generate IDs at an application or UI boundary.",
|
||||
},
|
||||
{
|
||||
selector: "NewExpression[callee.name=/^(AppServerClient|ConnectionManager|Notice)$/]",
|
||||
message: "Keep app-server and Obsidian side effects out of chat state transforms.",
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.property.name=/^(setTimeout|clearTimeout|requestAnimationFrame)$/]",
|
||||
message: "Keep scheduling side effects out of chat state transforms.",
|
||||
},
|
||||
{
|
||||
selector: "MemberExpression[object.name=/^(document|localStorage|sessionStorage)$/]",
|
||||
message: "Keep browser globals out of chat state transforms.",
|
||||
},
|
||||
];
|
||||
const chatExternalDomBridgeFiles = [
|
||||
"src/features/chat/ui/message-stream/markdown-renderer.ts",
|
||||
"src/features/chat/ui/message-stream/stream-markdown-renderer.ts",
|
||||
"src/features/chat/ui/message-stream/flow-scroll.ts",
|
||||
];
|
||||
const chatPreactDomBridgeFiles = [
|
||||
"src/features/chat/ui/message-stream/text-content.tsx",
|
||||
"src/features/chat/ui/message-stream/detail.tsx",
|
||||
"src/features/chat/ui/message-stream/viewport.tsx",
|
||||
"src/features/chat/ui/composer-dom.ts",
|
||||
"src/features/chat/panel/shell.tsx",
|
||||
"src/features/chat/ui/turn-diff/render.tsx",
|
||||
];
|
||||
const chatImperativeDomBridgeFiles = [...chatExternalDomBridgeFiles, ...chatPreactDomBridgeFiles];
|
||||
const uiRootBridgeFiles = [
|
||||
"src/features/chat/panel/shell.tsx",
|
||||
"src/features/chat/ui/turn-diff/render.tsx",
|
||||
"src/features/chat/ui/turn-diff/view.ts",
|
||||
"src/features/selection-rewrite/popover.tsx",
|
||||
"src/features/threads-view/renderer.tsx",
|
||||
"src/settings/tab.tsx",
|
||||
];
|
||||
const nonChatImperativeDomBridgeFiles = [
|
||||
"src/features/selection-rewrite/popover.tsx",
|
||||
"src/features/thread-picker/modal.ts",
|
||||
"src/features/threads-view/renderer.tsx",
|
||||
"src/settings/tab.tsx",
|
||||
"src/shared/diff/render.ts",
|
||||
"src/shared/ui/components.tsx",
|
||||
"src/shared/ui/textarea-autogrow.ts",
|
||||
"src/shared/ui/textarea-caret.ts",
|
||||
"src/shared/ui/ui-root.tsx",
|
||||
];
|
||||
const appServerProjectionRpcMethodPattern = "^(resumeThread|threadTurnsList|forkThread|rollbackThread)$";
|
||||
const appServerProjectionRpcRestrictions = [
|
||||
{
|
||||
selector: `CallExpression[callee.object.name='client'][callee.property.name=/${appServerProjectionRpcMethodPattern}/]`,
|
||||
message:
|
||||
"Keep app-server projection RPCs behind app-server facades; chat application code should consume Panel-owned snapshots or view models.",
|
||||
},
|
||||
];
|
||||
const appServerClientProjectionTypeRestrictions = [
|
||||
{
|
||||
selector: `TSIndexedAccessType[objectType.typeName.name='AppServerClient'][indexType.literal.value=/${appServerProjectionRpcMethodPattern}/]`,
|
||||
message:
|
||||
"Do not expose app-server projection RPC signatures through AppServerClient indexed access types; define a Panel-owned projection type instead.",
|
||||
},
|
||||
];
|
||||
const baseSourceSyntaxRestrictions = [
|
||||
...removedChatStateEscapeHatchRestrictions,
|
||||
...generatedAppServerThreadImportRestrictions,
|
||||
...appServerClientProjectionTypeRestrictions,
|
||||
...unsafeIteratorRestrictions,
|
||||
...preactFormRestrictions,
|
||||
];
|
||||
const sourceSyntaxRestrictionsWithoutUiRoot = [...baseSourceSyntaxRestrictions, ...chatSignalAdapterRestrictions];
|
||||
const sourceSyntaxRestrictions = [...sourceSyntaxRestrictionsWithoutUiRoot, ...uiRootImportRestrictions];
|
||||
const chatSourceSyntaxRestrictions = sourceSyntaxRestrictions;
|
||||
const chatDomBridgeSyntaxRestrictions = sourceSyntaxRestrictions;
|
||||
const nonChatDomBridgeSyntaxRestrictions = sourceSyntaxRestrictions;
|
||||
const pureChatStateSyntaxRestrictions = [...sourceSyntaxRestrictions, ...appServerProjectionRpcRestrictions, ...pureChatStateRestrictions];
|
||||
|
||||
function importBoundaryPatterns(relativeTarget, absoluteTarget, maxParentDepth) {
|
||||
const targets = [absoluteTarget, ...Array.from({ length: maxParentDepth }, (_, index) => `${"../".repeat(index + 1)}${relativeTarget}`)];
|
||||
return targets.flatMap((target) => [target, `${target}/**`]);
|
||||
}
|
||||
|
||||
function restrictedSyntaxRule(restrictions) {
|
||||
return {
|
||||
"no-restricted-syntax": ["error", ...restrictions],
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: ["main.js", "node_modules/**", "src/generated/**"],
|
||||
},
|
||||
{
|
||||
basePath: "src",
|
||||
plugins: {
|
||||
obsidianmd,
|
||||
},
|
||||
},
|
||||
...obsidianmd.configs.recommended.map(obsidianSourceRecommendedConfig).filter(Boolean),
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.strictTypeChecked.map((config) => ({
|
||||
...config,
|
||||
files: sourceTypeScriptFiles,
|
||||
files: lintedTypeScriptFiles,
|
||||
})),
|
||||
...tseslint.configs.stylisticTypeChecked.map((config) => ({
|
||||
...config,
|
||||
files: lintedTypeScriptFiles,
|
||||
})),
|
||||
...obsidianmd.configs.recommended.map((config) => ({
|
||||
...config,
|
||||
basePath: "src",
|
||||
})),
|
||||
{
|
||||
files: sourceTypeScriptFiles,
|
||||
files: lintedTypeScriptFiles,
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
globals: {
|
||||
AbortSignal: "readonly",
|
||||
|
|
@ -44,72 +276,292 @@ export default defineConfig([
|
|||
},
|
||||
},
|
||||
rules: {
|
||||
// Biome import restrictions do not cover `import("...").T`; keep that type syntax out before source-boundary lint runs.
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
...projectTypeScriptRules,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: nodeJavaScriptFiles,
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
URL: "readonly",
|
||||
console: "readonly",
|
||||
process: "readonly",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["tests/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
...testTypeScriptRelaxations,
|
||||
...restrictedSyntaxRule([...generatedAppServerThreadImportRestrictions, ...chatSignalAdapterRestrictions]),
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.ts", "src/**/*.tsx"],
|
||||
plugins: {
|
||||
"codex-panel": codexPanelEslintPlugin,
|
||||
obsidianmd,
|
||||
},
|
||||
rules: {
|
||||
"codex-panel/no-self-referential-initializer-callback": "error",
|
||||
"obsidianmd/ui/sentence-case": [
|
||||
"error",
|
||||
{
|
||||
disallowTypeAnnotations: true,
|
||||
fixStyle: "separate-type-imports",
|
||||
prefer: "type-imports",
|
||||
acronyms: ["MCP"],
|
||||
brands: ["Codex", "Codex Panel", "Obsidian"],
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/require-await": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["manifest.json"],
|
||||
plugins: {
|
||||
json,
|
||||
obsidianmd,
|
||||
},
|
||||
language: "json/json",
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
ignores: ["src/features/chat/**/*.{ts,tsx}", ...nonChatImperativeDomBridgeFiles],
|
||||
rules: {
|
||||
"obsidianmd/validate-manifest": "error",
|
||||
...restrictedSyntaxRule(sourceSyntaxRestrictions),
|
||||
"codex-panel/no-imperative-dom": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["LICENSE"],
|
||||
plugins: {
|
||||
obsidianmd,
|
||||
},
|
||||
languageOptions: {
|
||||
parser: PlainTextParser,
|
||||
},
|
||||
files: ["src/features/chat/**/*.{ts,tsx}"],
|
||||
ignores: ["src/features/chat/panel/shell-state.tsx", ...chatImperativeDomBridgeFiles],
|
||||
rules: {
|
||||
"obsidianmd/validate-license": "error",
|
||||
...restrictedSyntaxRule(chatSourceSyntaxRestrictions),
|
||||
"codex-panel/no-imperative-dom": "error",
|
||||
"codex-panel/no-chat-state-direct-mutation": "error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
function obsidianSourceRecommendedConfig(config) {
|
||||
const rules = Object.fromEntries(
|
||||
Object.entries(config.rules ?? {})
|
||||
.filter(([ruleName]) => {
|
||||
return (
|
||||
ruleName.startsWith("obsidianmd/") && ruleName !== "obsidianmd/validate-manifest" && ruleName !== "obsidianmd/validate-license"
|
||||
);
|
||||
})
|
||||
.map(([ruleName, ruleConfig]) => {
|
||||
if (ruleName !== "obsidianmd/ui/sentence-case") return [ruleName, ruleConfig];
|
||||
return [
|
||||
ruleName,
|
||||
[
|
||||
"error",
|
||||
{
|
||||
files: ["src/features/chat/application/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
...restrictedSyntaxRule([...chatSourceSyntaxRestrictions, ...appServerProjectionRpcRestrictions]),
|
||||
"codex-panel/no-imperative-dom": "error",
|
||||
"codex-panel/no-chat-state-direct-mutation": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/features/chat/panel/shell-state.tsx"],
|
||||
rules: {
|
||||
...restrictedSyntaxRule(baseSourceSyntaxRestrictions),
|
||||
"codex-panel/no-imperative-dom": "error",
|
||||
"codex-panel/no-chat-state-direct-mutation": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: chatImperativeDomBridgeFiles,
|
||||
rules: {
|
||||
...restrictedSyntaxRule(chatDomBridgeSyntaxRestrictions),
|
||||
"codex-panel/no-chat-state-direct-mutation": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: nonChatImperativeDomBridgeFiles,
|
||||
rules: restrictedSyntaxRule(nonChatDomBridgeSyntaxRestrictions),
|
||||
},
|
||||
{
|
||||
files: uiRootBridgeFiles,
|
||||
rules: restrictedSyntaxRule(sourceSyntaxRestrictionsWithoutUiRoot),
|
||||
},
|
||||
{
|
||||
files: ["src/features/chat/application/state/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
...restrictedSyntaxRule(pureChatStateSyntaxRestrictions),
|
||||
"codex-panel/no-imperative-dom": "error",
|
||||
"codex-panel/no-chat-state-direct-mutation": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/features/chat/domain/**/*.{ts,tsx}"],
|
||||
rules: restrictedSyntaxRule([...sourceSyntaxRestrictions, ...chatDomainLayerRestrictions]),
|
||||
},
|
||||
{
|
||||
files: ["src/app-server/**/*.{ts,tsx}", "src/domain/**/*.{ts,tsx}", "src/shared/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
enforceCamelCaseLower: true,
|
||||
acronyms: ["MCP"],
|
||||
brands: ["Codex", "Codex Panel", "Obsidian"],
|
||||
group: appServerProtocolConnectionImportPatterns,
|
||||
message:
|
||||
"Keep app-server protocol modules as connection-independent adapters. Put protocol-local projections in protocol modules and let connection/client consume them.",
|
||||
},
|
||||
{
|
||||
group: lowerLevelFeatureImportPatterns,
|
||||
message: "Lower-level modules must not import feature modules. Move shared behavior to shared, domain, or app-server.",
|
||||
},
|
||||
],
|
||||
];
|
||||
}),
|
||||
);
|
||||
if (Object.keys(rules).length === 0) return null;
|
||||
const obsidianConfig = {
|
||||
basePath: "src",
|
||||
rules,
|
||||
};
|
||||
if (config.files) obsidianConfig.files = config.files;
|
||||
if (config.ignores) obsidianConfig.ignores = config.ignores;
|
||||
return obsidianConfig;
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/app-server/**/*.{ts,tsx}"],
|
||||
ignores: ["src/app-server/connection/**/*.{ts,tsx}", "src/app-server/protocol/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: generatedAppServerSourceImportPatterns,
|
||||
message:
|
||||
"Keep generated app-server bindings in the raw connection layer or explicit protocol adapters. App-server services, queries, catalogs, and thread helpers should expose domain models or local projections.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/app-server/protocol/**/*.{ts,tsx}"],
|
||||
ignores: ["src/app-server/protocol/turn.ts", "src/app-server/protocol/server-requests.ts"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: appServerProtocolConnectionImportPatterns,
|
||||
message:
|
||||
"Keep app-server protocol modules as connection-independent adapters. Put protocol-local projections in protocol modules and let connection/client consume them.",
|
||||
},
|
||||
{
|
||||
group: lowerLevelFeatureImportPatterns,
|
||||
message: "Lower-level modules must not import feature modules. Move shared behavior to shared, domain, or app-server.",
|
||||
},
|
||||
{
|
||||
group: generatedAppServerSourceImportPatterns,
|
||||
message:
|
||||
"Keep generated app-server bindings out of protocol modules except explicit protocol adapter exceptions. Model app-server payloads with protocol-local projections.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/app-server/protocol/turn.ts"],
|
||||
rules: {
|
||||
...restrictedSyntaxRule(turnProtocolGeneratedImportRestrictions),
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: appServerProtocolConnectionImportPatterns,
|
||||
message:
|
||||
"Keep app-server protocol modules as connection-independent adapters. Put protocol-local projections in protocol modules and let connection/client consume them.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/app-server/protocol/server-requests.ts"],
|
||||
rules: {
|
||||
...restrictedSyntaxRule(serverRequestsProtocolGeneratedImportRestrictions),
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: appServerProtocolConnectionImportPatterns,
|
||||
message:
|
||||
"Keep app-server protocol modules as connection-independent adapters. Put protocol-local projections in protocol modules and let connection/client consume them.",
|
||||
},
|
||||
{
|
||||
group: lowerLevelFeatureImportPatterns,
|
||||
message: "Lower-level modules must not import feature modules. Move shared behavior to shared, domain, or app-server.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
ignores: ["src/app-server/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: nonAppServerBannedAppServerProtocolImportPatterns,
|
||||
message:
|
||||
"Source modules outside app-server must use domain models and app-server services instead of app-server protocol modules. Chat ingestion and message-stream conversion may consume app-server turn protocol at the boundary; feature state and UI must use Panel-owned models.",
|
||||
},
|
||||
{
|
||||
group: generatedAppServerSourceImportPatterns,
|
||||
message: "Keep generated app-server types behind src/app-server; expose Panel-owned models to feature, UI, and reducer code.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: chatAppServerRequestBoundaryFiles,
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: chatAppServerRequestBoundaryBannedImportPatterns,
|
||||
message:
|
||||
"Chat app-server request handling may consume server request protocol projections only. Convert app-server payloads to chat pending request domain models at this boundary.",
|
||||
},
|
||||
{
|
||||
group: generatedAppServerSourceImportPatterns,
|
||||
message: "Keep generated app-server types behind src/app-server; expose Panel-owned models to feature, UI, and reducer code.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: chatAppServerProtocolBoundaryFiles,
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: chatAppServerProtocolBoundaryBannedImportPatterns,
|
||||
message:
|
||||
"Chat app-server ingestion and message-stream conversion may consume the app-server turn protocol only. Convert other protocol payloads to local or domain models at the boundary.",
|
||||
},
|
||||
{
|
||||
group: generatedAppServerSourceImportPatterns,
|
||||
message: "Keep generated app-server types behind src/app-server; expose Panel-owned models to feature, UI, and reducer code.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["tests/**/*.{ts,tsx}"],
|
||||
ignores: ["tests/app-server/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: generatedAppServerTestImportPatterns,
|
||||
message:
|
||||
"Keep generated app-server types behind src/app-server and tests/app-server; feature tests should use Panel-owned models.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
eslintConfigPrettier,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
"entry": ["src/main.ts", "scripts/**/*.mjs", "*.config.{ts,mjs}", "tests/**/*.test.{ts,tsx}"],
|
||||
"project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}", "scripts/**/*.mjs", "*.config.{ts,mjs}"],
|
||||
"ignore": ["src/generated/**"],
|
||||
"ignoreBinaries": ["codex", "taskkill"]
|
||||
"ignoreBinaries": ["codex"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "codex-panel",
|
||||
"name": "Codex Panel",
|
||||
"version": "5.2.1",
|
||||
"version": "3.4.3",
|
||||
"minAppVersion": "1.12.0",
|
||||
"description": "Codex in your sidebar.",
|
||||
"author": "murashit",
|
||||
|
|
|
|||
4820
package-lock.json
generated
4820
package-lock.json
generated
File diff suppressed because it is too large
Load diff
72
package.json
72
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "codex-panel",
|
||||
"version": "5.2.1",
|
||||
"version": "3.4.3",
|
||||
"description": "Codex in your Obsidian sidebar.",
|
||||
"main": "main.js",
|
||||
"author": "murashit",
|
||||
|
|
@ -13,65 +13,49 @@
|
|||
"url": "https://github.com/murashit/codex-panel/issues"
|
||||
},
|
||||
"homepage": "https://github.com/murashit/codex-panel#readme",
|
||||
"engines": {
|
||||
"node": ">=26"
|
||||
},
|
||||
"scripts": {
|
||||
"api:baseline": "node scripts/api-baseline.mjs",
|
||||
"build": "node esbuild.config.mjs",
|
||||
"build:styles": "node scripts/build-styles.mjs",
|
||||
"commitlint": "commitlint",
|
||||
"check": "concurrently --group --pad-prefix 'node:check:*' && node --run build",
|
||||
"check:biome": "biome check --diagnostic-level=warn --error-on-warnings",
|
||||
"check:css-usage": "node scripts/check-css-usage.mjs",
|
||||
"check:eslint": "eslint src manifest.json LICENSE --max-warnings=0",
|
||||
"check:test": "node --run test",
|
||||
"check:typecheck": "node --run typecheck",
|
||||
"check:unused": "knip --no-progress",
|
||||
"fix": "biome check --fix --skip=plugin --diagnostic-level=warn --error-on-warnings && knip --fix --no-progress",
|
||||
"check": "node scripts/check.mjs",
|
||||
"check:ci": "node scripts/check.mjs --ci",
|
||||
"format": "node scripts/format.mjs",
|
||||
"format:check": "node scripts/format.mjs --check --cache",
|
||||
"format:check:ci": "node scripts/format.mjs --check",
|
||||
"generate:app-server-types": "node scripts/generate-app-server-types.mjs",
|
||||
"generate:app-server-types:check": "node scripts/generate-app-server-types.mjs --check",
|
||||
"lint": "node scripts/check.mjs --lint",
|
||||
"lint:ci": "node scripts/check.mjs --ci --lint",
|
||||
"release:check": "node scripts/release/check.mjs",
|
||||
"release:notes": "node scripts/release/notes.mjs",
|
||||
"release:preflight": "node scripts/release/preflight.mjs",
|
||||
"release:prepare": "node scripts/release/prepare.mjs",
|
||||
"test": "vitest run --pool threads --maxWorkers 6",
|
||||
"test:coverage": "vitest run --pool threads --maxWorkers 6 --coverage",
|
||||
"test:mutation": "stryker run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --incremental --tsBuildInfoFile node_modules/.cache/typescript/tsconfig.tsbuildinfo"
|
||||
"test": "vitest run --cache",
|
||||
"test:ci": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --incremental --tsBuildInfoFile node_modules/.cache/typescript/tsconfig.tsbuildinfo",
|
||||
"typecheck:ci": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.3",
|
||||
"@commitlint/cli": "^21.2.1",
|
||||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@stryker-mutator/core": "^9.6.1",
|
||||
"@stryker-mutator/vitest-runner": "^9.6.1",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"concurrently": "^9.2.4",
|
||||
"conventional-changelog-conventionalcommits": "^10.2.1",
|
||||
"conventional-commits-parser": "^7.1.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^25.9.3",
|
||||
"concurrently": "^10.0.3",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-obsidianmd": "^0.4.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^6.25.0",
|
||||
"moment": "^2.30.1",
|
||||
"knip": "^6.16.1",
|
||||
"obsidian": "~1.12.3",
|
||||
"prettier": "^3.8.4",
|
||||
"stylelint": "^17.13.0",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.64.0",
|
||||
"vitest": "^4.1.10"
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "^5.101.2",
|
||||
"defuddle": "^0.19.1",
|
||||
"diff": "^9.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"@preact/signals": "^2.9.1",
|
||||
"@tanstack/query-core": "^5.101.0",
|
||||
"micromark": "^4.0.2",
|
||||
"obsidian-daily-notes-interface": "^0.9.5",
|
||||
"preact": "^10.29.7",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
"preact": "^10.29.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,19 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const asJson = args.has("--json");
|
||||
const validArgs = new Set(["--json"]);
|
||||
|
||||
if (isMain()) {
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const asJson = args.has("--json");
|
||||
for (const arg of args) {
|
||||
if (!validArgs.has(arg)) {
|
||||
console.error("Usage: node scripts/api-baseline.mjs [--json]");
|
||||
process.exit(1);
|
||||
}
|
||||
for (const arg of args) {
|
||||
if (!validArgs.has(arg)) {
|
||||
console.error("Usage: node scripts/api-baseline.mjs [--json]");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const report = await createApiBaselineReport();
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
printReport(report);
|
||||
}
|
||||
|
||||
if (report.failures.length > 0) process.exit(1);
|
||||
function fail(message) {
|
||||
failures.push(message);
|
||||
}
|
||||
|
||||
function parseSemver(value) {
|
||||
|
|
@ -50,137 +41,11 @@ function readCodexVersion() {
|
|||
return parseSemver(`${result.stdout}\n${result.stderr}`)?.version ?? null;
|
||||
}
|
||||
|
||||
export async function createApiBaselineReport(options = {}) {
|
||||
const failures = [];
|
||||
const fail = (message) => {
|
||||
failures.push(message);
|
||||
};
|
||||
const inputs = await readBaselineInputs(options.cwd ?? process.cwd());
|
||||
const readmeBaselines = readCompatibilityBaselines(inputs.readme);
|
||||
|
||||
const codexReadmeVersion = readmeBaselines.codexTestedCliVersion;
|
||||
const codexRecordedVersion = inputs.appServerCompatibilityJson.codexAppServer?.testedCliVersion ?? null;
|
||||
const codexLocalVersion = options.readCodexVersion ? await options.readCodexVersion() : readCodexVersion();
|
||||
const codexReadmeSemver = parseSemver(codexReadmeVersion);
|
||||
const codexRecordedSemver = parseSemver(codexRecordedVersion);
|
||||
const codexLocalSemver = parseSemver(codexLocalVersion);
|
||||
|
||||
const obsidianMinVersion = inputs.manifestJson.minAppVersion;
|
||||
const obsidianReadmeApiTypesVersion = readmeBaselines.obsidianApiTypesVersion;
|
||||
const obsidianReadmeMinVersion = readmeBaselines.obsidianMinAppVersion;
|
||||
const obsidianVersionEntry = inputs.versionsJson[inputs.packageJson.version] ?? null;
|
||||
const obsidianSpec = inputs.packageJson.devDependencies?.obsidian ?? null;
|
||||
const obsidianLockVersion = inputs.packageLockJson.packages?.["node_modules/obsidian"]?.version ?? null;
|
||||
const obsidianSpecSemver = parseSemver(obsidianSpec);
|
||||
const obsidianLockSemver = parseSemver(obsidianLockVersion);
|
||||
const obsidianMinSemver = parseSemver(obsidianMinVersion);
|
||||
|
||||
const generationArguments = inputs.appServerCompatibilityJson.codexAppServer?.typeGeneration?.arguments ?? null;
|
||||
const expectedGenerationArguments = ["app-server", "generate-ts", "--experimental"];
|
||||
const appServerGenerationArgumentsDeclared =
|
||||
Array.isArray(generationArguments) && generationArguments.every((argument) => typeof argument === "string");
|
||||
const appServerGenerationArgumentsSupported =
|
||||
appServerGenerationArgumentsDeclared && arraysEqual(generationArguments, expectedGenerationArguments);
|
||||
const initializeCapabilities = inputs.appServerCompatibilityJson.codexAppServer?.initialize?.capabilities ?? {};
|
||||
const initializeExperimentalApi = initializeCapabilities.experimentalApi === true;
|
||||
const initializeRequestAttestationDisabled = initializeCapabilities.requestAttestation === false;
|
||||
|
||||
if (!codexReadmeSemver) {
|
||||
fail("README.md Compatibility table must define `codexAppServer.testedCliVersion` as X.Y.Z.");
|
||||
}
|
||||
if (!codexRecordedSemver || codexRecordedSemver.version !== codexRecordedVersion) {
|
||||
fail("src/app-server/connection/compatibility.json must declare codexAppServer.testedCliVersion as X.Y.Z.");
|
||||
}
|
||||
if (!codexLocalSemver) {
|
||||
fail("local codex --version could not be read.");
|
||||
}
|
||||
if (codexReadmeVersion && codexRecordedVersion && codexReadmeVersion !== codexRecordedVersion) {
|
||||
fail(`README Codex CLI ${codexReadmeVersion} does not match recorded tested CLI ${codexRecordedVersion}.`);
|
||||
}
|
||||
if (codexLocalSemver && codexRecordedSemver && codexLocalSemver.version !== codexRecordedSemver.version) {
|
||||
fail(`local Codex CLI ${codexLocalSemver.version} does not match recorded tested CLI ${codexRecordedSemver.version}.`);
|
||||
}
|
||||
if (!appServerGenerationArgumentsDeclared) {
|
||||
fail("src/app-server/connection/compatibility.json must declare codexAppServer.typeGeneration.arguments as a string array.");
|
||||
} else if (!appServerGenerationArgumentsSupported) {
|
||||
fail(`app-server type generation arguments must be ${expectedGenerationArguments.join(" ")}.`);
|
||||
}
|
||||
if (!initializeExperimentalApi) {
|
||||
fail("src/app-server/connection/compatibility.json must declare codexAppServer.initialize.capabilities.experimentalApi: true.");
|
||||
}
|
||||
if (!initializeRequestAttestationDisabled) {
|
||||
fail("src/app-server/connection/compatibility.json must declare codexAppServer.initialize.capabilities.requestAttestation: false.");
|
||||
}
|
||||
|
||||
if (!obsidianMinSemver) fail("manifest.json minAppVersion must be X.Y.Z.");
|
||||
if (obsidianMinSemver && obsidianMinSemver.patch !== 0) {
|
||||
fail(`manifest.json minAppVersion should be the minor baseline patch 0, got ${obsidianMinVersion}.`);
|
||||
}
|
||||
if (!parseSemver(obsidianReadmeMinVersion)) {
|
||||
fail("README.md Compatibility table must define `manifest.minAppVersion` as X.Y.Z.");
|
||||
} else if (obsidianReadmeMinVersion !== obsidianMinVersion) {
|
||||
fail(`README Obsidian baseline ${displayValue(obsidianReadmeMinVersion)} does not match manifest ${obsidianMinVersion}.`);
|
||||
}
|
||||
if (obsidianVersionEntry !== obsidianMinVersion) {
|
||||
fail(`versions.json must map ${inputs.packageJson.version} to manifest minAppVersion ${obsidianMinVersion}.`);
|
||||
}
|
||||
if (!obsidianSpecSemver) fail("package.json devDependency obsidian must include an X.Y.Z version.");
|
||||
if (!obsidianLockSemver) fail("package-lock.json must lock node_modules/obsidian to X.Y.Z.");
|
||||
if (packageRangeKind(obsidianSpec) !== "patch") {
|
||||
fail(`package.json devDependency obsidian should use a patch-only '~' range, got ${displayValue(obsidianSpec)}.`);
|
||||
}
|
||||
if (obsidianMinSemver && obsidianSpecSemver && minorKey(obsidianSpecSemver) !== minorKey(obsidianMinSemver)) {
|
||||
fail(`obsidian devDependency minor ${minorKey(obsidianSpecSemver)} does not match minAppVersion minor ${minorKey(obsidianMinSemver)}.`);
|
||||
}
|
||||
if (obsidianMinSemver && obsidianLockSemver && minorKey(obsidianLockSemver) !== minorKey(obsidianMinSemver)) {
|
||||
fail(`locked obsidian minor ${minorKey(obsidianLockSemver)} does not match minAppVersion minor ${minorKey(obsidianMinSemver)}.`);
|
||||
}
|
||||
if (!parseSemver(obsidianReadmeApiTypesVersion)) {
|
||||
fail("README.md Compatibility table must define `obsidian` API types as X.Y.Z.");
|
||||
} else if (obsidianReadmeApiTypesVersion !== obsidianLockVersion) {
|
||||
fail(
|
||||
`README Obsidian API types ${displayValue(obsidianReadmeApiTypesVersion)} does not match package-lock obsidian ${displayValue(obsidianLockVersion)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
codex: {
|
||||
policy: "compatibility is managed by minor; generated bindings are proven against an exact CLI patch",
|
||||
recordedTestedCliVersion: codexRecordedVersion,
|
||||
readmeTestedCliVersion: codexReadmeVersion,
|
||||
readmeTestedMinor: minorKey(codexReadmeSemver),
|
||||
localCliVersion: codexLocalVersion,
|
||||
localCliMinor: minorKey(codexLocalSemver),
|
||||
readmeMatchesRecordedVersion: codexReadmeVersion && codexRecordedVersion ? codexReadmeVersion === codexRecordedVersion : false,
|
||||
localCliMatchesRecordedVersion:
|
||||
codexLocalSemver && codexRecordedSemver ? codexLocalSemver.version === codexRecordedSemver.version : null,
|
||||
generationArguments,
|
||||
appServerGenerationArgumentsSupported,
|
||||
initializeExperimentalApi,
|
||||
initializeRequestAttestationDisabled,
|
||||
},
|
||||
obsidian: {
|
||||
policy: "manifest declares the minimum app version; obsidian npm provides compile-time API types in the same minor",
|
||||
minAppVersion: obsidianMinVersion,
|
||||
minAppVersionMinor: minorKey(obsidianMinSemver),
|
||||
readmeMinAppVersion: obsidianReadmeMinVersion,
|
||||
versionsJsonCurrentMinAppVersion: obsidianVersionEntry,
|
||||
readmeApiTypesVersion: obsidianReadmeApiTypesVersion,
|
||||
packageDependency: obsidianSpec,
|
||||
packageDependencyRange: packageRangeKind(obsidianSpec),
|
||||
packageDependencyMinor: minorKey(obsidianSpecSemver),
|
||||
lockedPackageVersion: obsidianLockVersion,
|
||||
lockedPackageMinor: minorKey(obsidianLockSemver),
|
||||
},
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
function readCompatibilityBaselines(readme) {
|
||||
const section = markdownSection(readme, "Compatibility");
|
||||
const table = readMarkdownTableValues(section);
|
||||
return {
|
||||
codexTestedCliVersion: table.get("codexAppServer.testedCliVersion") ?? null,
|
||||
codexTestedCliVersion: table.get("codex.testedCliVersion") ?? null,
|
||||
obsidianApiTypesVersion: table.get("obsidian") ?? null,
|
||||
obsidianMinAppVersion: table.get("manifest.minAppVersion") ?? null,
|
||||
};
|
||||
|
|
@ -202,7 +67,7 @@ function readMarkdownTableValues(markdown) {
|
|||
const header = splitMarkdownTableRow(lines[index]);
|
||||
if (!header) continue;
|
||||
const separator = splitMarkdownTableRow(lines[index + 1]);
|
||||
if (!separator?.every((cell) => /^:?-{3,}:?$/.test(cell))) continue;
|
||||
if (!separator || !separator.every((cell) => /^:?-{3,}:?$/.test(cell))) continue;
|
||||
|
||||
const keyColumn = header.findIndex((cell) => normalizeTableHeader(cell) === "key");
|
||||
const versionColumn = header.findIndex((cell) => normalizeTableHeader(cell) === "version");
|
||||
|
|
@ -247,22 +112,126 @@ function displayValue(value) {
|
|||
return value ?? "(missing)";
|
||||
}
|
||||
|
||||
function arraysEqual(left, right) {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
const failures = [];
|
||||
const inputs = await readBaselineInputs();
|
||||
const readmeBaselines = readCompatibilityBaselines(inputs.readme);
|
||||
|
||||
const codexReadmeVersion = readmeBaselines.codexTestedCliVersion;
|
||||
const codexLocalVersion = readCodexVersion();
|
||||
const codexReadmeSemver = parseSemver(codexReadmeVersion);
|
||||
const codexLocalSemver = parseSemver(codexLocalVersion);
|
||||
|
||||
const obsidianMinVersion = inputs.manifestJson.minAppVersion;
|
||||
const obsidianReadmeApiTypesVersion = readmeBaselines.obsidianApiTypesVersion;
|
||||
const obsidianReadmeMinVersion = readmeBaselines.obsidianMinAppVersion;
|
||||
const obsidianVersionEntry = inputs.versionsJson[inputs.packageJson.version] ?? null;
|
||||
const obsidianSpec = inputs.packageJson.devDependencies?.obsidian ?? null;
|
||||
const obsidianLockVersion = inputs.packageLockJson.packages?.["node_modules/obsidian"]?.version ?? null;
|
||||
const obsidianSpecSemver = parseSemver(obsidianSpec);
|
||||
const obsidianLockSemver = parseSemver(obsidianLockVersion);
|
||||
const obsidianMinSemver = parseSemver(obsidianMinVersion);
|
||||
|
||||
const appServerGenerationExperimental =
|
||||
inputs.appServerGenerateSource.includes("app-server") &&
|
||||
inputs.appServerGenerateSource.includes("generate-ts") &&
|
||||
inputs.appServerGenerateSource.includes("--experimental");
|
||||
const initializeExperimentalApi = /experimentalApi:\s*true/.test(inputs.clientSource);
|
||||
const initializeRequestAttestationDisabled = /requestAttestation:\s*false/.test(inputs.clientSource);
|
||||
|
||||
if (!codexReadmeSemver) {
|
||||
fail("README.md Compatibility table must define `codex.testedCliVersion` as X.Y.Z.");
|
||||
}
|
||||
if (!codexLocalSemver) {
|
||||
fail("local codex --version could not be read.");
|
||||
}
|
||||
if (codexReadmeSemver && codexLocalSemver && minorKey(codexReadmeSemver) !== minorKey(codexLocalSemver)) {
|
||||
fail(`local Codex CLI minor ${minorKey(codexLocalSemver)} does not match compatibility table minor ${minorKey(codexReadmeSemver)}.`);
|
||||
}
|
||||
if (!appServerGenerationExperimental) fail("generate:app-server-types must use codex app-server generate-ts --experimental.");
|
||||
if (!initializeExperimentalApi) fail("app-server initialize must declare experimentalApi: true.");
|
||||
if (!initializeRequestAttestationDisabled) fail("app-server initialize must declare requestAttestation: false.");
|
||||
|
||||
if (!obsidianMinSemver) fail("manifest.json minAppVersion must be X.Y.Z.");
|
||||
if (obsidianMinSemver && obsidianMinSemver.patch !== 0) {
|
||||
fail(`manifest.json minAppVersion should be the minor baseline patch 0, got ${obsidianMinVersion}.`);
|
||||
}
|
||||
if (!parseSemver(obsidianReadmeMinVersion)) {
|
||||
fail("README.md Compatibility table must define `manifest.minAppVersion` as X.Y.Z.");
|
||||
} else if (obsidianReadmeMinVersion !== obsidianMinVersion) {
|
||||
fail(`README Obsidian baseline ${displayValue(obsidianReadmeMinVersion)} does not match manifest ${obsidianMinVersion}.`);
|
||||
}
|
||||
if (obsidianVersionEntry !== obsidianMinVersion) {
|
||||
fail(`versions.json must map ${inputs.packageJson.version} to manifest minAppVersion ${obsidianMinVersion}.`);
|
||||
}
|
||||
if (!obsidianSpecSemver) fail("package.json devDependency obsidian must include an X.Y.Z version.");
|
||||
if (!obsidianLockSemver) fail("package-lock.json must lock node_modules/obsidian to X.Y.Z.");
|
||||
if (packageRangeKind(obsidianSpec) !== "patch") {
|
||||
fail(`package.json devDependency obsidian should use a patch-only '~' range, got ${displayValue(obsidianSpec)}.`);
|
||||
}
|
||||
if (obsidianMinSemver && obsidianSpecSemver && minorKey(obsidianSpecSemver) !== minorKey(obsidianMinSemver)) {
|
||||
fail(`obsidian devDependency minor ${minorKey(obsidianSpecSemver)} does not match minAppVersion minor ${minorKey(obsidianMinSemver)}.`);
|
||||
}
|
||||
if (obsidianMinSemver && obsidianLockSemver && minorKey(obsidianLockSemver) !== minorKey(obsidianMinSemver)) {
|
||||
fail(`locked obsidian minor ${minorKey(obsidianLockSemver)} does not match minAppVersion minor ${minorKey(obsidianMinSemver)}.`);
|
||||
}
|
||||
if (!parseSemver(obsidianReadmeApiTypesVersion)) {
|
||||
fail("README.md Compatibility table must define `obsidian` API types as X.Y.Z.");
|
||||
} else if (obsidianReadmeApiTypesVersion !== obsidianLockVersion) {
|
||||
fail(
|
||||
`README Obsidian API types ${displayValue(obsidianReadmeApiTypesVersion)} does not match package-lock obsidian ${displayValue(obsidianLockVersion)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function readBaselineInputs(cwd) {
|
||||
const [packageJson, packageLockJson, manifestJson, versionsJson, readme, appServerCompatibilityJson] = await Promise.all([
|
||||
readJson(cwd, "package.json"),
|
||||
readJson(cwd, "package-lock.json"),
|
||||
readJson(cwd, "manifest.json"),
|
||||
readJson(cwd, "versions.json"),
|
||||
readFile(path.join(cwd, "README.md"), "utf8"),
|
||||
readJson(cwd, "src/app-server/connection/compatibility.json"),
|
||||
const report = {
|
||||
codex: {
|
||||
policy: "managed by minor version",
|
||||
readmeTestedCliVersion: codexReadmeVersion,
|
||||
readmeTestedMinor: minorKey(codexReadmeSemver),
|
||||
localCliVersion: codexLocalVersion,
|
||||
localCliMinor: minorKey(codexLocalSemver),
|
||||
localCliMatchesTestedMinor: codexReadmeSemver && codexLocalSemver ? minorKey(codexReadmeSemver) === minorKey(codexLocalSemver) : null,
|
||||
appServerGenerationExperimental,
|
||||
initializeExperimentalApi,
|
||||
initializeRequestAttestationDisabled,
|
||||
},
|
||||
obsidian: {
|
||||
policy: "manifest declares the minimum app version; obsidian npm provides compile-time API types in the same minor",
|
||||
minAppVersion: obsidianMinVersion,
|
||||
minAppVersionMinor: minorKey(obsidianMinSemver),
|
||||
readmeMinAppVersion: obsidianReadmeMinVersion,
|
||||
versionsJsonCurrentMinAppVersion: obsidianVersionEntry,
|
||||
readmeApiTypesVersion: obsidianReadmeApiTypesVersion,
|
||||
packageDependency: obsidianSpec,
|
||||
packageDependencyRange: packageRangeKind(obsidianSpec),
|
||||
packageDependencyMinor: minorKey(obsidianSpecSemver),
|
||||
lockedPackageVersion: obsidianLockVersion,
|
||||
lockedPackageMinor: minorKey(obsidianLockSemver),
|
||||
},
|
||||
failures,
|
||||
};
|
||||
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
printReport(report);
|
||||
}
|
||||
|
||||
if (failures.length > 0) process.exit(1);
|
||||
|
||||
async function readBaselineInputs() {
|
||||
const [packageJson, packageLockJson, manifestJson, versionsJson, readme, clientSource, appServerGenerateSource] = await Promise.all([
|
||||
readJson("package.json"),
|
||||
readJson("package-lock.json"),
|
||||
readJson("manifest.json"),
|
||||
readJson("versions.json"),
|
||||
readFile("README.md", "utf8"),
|
||||
readFile("src/app-server/connection/client.ts", "utf8"),
|
||||
readFile("scripts/generate-app-server-types.mjs", "utf8"),
|
||||
]);
|
||||
|
||||
return {
|
||||
appServerCompatibilityJson,
|
||||
appServerGenerateSource,
|
||||
clientSource,
|
||||
manifestJson,
|
||||
packageJson,
|
||||
packageLockJson,
|
||||
|
|
@ -271,12 +240,8 @@ async function readBaselineInputs(cwd) {
|
|||
};
|
||||
}
|
||||
|
||||
async function readJson(cwd, file) {
|
||||
return JSON.parse(await readFile(path.join(cwd, file), "utf8"));
|
||||
}
|
||||
|
||||
function isMain() {
|
||||
return process.argv[1] ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false;
|
||||
async function readJson(file) {
|
||||
return JSON.parse(await readFile(file, "utf8"));
|
||||
}
|
||||
|
||||
function printReport(report) {
|
||||
|
|
@ -285,11 +250,10 @@ function printReport(report) {
|
|||
console.log("Codex app-server");
|
||||
console.log(` policy: ${report.codex.policy}`);
|
||||
console.log(` compatibility table CLI: ${displayValue(report.codex.readmeTestedCliVersion)}`);
|
||||
console.log(` recorded tested CLI: ${displayValue(report.codex.recordedTestedCliVersion)}`);
|
||||
console.log(` compatibility table minor: ${displayValue(report.codex.readmeTestedMinor)}`);
|
||||
console.log(` local codex CLI: ${displayValue(report.codex.localCliVersion)}`);
|
||||
console.log(` local codex minor: ${displayValue(report.codex.localCliMinor)}`);
|
||||
console.log(` generation arguments: ${report.codex.generationArguments?.join(" ") ?? "(missing)"}`);
|
||||
console.log(` generate-ts --experimental: ${report.codex.appServerGenerationExperimental ? "yes" : "no"}`);
|
||||
console.log(` initialize experimentalApi: ${report.codex.initializeExperimentalApi ? "yes" : "no"}`);
|
||||
console.log(` initialize requestAttestation disabled: ${report.codex.initializeRequestAttestationDisabled ? "yes" : "no"}`);
|
||||
console.log("");
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const compatibilityRelativePath = "src/app-server/connection/compatibility.json";
|
||||
const exactSemverPattern = /^\d+\.\d+\.\d+$/;
|
||||
|
||||
if (isMain()) {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 1 || args[0] !== "--tested-cli-version") {
|
||||
console.error("Usage: node scripts/app-server-compatibility.mjs --tested-cli-version");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const policy = await readAppServerGenerationPolicy(process.cwd());
|
||||
console.log(policy.testedCliVersion);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAppServerGenerationPolicy(cwd) {
|
||||
const compatibilityPath = path.join(cwd, compatibilityRelativePath);
|
||||
const compatibility = JSON.parse(await readFile(compatibilityPath, "utf8"));
|
||||
const testedCliVersion = compatibility.codexAppServer?.testedCliVersion;
|
||||
const generationArguments = compatibility.codexAppServer?.typeGeneration?.arguments;
|
||||
|
||||
if (typeof testedCliVersion !== "string" || !exactSemverPattern.test(testedCliVersion)) {
|
||||
throw new Error(`${compatibilityRelativePath} must declare codexAppServer.testedCliVersion as X.Y.Z.`);
|
||||
}
|
||||
if (!isStringArray(generationArguments) || generationArguments.length === 0) {
|
||||
throw new Error(`${compatibilityRelativePath} must declare codexAppServer.typeGeneration.arguments as a non-empty string array.`);
|
||||
}
|
||||
if (generationArguments.includes("--out")) {
|
||||
throw new Error(`${compatibilityRelativePath} must not include --out in codexAppServer.typeGeneration.arguments.`);
|
||||
}
|
||||
|
||||
return { generationArguments, testedCliVersion };
|
||||
}
|
||||
|
||||
function isStringArray(value) {
|
||||
return Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.length > 0);
|
||||
}
|
||||
|
||||
function isMain() {
|
||||
return process.argv[1] ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false;
|
||||
}
|
||||
|
|
@ -21,7 +21,6 @@ export async function buildStyles() {
|
|||
}
|
||||
|
||||
export async function renderStyles() {
|
||||
const { version } = JSON.parse(await readFile("package.json", "utf8"));
|
||||
const parts = [];
|
||||
|
||||
const sourceFiles = await readStyleOrder();
|
||||
|
|
@ -30,7 +29,7 @@ export async function renderStyles() {
|
|||
parts.push(content.trimEnd());
|
||||
}
|
||||
|
||||
return `/* Codex Panel v${version} */\n\n${parts.join("\n\n")}\n`;
|
||||
return `${parts.join("\n\n")}\n`;
|
||||
}
|
||||
|
||||
async function readStyleOrder() {
|
||||
|
|
|
|||
60
scripts/check.mjs
Normal file
60
scripts/check.mjs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import concurrently from "concurrently";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const validArgs = new Set(["--ci", "--lint"]);
|
||||
const unknownArgs = args.filter((arg) => !validArgs.has(arg));
|
||||
|
||||
if (unknownArgs.length > 0) {
|
||||
console.error("Usage: node scripts/check.mjs [--ci] [--lint]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ciMode = args.includes("--ci");
|
||||
const lintOnly = args.includes("--lint");
|
||||
const concurrentOptions = {
|
||||
group: true,
|
||||
padPrefix: true,
|
||||
prefix: "name",
|
||||
};
|
||||
|
||||
try {
|
||||
if (lintOnly) {
|
||||
await runConcurrently(lintCommands({ ciMode }));
|
||||
} else {
|
||||
await runConcurrently(checkCommands({ ciMode }));
|
||||
await runConcurrently([{ name: "build", command: "node esbuild.config.mjs" }]);
|
||||
}
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function runConcurrently(commands) {
|
||||
await concurrently(commands, concurrentOptions).result;
|
||||
}
|
||||
|
||||
function checkCommands({ ciMode }) {
|
||||
const suffix = ciMode ? ":ci" : "";
|
||||
const formatArgs = ciMode ? "--check" : "--check --cache";
|
||||
|
||||
return [
|
||||
{ name: "typecheck", command: `npm run --silent typecheck${suffix}` },
|
||||
{ name: "test", command: `npm run --silent test${suffix}` },
|
||||
...lintCommands({ ciMode, namePrefix: "lint:" }),
|
||||
{ name: "format:check", command: `node scripts/format.mjs ${formatArgs}` },
|
||||
];
|
||||
}
|
||||
|
||||
function lintCommands({ ciMode, namePrefix = "" }) {
|
||||
const eslintCacheArgs = ciMode ? "" : " --cache --cache-strategy content --cache-location node_modules/.cache/eslint/.eslintcache";
|
||||
|
||||
return [
|
||||
{
|
||||
name: `${namePrefix}ts`,
|
||||
command: `eslint src tests scripts "*.config.ts" "*.config.mjs" --max-warnings=0${eslintCacheArgs}`,
|
||||
},
|
||||
{ name: `${namePrefix}css`, command: 'stylelint "src/**/*.css" --max-warnings=0' },
|
||||
{ name: `${namePrefix}css-usage`, command: "node scripts/lint/check-css-usage.mjs" },
|
||||
{ name: `${namePrefix}deps`, command: "node scripts/lint/check-import-cycles.mjs" },
|
||||
{ name: `${namePrefix}unused`, command: "knip --no-progress" },
|
||||
];
|
||||
}
|
||||
154
scripts/format.mjs
Normal file
154
scripts/format.mjs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import prettier from "prettier";
|
||||
|
||||
const sourceExtensions = new Set([".ts", ".tsx", ".js", ".mjs", ".json", ".css", ".md"]);
|
||||
const scriptExtensions = new Set([".js", ".mjs"]);
|
||||
const rootExtensions = new Set([".ts", ".json", ".mjs", ".md", ".css"]);
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const shouldCheck = args.check;
|
||||
const shouldUseCache = args.cache;
|
||||
const cacheLocation = args.cacheLocation ?? "node_modules/.cache/prettier/.prettier-cache";
|
||||
const root = process.cwd();
|
||||
const files = await formatFiles();
|
||||
const cache = shouldCheck && shouldUseCache ? await readCache(cacheLocation) : new Map();
|
||||
const nextCache = new Map(cache);
|
||||
const unformattedFiles = [];
|
||||
let hadError = false;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const info = await prettier.getFileInfo(file, { ignorePath: path.join(root, ".prettierignore") });
|
||||
if (info.ignored || info.inferredParser === null) continue;
|
||||
|
||||
const input = await readFile(file, "utf8");
|
||||
const config = (await prettier.resolveConfig(file)) ?? {};
|
||||
const cacheKey = path.relative(root, file);
|
||||
const cacheHash = hashFormatInput(input, config, info.inferredParser);
|
||||
if (shouldCheck && cache.get(cacheKey) === cacheHash) continue;
|
||||
|
||||
const output = await prettier.format(input, { ...config, filepath: file });
|
||||
|
||||
if (output === input) {
|
||||
nextCache.set(cacheKey, cacheHash);
|
||||
continue;
|
||||
}
|
||||
if (shouldCheck) {
|
||||
nextCache.delete(cacheKey);
|
||||
unformattedFiles.push(cacheKey);
|
||||
continue;
|
||||
}
|
||||
await writeFile(file, output);
|
||||
console.log(path.relative(root, file));
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`${path.relative(root, file)}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldCheck && shouldUseCache) await writeCache(cacheLocation, nextCache);
|
||||
if (unformattedFiles.length > 0) {
|
||||
console.error("Unformatted files:");
|
||||
for (const file of unformattedFiles) console.error(file);
|
||||
process.exit(1);
|
||||
}
|
||||
if (hadError) process.exit(1);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {
|
||||
cache: false,
|
||||
cacheLocation: null,
|
||||
check: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--cache") {
|
||||
parsed.cache = true;
|
||||
} else if (arg === "--check") {
|
||||
parsed.check = true;
|
||||
} else if (arg === "--cache-location") {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("--")) usage();
|
||||
parsed.cacheLocation = value;
|
||||
index += 1;
|
||||
} else {
|
||||
usage();
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.error("Usage: node scripts/format.mjs [--check] [--cache] [--cache-location PATH]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function readCache(file) {
|
||||
try {
|
||||
const value = JSON.parse(await readFile(path.join(root, file), "utf8"));
|
||||
return new Map(Object.entries(value.files ?? {}));
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCache(file, cacheEntries) {
|
||||
const absoluteFile = path.join(root, file);
|
||||
await mkdir(path.dirname(absoluteFile), { recursive: true });
|
||||
const files = Object.fromEntries([...cacheEntries.entries()].sort(([left], [right]) => left.localeCompare(right)));
|
||||
await writeFile(absoluteFile, `${JSON.stringify({ version: 1, files })}\n`);
|
||||
}
|
||||
|
||||
function hashFormatInput(input, config, inferredParser) {
|
||||
return createHash("sha256")
|
||||
.update(prettier.version)
|
||||
.update("\0")
|
||||
.update(inferredParser ?? "")
|
||||
.update("\0")
|
||||
.update(JSON.stringify(config))
|
||||
.update("\0")
|
||||
.update(input)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
async function formatFiles() {
|
||||
const result = [
|
||||
...(await filesInTree("src", sourceExtensions, { excludedPrefixes: ["src/generated"] })),
|
||||
...(await filesInTree("tests", sourceExtensions)),
|
||||
...(await filesInTree("scripts", scriptExtensions)),
|
||||
...(await rootFiles(rootExtensions)),
|
||||
];
|
||||
return [...new Set(result)].sort((a, b) => path.relative(root, a).localeCompare(path.relative(root, b)));
|
||||
}
|
||||
|
||||
async function rootFiles(extensions) {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
return entries.filter((entry) => entry.isFile() && extensions.has(path.extname(entry.name))).map((entry) => path.join(root, entry.name));
|
||||
}
|
||||
|
||||
async function filesInTree(relativeDirectory, extensions, options = {}) {
|
||||
const directory = path.join(root, relativeDirectory);
|
||||
const excludedPrefixes = options.excludedPrefixes?.map((prefix) => path.join(root, prefix)) ?? [];
|
||||
const result = [];
|
||||
await collectFiles(directory, extensions, excludedPrefixes, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function collectFiles(directory, extensions, excludedPrefixes, result) {
|
||||
if (excludedPrefixes.some((prefix) => directory === prefix || directory.startsWith(`${prefix}${path.sep}`))) return;
|
||||
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await collectFiles(file, extensions, excludedPrefixes, result);
|
||||
} else if (entry.isFile() && extensions.has(path.extname(entry.name))) {
|
||||
result.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,105 +1,21 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readAppServerGenerationPolicy } from "./app-server-compatibility.mjs";
|
||||
|
||||
const generatedRelativeDir = "src/generated/app-server";
|
||||
const generatedDir = path.resolve("src/generated/app-server");
|
||||
const generatedHeader = "// GENERATED CODE! DO NOT MODIFY BY HAND!";
|
||||
const normalizationNotice = "// This file was mechanically normalized after generation by scripts/generate-app-server-types.mjs.";
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.some((arg) => arg !== "--check") || args.length > 1) {
|
||||
throw new Error("Usage: node scripts/generate-app-server-types.mjs [--check]");
|
||||
}
|
||||
await generateAppServerTypes({ check: args.includes("--check") });
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
await cleanGeneratedTypes();
|
||||
run("codex", ["app-server", "generate-ts", "--experimental", "--out", "src/generated/app-server"]);
|
||||
await normalizeGeneratedTypes();
|
||||
|
||||
async function cleanGeneratedTypes() {
|
||||
await rm(generatedDir, { recursive: true, force: true });
|
||||
await mkdir(generatedDir, { recursive: true });
|
||||
}
|
||||
|
||||
export async function generateAppServerTypes(options = {}) {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const generatedDir = path.resolve(cwd, generatedRelativeDir);
|
||||
const runCommand = options.runCommand ?? run;
|
||||
const readCodexVersion = options.readCodexVersion ?? readInstalledCodexVersion;
|
||||
const policy = await readAppServerGenerationPolicy(cwd);
|
||||
const installedCliVersion = await readCodexVersion(cwd);
|
||||
if (installedCliVersion !== policy.testedCliVersion) {
|
||||
throw new Error(
|
||||
`Codex CLI ${policy.testedCliVersion} is required to generate app-server bindings; found ${installedCliVersion ?? "an unreadable version"}.`,
|
||||
);
|
||||
}
|
||||
const generatedParent = path.dirname(generatedDir);
|
||||
await mkdir(generatedParent, { recursive: true });
|
||||
const stagedDir = await mkdtemp(path.join(generatedParent, ".app-server-"));
|
||||
try {
|
||||
const stagedRelativeDir = path.relative(cwd, stagedDir);
|
||||
await runCommand("codex", [...policy.generationArguments, "--out", stagedRelativeDir], { cwd });
|
||||
await normalizeGeneratedTypes(stagedDir);
|
||||
if (options.check === true) {
|
||||
const differences = await compareGeneratedTrees(generatedDir, stagedDir);
|
||||
if (differences.length > 0) {
|
||||
throw new Error(`generated app-server bindings are out of date:\n${differences.map((difference) => ` ${difference}`).join("\n")}`);
|
||||
}
|
||||
} else {
|
||||
await replaceGeneratedTypes(generatedDir, stagedDir);
|
||||
}
|
||||
} finally {
|
||||
await rm(stagedDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function compareGeneratedTrees(trackedDir, stagedDir) {
|
||||
const [trackedFiles, stagedFiles] = await Promise.all([listFiles(trackedDir), listFiles(stagedDir)]);
|
||||
const trackedSet = new Set(trackedFiles);
|
||||
const stagedSet = new Set(stagedFiles);
|
||||
const differences = [];
|
||||
|
||||
for (const file of stagedFiles) {
|
||||
if (!trackedSet.has(file)) {
|
||||
differences.push(`added: ${file}`);
|
||||
continue;
|
||||
}
|
||||
const [trackedSource, stagedSource] = await Promise.all([readFile(path.join(trackedDir, file)), readFile(path.join(stagedDir, file))]);
|
||||
if (!trackedSource.equals(stagedSource)) differences.push(`changed: ${file}`);
|
||||
}
|
||||
for (const file of trackedFiles) {
|
||||
if (!stagedSet.has(file)) differences.push(`removed: ${file}`);
|
||||
}
|
||||
|
||||
return differences;
|
||||
}
|
||||
|
||||
async function replaceGeneratedTypes(generatedDir, stagedDir) {
|
||||
const backupDir = `${generatedDir}.backup-${process.pid.toString()}-${Date.now().toString()}`;
|
||||
let hasBackup = false;
|
||||
try {
|
||||
try {
|
||||
await rename(generatedDir, backupDir);
|
||||
hasBackup = true;
|
||||
} catch (error) {
|
||||
if (!isMissingPathError(error)) throw error;
|
||||
}
|
||||
await rename(stagedDir, generatedDir);
|
||||
if (hasBackup) await rm(backupDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
if (hasBackup) {
|
||||
await rm(generatedDir, { recursive: true, force: true });
|
||||
await rename(backupDir, generatedDir);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingPathError(error) {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
|
||||
async function normalizeGeneratedTypes(generatedDir) {
|
||||
async function normalizeGeneratedTypes() {
|
||||
const files = await listTypeScriptFiles(generatedDir);
|
||||
|
||||
await Promise.all(
|
||||
|
|
@ -124,24 +40,6 @@ async function listTypeScriptFiles(dir) {
|
|||
return files.flat();
|
||||
}
|
||||
|
||||
async function listFiles(dir, relativeDir = "") {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(path.join(dir, relativeDir), { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
const files = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const relativePath = path.join(relativeDir, entry.name);
|
||||
if (entry.isDirectory()) return listFiles(dir, relativePath);
|
||||
return entry.isFile() ? [relativePath.replaceAll(path.sep, "/")] : [];
|
||||
}),
|
||||
);
|
||||
return files.flat().sort();
|
||||
}
|
||||
|
||||
function normalizeSource(source) {
|
||||
let normalized = source;
|
||||
do {
|
||||
|
|
@ -157,25 +55,14 @@ function addNormalizationNotice(source) {
|
|||
return source.replace(generatedHeader, `${generatedHeader}\n${normalizationNotice}`);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to run ${command} ${args.join(" ")}: ${result.error.message}`);
|
||||
console.error(`Failed to run ${command} ${args.join(" ")}: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} exited with status ${(result.status ?? 1).toString()}.`);
|
||||
}
|
||||
|
||||
function readInstalledCodexVersion(cwd) {
|
||||
const result = spawnSync("codex", ["--version"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
shell: false,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
return `${result.stdout}\n${result.stderr}`.match(/\b\d+\.\d+\.\d+\b/)?.[0] ?? null;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where { $source <: r"^[\"'].*shared/dom/events\.dom[\"']$" },
|
||||
register_diagnostic(span=$stmt, message="Import DOM event listener helpers only from explicit .dom, .obsidian, or .measure bridge files.", severity="error")
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
language js
|
||||
|
||||
or {
|
||||
`$target.createElement($...)` as $stmt,
|
||||
`$target.createTextNode($...)` as $stmt,
|
||||
`$target.createEl($...)` as $stmt,
|
||||
`$target.createDiv($...)` as $stmt,
|
||||
`$target.createSpan($...)` as $stmt,
|
||||
`$target.empty()` as $stmt,
|
||||
`$target.append($...)` as $stmt,
|
||||
`$target.prepend($...)` as $stmt,
|
||||
`$target.before($...)` as $stmt,
|
||||
`$target.after($...)` as $stmt,
|
||||
`$target.appendChild($...)` as $stmt,
|
||||
`$target.insertBefore($...)` as $stmt,
|
||||
`$target.replaceChildren($...)` as $stmt,
|
||||
`$target.remove()` as $stmt,
|
||||
`$target.insertAdjacentHTML($...)` as $stmt,
|
||||
`$target.setAttribute($...)` as $stmt,
|
||||
`$target.setAttr($...)` as $stmt,
|
||||
`$target.addClass($...)` as $stmt,
|
||||
`$target.removeClass($...)` as $stmt,
|
||||
`$target.toggleClass($...)` as $stmt,
|
||||
`$target.setText($...)` as $stmt,
|
||||
`$target.classList.add($...)` as $stmt,
|
||||
`$target.classList.remove($...)` as $stmt,
|
||||
`$target.classList.toggle($...)` as $stmt,
|
||||
`$target.addEventListener($...)` as $stmt,
|
||||
`$target.removeEventListener($...)` as $stmt,
|
||||
`$target.querySelector($...)` as $stmt,
|
||||
`$target.querySelectorAll($...)` as $stmt,
|
||||
`$target.closest($...)` as $stmt,
|
||||
`$target.contains($...)` as $stmt,
|
||||
`$target.getBoundingClientRect()` as $stmt,
|
||||
`$target.focus($...)` as $stmt,
|
||||
`$target.select()` as $stmt,
|
||||
`$target.setSelectionRange($...)` as $stmt,
|
||||
`$target.setCssProps($...)` as $stmt,
|
||||
`$target.scrollHeight` as $stmt,
|
||||
`$target.scrollWidth` as $stmt,
|
||||
`$target.clientHeight` as $stmt,
|
||||
`$target.clientWidth` as $stmt,
|
||||
`$target.offsetTop` as $stmt,
|
||||
`$target.offsetHeight` as $stmt,
|
||||
`$target.selectionStart` as $stmt,
|
||||
`$target.selectionEnd` as $stmt,
|
||||
`$target.selectionDirection` as $stmt,
|
||||
`$target.activeElement` as $stmt,
|
||||
`$target.ownerDocument` as $stmt,
|
||||
`$target.defaultView` as $stmt,
|
||||
`$target.style.$property` as $stmt
|
||||
} where {
|
||||
not { $stmt <: `$target.addEventListener("abort", $...)` },
|
||||
not { $stmt <: `$target.removeEventListener("abort", $...)` },
|
||||
register_diagnostic(span=$stmt, message="Keep DOM reads, writes, measurements, hit-tests, focus, and event wiring in files named with a .dom, .obsidian, or .measure suffix.", severity="error")
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where { $source <: r"^[\"'].*shared/dom/preact-root\.dom[\"']$" },
|
||||
register_diagnostic(span=$stmt, message="Import the Preact root adapter only from explicit root bridge files.", severity="error")
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
private pattern app_server_protocol_connection_source() { r"^[\"'](?:\.\./)+connection(?:/.*)?[\"']$" }
|
||||
|
||||
private pattern root_app_server_connection_source() {
|
||||
or {
|
||||
r"^[\"'](?:\.\./)+app-server/connection(?:/.*)?[\"']$",
|
||||
r"^[\"']src/app-server/connection(?:/.*)?[\"']$"
|
||||
}
|
||||
}
|
||||
|
||||
private pattern app_server_connection_boundary_source() {
|
||||
or {
|
||||
app_server_protocol_connection_source(),
|
||||
root_app_server_connection_source()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where { $source <: app_server_connection_boundary_source() },
|
||||
register_diagnostic(span=$stmt, message="Do not import app-server connection internals from this module. Keep connection usage at app-server adapters.", severity="error")
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
private pattern app_server_protocol_source() { r"^[\"'](?:(?:\.\./)+app-server/protocol|src/app-server/protocol)/[^/\"']+(?:/.*)?[\"']$" }
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where { $source <: app_server_protocol_source() },
|
||||
register_diagnostic(span=$stmt, message="Source modules outside root src/app-server must use domain models and app-server services instead of app-server protocol modules. Chat turn-item conversion may consume turn protocol at its app-server boundary; feature state and UI must use Panel-owned models.", severity="error")
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where { $source <: r"^[\"']\.\./[^/\"']+[\"']$" },
|
||||
register_diagnostic(span=$stmt, message="App-server subfolders must not import sibling root modules; move the dependency into a responsibility subfolder.", severity="error")
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where {
|
||||
$source <: r"^[\"'](?:(?:\.\./)+(?:host|panel|presentation|ui)|src/features/chat/(?:host|panel|presentation|ui))(?:/.*)?[\"']$"
|
||||
},
|
||||
register_diagnostic(span=$stmt, message="Chat app-server adapters must not import chat host, panel, presentation, or UI layers.", severity="error")
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where {
|
||||
$source <: r"^[\"'](?:(?:\.\./)+(?:app-server|host|panel|presentation|ui)|(?:\.\./){2,}(?:selection-rewrite|thread-picker|threads|threads-view|turn-diff)|src/(?:app-server|features/(?:chat/(?:app-server|host|panel|presentation|ui)|selection-rewrite|thread-picker|threads|threads-view|turn-diff)))(?:/.*)?[\"']$"
|
||||
},
|
||||
register_diagnostic(span=$stmt, message="Chat application modules must not import app-server, sibling feature, host, panel, presentation, or UI layers; expose state and workflow contracts instead.", severity="error")
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where {
|
||||
$source <: r"^[\"'](?:(?:\.\./)+(?:presentation|ui)|src/features/chat/(?:presentation|ui))(?:/.*)?[\"']$"
|
||||
},
|
||||
register_diagnostic(span=$stmt, message="Chat host modules must not import chat presentation or UI layers; route render-facing projections through panel-owned adapters.", severity="error")
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where {
|
||||
$source <: r"^[\"'](?:(?:\.\./)+(?:app-server|host)|src/(?:app-server|features/chat/(?:app-server|host)))(?:/.*)?[\"']$"
|
||||
},
|
||||
register_diagnostic(span=$stmt, message="Chat panel modules must not import app-server adapters or chat host internals.", severity="error")
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where {
|
||||
$source <: r"^[\"'](?:(?:\.\./)+(?:application|app-server|host|panel|ui)|src/(?:app-server|features/chat/(?:application|app-server|host|panel|ui)))(?:/.*)?[\"']$"
|
||||
},
|
||||
register_diagnostic(span=$stmt, message="Chat presentation modules must stay pure view-model projection; keep application, app-server, host, panel, and UI dependencies outward.", severity="error")
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
private pattern app_server_protocol_source() { r"^[\"'](?:(?:\.\./)+app-server/protocol|src/app-server/protocol)/[^/\"']+(?:/.*)?[\"']$" }
|
||||
|
||||
private pattern turn_protocol_source() { r"^[\"'](?:(?:\.\./)+app-server/protocol/turn|src/app-server/protocol/turn)(?:/.*)?[\"']$" }
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where { $source <: app_server_protocol_source(), not { $source <: turn_protocol_source() } },
|
||||
register_diagnostic(span=$stmt, message="Source modules outside root src/app-server must use domain models and app-server services instead of app-server protocol modules. Chat turn-item conversion may consume turn protocol at its app-server boundary; feature state and UI must use Panel-owned models.", severity="error")
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where {
|
||||
$source <: r"^[\"'](?:(?:\.\./)+(?:application|app-server|host|panel)|src/(?:app-server|features/chat/(?:application|app-server|host|panel)))(?:/.*)?[\"']$"
|
||||
},
|
||||
register_diagnostic(span=$stmt, message="Chat UI modules must not import application, app-server, host, or panel layers; pass render-ready props and actions through UI contracts.", severity="error")
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where { $source <: r"^[\"'](?:(?:\.\./)+workspace|src/workspace)(?:/.*)?[\"']$" },
|
||||
register_diagnostic(span=$stmt, message="Chat modules must not import workspace modules; pass workspace capabilities through chat host contracts.", severity="error")
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
private pattern root_outer_layer_source() {
|
||||
or {
|
||||
r"^[\"'](?:\.\./)+(?:app-server|settings|workspace|shared|features)(?:/.*)?[\"']$",
|
||||
r"^[\"']src/(?:app-server|settings|workspace|shared|features)(?:/.*)?[\"']$",
|
||||
r"^[\"']obsidian[\"']$"
|
||||
}
|
||||
}
|
||||
|
||||
private pattern chat_domain_outer_layer_source() { r"^[\"'](?:\.\./)+(?:application|host|panel|presentation|ui)(?:/.*)?[\"']$" }
|
||||
|
||||
private pattern domain_outer_layer_source() {
|
||||
or {
|
||||
root_outer_layer_source(),
|
||||
chat_domain_outer_layer_source()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where { $source <: domain_outer_layer_source() },
|
||||
register_diagnostic(span=$stmt, message="Domain modules must stay pure; outer layers may depend on domain, not the reverse.", severity="error")
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where { $source <: r"^[\"'](?:(?:\.\./)+workspace|src/workspace)(?:/.*)?[\"']$" },
|
||||
register_diagnostic(span=$stmt, message="Feature modules must not import workspace modules; pass workspace capabilities through feature host contracts.", severity="error")
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
private pattern generated_app_server_source() { r"^[\"'].*(?:src/)?generated/app-server(?:/.*)?[\"']$" }
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where { $source <: generated_app_server_source() },
|
||||
register_diagnostic(span=$stmt, message="Keep generated app-server types behind src/app-server adapters; expose Panel-owned models outside raw app-server boundaries.", severity="error")
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where { $source <: r"^[\"'](?:(?:\.\./)+features|src/features)(?:/.*)?[\"']$" },
|
||||
register_diagnostic(span=$stmt, message="Do not import feature modules from this layer. Move reusable behavior to domain, shared DOM/Obsidian/runtime/UI, or app-server adapters.", severity="error")
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where {
|
||||
$source <: r"^[\"'](?:(?:\.\./)+app-server/(?:connection|services)|src/app-server/(?:connection|services))(?:/.*)?[\"']$"
|
||||
},
|
||||
register_diagnostic(span=$stmt, message="Thread workflows must depend on feature-owned ports instead of app-server clients or services.", severity="error")
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
language js
|
||||
|
||||
private pattern js_module_reference() {
|
||||
or {
|
||||
JsImport(),
|
||||
JsImportCallExpression()
|
||||
}
|
||||
}
|
||||
|
||||
js_module_reference() as $stmt where {
|
||||
$stmt <: contains `$source` where {
|
||||
$source <: r"^[\"'](?:\.\./features/chat/(?:application|app-server|domain|panel|presentation|ui)|src/features/chat/(?:application|app-server|domain|panel|presentation|ui))(?:/.*)?[\"']$"
|
||||
},
|
||||
register_diagnostic(span=$stmt, message="Workspace modules may coordinate chat only through chat host contracts and Obsidian views.", severity="error")
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
engine biome(1.0)
|
||||
language css
|
||||
|
||||
or {
|
||||
// Keep colors on Obsidian or Codex Panel design tokens.
|
||||
CssDeclarationWithSemicolon() as $decl where {
|
||||
$decl <: r"\s*(?:background(?:-color)?|border(?:-(?:block|inline|top|right|bottom|left))?(?:-color)?|box-shadow|caret-color|color|fill|outline(?:-color)?|stroke|text-shadow): .*?(?:#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\(|\b(?:black|white)\b).*",
|
||||
register_diagnostic(span=$decl, message="Use Obsidian or Codex Panel design tokens instead of hardcoded colors.")
|
||||
},
|
||||
CssDeclarationWithSemicolon() as $decl where {
|
||||
$decl <: r".*--codex-panel-.*(?:accent|background|border|color|danger|error|faint|muted|ring|success|surface|text|warning).*: .*?(?:#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\(|\b(?:black|white)\b).*",
|
||||
register_diagnostic(span=$decl, message="Use Obsidian or Codex Panel design tokens instead of hardcoded colors.")
|
||||
},
|
||||
CssDeclarationWithSemicolon() as $decl where {
|
||||
$decl <: r".*\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\(.*",
|
||||
register_diagnostic(span=$decl, message="Use Obsidian or Codex Panel design tokens instead of color functions.")
|
||||
},
|
||||
// Keep typography and layout dimensions on shared variables.
|
||||
CssDeclarationWithSemicolon() as $decl where {
|
||||
$decl <: r"\s*font-size: .*?\b\d*\.?\d+(?:px|rem|em|%)\b.*",
|
||||
register_diagnostic(span=$decl, message="Use Obsidian or Codex Panel typography tokens instead of hardcoded font sizes.")
|
||||
},
|
||||
CssDeclarationWithSemicolon() as $decl where {
|
||||
$decl <: r"\s*font-weight:\s*(?:[1-9]00|bold|bolder|lighter|normal)\s*;.*",
|
||||
register_diagnostic(span=$decl, message="Use Obsidian or Codex Panel typography tokens instead of hardcoded font weights.")
|
||||
},
|
||||
CssDeclarationWithSemicolon() as $decl where {
|
||||
$decl <: r"\s*line-height: .*?(?:\b\d*\.?\d+\b|\b\d*\.?\d+(?:px|rem|em|%)\b).*",
|
||||
register_diagnostic(span=$decl, message="Use Obsidian or Codex Panel typography tokens instead of hardcoded line heights.")
|
||||
},
|
||||
CssDeclarationWithSemicolon() as $decl where {
|
||||
$decl <: r"\s*(?:border(?:-(?:block|inline|top|right|bottom|left))?-width|border-radius|bottom|column-gap|gap|height|inset|inset-(?:block|inline)|left|margin|margin-(?:block|inline|top|right|bottom|left)|max-height|max-width|min-height|min-width|padding|padding-(?:block|inline|top|right|bottom|left)|right|row-gap|top|width): .*?\b\d*\.?\d+(?:px|rem|em)\b.*",
|
||||
register_diagnostic(span=$decl, message="Prefer Obsidian or Codex Panel spacing and size tokens for layout dimensions.")
|
||||
},
|
||||
// Keep selectors local and predictable.
|
||||
CssPseudoClassSelector() as $selector where {
|
||||
$selector <: r".*:has.*",
|
||||
register_diagnostic(span=$selector, message="Avoid :has() because it can cause broad selector invalidation.")
|
||||
},
|
||||
CssPseudoClassSelector() as $selector where {
|
||||
$selector <: r".*:where[(].*(?:[.]|#|\[).*",
|
||||
register_diagnostic(span=$selector, message="Do not hide class, id, or attribute selectors inside :where().")
|
||||
},
|
||||
CssIdSelector() as $selector where { register_diagnostic(span=$selector, message="Avoid ID selectors in Codex Panel CSS.") },
|
||||
CssUniversalSelector() as $selector where {
|
||||
register_diagnostic(span=$selector, message="Avoid universal selectors in Codex Panel CSS.")
|
||||
},
|
||||
CssAtRule() as $rule where {
|
||||
$rule <: r"@keyframes ([a-zA-Z0-9_-]+)[\s\S]*"($name),
|
||||
not $name <: r"codex-panel-[a-z0-9-]+",
|
||||
register_diagnostic(span=$rule, message="Prefix keyframes with codex-panel-.")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
language js
|
||||
|
||||
`$target.request($...)` as $stmt where {
|
||||
register_diagnostic(span=$stmt, message="Keep direct app-server RPC calls behind app-server services and chat app-server adapters.", severity="error")
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
language js
|
||||
|
||||
or {
|
||||
`Date.now()` as $stmt,
|
||||
`new Date()` as $stmt,
|
||||
`Math.random()` as $stmt,
|
||||
`new AppServerClient($...)` as $stmt,
|
||||
`new ConnectionManager($...)` as $stmt,
|
||||
`new Notice($...)` as $stmt,
|
||||
`setTimeout($...)` as $stmt,
|
||||
`clearTimeout($...)` as $stmt,
|
||||
`requestAnimationFrame($...)` as $stmt,
|
||||
`document.$property` as $stmt,
|
||||
`localStorage.$property` as $stmt,
|
||||
`sessionStorage.$property` as $stmt
|
||||
} where {
|
||||
register_diagnostic(span=$stmt, message="Keep this state module deterministic and free of app-server, Obsidian, scheduling, and browser side effects.", severity="error")
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
language js
|
||||
|
||||
or {
|
||||
JsExportNamedFromClause() as $stmt,
|
||||
JsExportFromClause() as $stmt
|
||||
} where {
|
||||
register_diagnostic(span=$stmt, message="Do not add hand-written re-exports. Import from the owning module directly.", severity="error")
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
language js
|
||||
|
||||
`$program` where {
|
||||
register_diagnostic(span=$program, message="Keep TSX files in rendering-owned source folders; non-rendering source should use .ts.", severity="error")
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
language js
|
||||
|
||||
`$program` where {
|
||||
register_diagnostic(span=$program, message="Keep responsibility-split source roots free of module files; add modules to the matching subfolder instead of the root.", severity="error")
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
language js
|
||||
|
||||
`const $name = new $constructor($callback)` as $stmt where {
|
||||
or { $callback <: `() => $body`, $callback <: `function() { $body }` },
|
||||
$body <: contains `$name`,
|
||||
register_diagnostic(span=$stmt, message="Avoid referencing a variable from a callback inside its own initializer; declare it first with an explicit type.", severity="error")
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
language js
|
||||
|
||||
JsxAttribute(name=$name) as $stmt where {
|
||||
or { $name <: `defaultValue`, $name <: `defaultChecked` },
|
||||
register_diagnostic(span=$stmt, message="Keep Preact form state explicit with controlled value or checked props.", severity="error")
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
language js
|
||||
|
||||
`$iterator.next().value` as $stmt where {
|
||||
register_diagnostic(span=$stmt, message="Avoid reading iterator.next().value directly; use for...of or inspect the typed IteratorResult first.", severity="error")
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import path from "node:path";
|
|||
const root = process.cwd();
|
||||
const stylesDir = path.join(root, "src", "styles");
|
||||
if (process.argv.length > 2) {
|
||||
console.error("Usage: node scripts/check-css-usage.mjs");
|
||||
console.error("Usage: node scripts/lint/check-css-usage.mjs");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
@ -15,8 +15,6 @@ const sourceFiles = await filesInTree(path.join(root, "src"), new Set([".ts", ".
|
|||
const testFiles = await filesInTree(path.join(root, "tests"), new Set([".ts", ".tsx"]));
|
||||
|
||||
const cssClasses = await collectCssClasses(cssFiles);
|
||||
const cssCustomProperties = await collectCssCustomProperties(cssFiles);
|
||||
const referencedCssCustomProperties = await collectReferencedCssCustomProperties(cssFiles);
|
||||
const sourceTexts = await readTexts(sourceFiles);
|
||||
const testTexts = await readTexts(testFiles);
|
||||
const sourceText = sourceTexts.map((item) => item.text).join("\n");
|
||||
|
|
@ -24,12 +22,14 @@ const dynamicPrefixes = collectDynamicClassPrefixes(sourceText);
|
|||
|
||||
const testOnlyCandidates = [];
|
||||
const candidates = [];
|
||||
const unusedCustomProperties = [];
|
||||
|
||||
for (const [className, locations] of cssClasses) {
|
||||
const sourceMatches = locationsInTexts(sourceTexts, className);
|
||||
if (sourceMatches.length > 0) continue;
|
||||
|
||||
const matchingPrefix = dynamicPrefixes.find((prefix) => className.startsWith(prefix));
|
||||
if (matchingPrefix) continue;
|
||||
|
||||
const testMatches = locationsInTexts(testTexts, className);
|
||||
if (testMatches.length > 0) {
|
||||
testOnlyCandidates.push({ className, locations, testMatches });
|
||||
|
|
@ -39,13 +39,8 @@ for (const [className, locations] of cssClasses) {
|
|||
candidates.push({ className, locations });
|
||||
}
|
||||
|
||||
for (const [propertyName, locations] of cssCustomProperties) {
|
||||
if (referencedCssCustomProperties.has(propertyName) || locationsInTexts(sourceTexts, propertyName).length > 0) continue;
|
||||
unusedCustomProperties.push({ propertyName, locations });
|
||||
}
|
||||
|
||||
if (candidates.length + testOnlyCandidates.length + unusedCustomProperties.length + dynamicPrefixes.length > 0) {
|
||||
printCandidates({ testOnlyCandidates, candidates, unusedCustomProperties, dynamicPrefixes });
|
||||
if (candidates.length + testOnlyCandidates.length > 0) {
|
||||
printCandidates({ testOnlyCandidates, candidates });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
@ -61,11 +56,12 @@ async function orderedCssFiles() {
|
|||
async function collectCssClasses(files) {
|
||||
const result = new Map();
|
||||
for (const file of files) {
|
||||
const text = stripCssComments(await readFile(file, "utf8"));
|
||||
const text = await readFile(file, "utf8");
|
||||
const lines = text.split("\n");
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const withoutComment = line.replace(/\/\*.*?\*\//g, "");
|
||||
const classPattern = /(^|[^\\])\.([_a-zA-Z][\w-]*)/g;
|
||||
for (const match of line.matchAll(classPattern)) {
|
||||
for (const match of withoutComment.matchAll(classPattern)) {
|
||||
const className = match[2];
|
||||
if (!className.startsWith("codex-panel")) continue;
|
||||
const locations = result.get(className) ?? [];
|
||||
|
|
@ -77,38 +73,6 @@ async function collectCssClasses(files) {
|
|||
return new Map([...result.entries()].sort(([left], [right]) => left.localeCompare(right)));
|
||||
}
|
||||
|
||||
async function collectCssCustomProperties(files) {
|
||||
const result = new Map();
|
||||
for (const file of files) {
|
||||
const text = stripCssComments(await readFile(file, "utf8"));
|
||||
const lines = text.split("\n");
|
||||
for (const [index, line] of lines.entries()) {
|
||||
for (const match of line.matchAll(/(--codex-panel-[\w-]+)\s*:/g)) {
|
||||
const propertyName = match[1];
|
||||
const locations = result.get(propertyName) ?? [];
|
||||
locations.push(`${relative(file)}:${index + 1}`);
|
||||
result.set(propertyName, locations);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Map([...result.entries()].sort(([left], [right]) => left.localeCompare(right)));
|
||||
}
|
||||
|
||||
async function collectReferencedCssCustomProperties(files) {
|
||||
const result = new Set();
|
||||
for (const file of files) {
|
||||
const text = stripCssComments(await readFile(file, "utf8"));
|
||||
for (const match of text.matchAll(/var\(\s*(--codex-panel-[\w-]+)/g)) {
|
||||
result.add(match[1]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stripCssComments(text) {
|
||||
return text.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ""));
|
||||
}
|
||||
|
||||
function collectDynamicClassPrefixes(text) {
|
||||
const prefixes = new Set();
|
||||
const templatePrefixPattern = /codex-panel(?:-[a-z]+)?__[a-z0-9-]+--\$\{/g;
|
||||
|
|
@ -128,12 +92,9 @@ async function readTexts(files) {
|
|||
|
||||
function locationsInTexts(texts, needle) {
|
||||
const locations = [];
|
||||
const escapedNeedle = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(`(^|[^\\w-])${escapedNeedle}(?![\\w-])`);
|
||||
for (const { file, text } of texts) {
|
||||
const match = pattern.exec(text);
|
||||
if (!match) continue;
|
||||
const offset = match.index + match[1].length;
|
||||
let offset = text.indexOf(needle);
|
||||
if (offset === -1) continue;
|
||||
const line = text.slice(0, offset).split("\n").length;
|
||||
locations.push(`${relative(file)}:${line}`);
|
||||
}
|
||||
|
|
@ -161,16 +122,8 @@ async function collectFiles(directory, extensions, excludedPrefixes, result) {
|
|||
}
|
||||
}
|
||||
|
||||
function printCandidates({ testOnlyCandidates, candidates, unusedCustomProperties, dynamicPrefixes }) {
|
||||
function printCandidates({ testOnlyCandidates, candidates }) {
|
||||
console.error("CSS usage check failed.");
|
||||
if (dynamicPrefixes.length > 0) {
|
||||
console.error("");
|
||||
console.error("Dynamic CSS class prefixes are not allowed:");
|
||||
for (const prefix of dynamicPrefixes) {
|
||||
console.error(` ${prefix}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (testOnlyCandidates.length > 0) {
|
||||
console.error("");
|
||||
console.error("Test-only unused CSS class candidates:");
|
||||
|
|
@ -189,15 +142,6 @@ function printCandidates({ testOnlyCandidates, candidates, unusedCustomPropertie
|
|||
console.error(` css: ${item.locations.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (unusedCustomProperties.length > 0) {
|
||||
console.error("");
|
||||
console.error("Unused CSS custom property candidates:");
|
||||
for (const item of unusedCustomProperties) {
|
||||
console.error(` ${item.propertyName}`);
|
||||
console.error(` css: ${item.locations.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function relative(file) {
|
||||
213
scripts/lint/check-import-cycles.mjs
Normal file
213
scripts/lint/check-import-cycles.mjs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
if (process.argv.length > 2) {
|
||||
console.error("Usage: node scripts/lint/check-import-cycles.mjs");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const configPath = ts.findConfigFile(cwd, ts.sys.fileExists, "tsconfig.json");
|
||||
if (!configPath) {
|
||||
console.error("Could not find tsconfig.json.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
|
||||
if (configFile.error) {
|
||||
reportDiagnostic(configFile.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath));
|
||||
if (parsedConfig.errors.length > 0) {
|
||||
for (const diagnostic of parsedConfig.errors) reportDiagnostic(diagnostic);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sourceFiles = parsedConfig.fileNames.map((file) => path.resolve(file)).filter((file) => isCheckedSourceFile(relativePath(file)));
|
||||
const sourceFileSet = new Set(sourceFiles);
|
||||
const compilerHost = ts.createCompilerHost(parsedConfig.options, true);
|
||||
const moduleResolutionCache = ts.createModuleResolutionCache(cwd, (file) => file, parsedConfig.options);
|
||||
const graph = new Map(sourceFiles.map((file) => [relativePath(file), new Set()]));
|
||||
|
||||
for (const sourceFile of sourceFiles) {
|
||||
const sourceText = ts.sys.readFile(sourceFile);
|
||||
if (sourceText === undefined) continue;
|
||||
|
||||
const parsedSource = ts.createSourceFile(sourceFile, sourceText, parsedConfig.options.target ?? ts.ScriptTarget.ES2022, true);
|
||||
const from = relativePath(sourceFile);
|
||||
|
||||
for (const edge of importEdges(parsedSource)) {
|
||||
const resolved = ts.resolveModuleName(
|
||||
edge.specifier,
|
||||
sourceFile,
|
||||
parsedConfig.options,
|
||||
compilerHost,
|
||||
moduleResolutionCache,
|
||||
).resolvedModule;
|
||||
if (!resolved) continue;
|
||||
|
||||
const resolvedFile = path.resolve(resolved.resolvedFileName);
|
||||
if (!sourceFileSet.has(resolvedFile)) continue;
|
||||
|
||||
graph.get(from)?.add(relativePath(resolvedFile));
|
||||
}
|
||||
}
|
||||
|
||||
const cycleComponents = stronglyConnectedComponents(graph)
|
||||
.filter((component) => component.length > 1 || hasSelfDependency(graph, component[0]))
|
||||
.sort((left, right) => left[0].localeCompare(right[0]));
|
||||
|
||||
if (cycleComponents.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`Import cycle check failed: found ${cycleComponents.length} cycle${cycleComponents.length === 1 ? "" : "s"}.`);
|
||||
for (const [index, component] of cycleComponents.entries()) {
|
||||
const cycle = sampleCycle(graph, component);
|
||||
console.error(`${index + 1}. ${cycle.join(" -> ")}`);
|
||||
}
|
||||
process.exit(1);
|
||||
|
||||
function importEdges(sourceFile) {
|
||||
const edges = [];
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (ts.isImportDeclaration(statement) && hasRuntimeImportEdge(statement) && stringLiteralText(statement.moduleSpecifier)) {
|
||||
edges.push({
|
||||
specifier: stringLiteralText(statement.moduleSpecifier),
|
||||
});
|
||||
} else if (
|
||||
ts.isExportDeclaration(statement) &&
|
||||
hasRuntimeExportEdge(statement) &&
|
||||
statement.moduleSpecifier &&
|
||||
stringLiteralText(statement.moduleSpecifier)
|
||||
) {
|
||||
edges.push({
|
||||
specifier: stringLiteralText(statement.moduleSpecifier),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
function hasRuntimeImportEdge(statement) {
|
||||
const importClause = statement.importClause;
|
||||
if (!importClause) return true;
|
||||
if (importClause.isTypeOnly) return false;
|
||||
if (importClause.name) return true;
|
||||
|
||||
const namedBindings = importClause.namedBindings;
|
||||
if (!namedBindings) return false;
|
||||
if (ts.isNamespaceImport(namedBindings)) return true;
|
||||
return namedBindings.elements.some((element) => !element.isTypeOnly);
|
||||
}
|
||||
|
||||
function hasRuntimeExportEdge(statement) {
|
||||
if (statement.isTypeOnly) return false;
|
||||
|
||||
const exportClause = statement.exportClause;
|
||||
if (!exportClause) return true;
|
||||
if (ts.isNamespaceExport(exportClause)) return true;
|
||||
return exportClause.elements.some((element) => !element.isTypeOnly);
|
||||
}
|
||||
|
||||
function stringLiteralText(node) {
|
||||
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : null;
|
||||
}
|
||||
|
||||
function stronglyConnectedComponents(graph) {
|
||||
let nextIndex = 0;
|
||||
const stack = [];
|
||||
const onStack = new Set();
|
||||
const indices = new Map();
|
||||
const lowLinks = new Map();
|
||||
const components = [];
|
||||
|
||||
for (const node of graph.keys()) {
|
||||
if (!indices.has(node)) visit(node);
|
||||
}
|
||||
|
||||
return components.map((component) => component.sort());
|
||||
|
||||
function visit(node) {
|
||||
indices.set(node, nextIndex);
|
||||
lowLinks.set(node, nextIndex);
|
||||
nextIndex += 1;
|
||||
stack.push(node);
|
||||
onStack.add(node);
|
||||
|
||||
for (const dependency of graph.get(node) ?? []) {
|
||||
if (!indices.has(dependency)) {
|
||||
visit(dependency);
|
||||
lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(dependency)));
|
||||
} else if (onStack.has(dependency)) {
|
||||
lowLinks.set(node, Math.min(lowLinks.get(node), indices.get(dependency)));
|
||||
}
|
||||
}
|
||||
|
||||
if (lowLinks.get(node) !== indices.get(node)) return;
|
||||
|
||||
const component = [];
|
||||
let item;
|
||||
do {
|
||||
item = stack.pop();
|
||||
onStack.delete(item);
|
||||
component.push(item);
|
||||
} while (item !== node);
|
||||
components.push(component);
|
||||
}
|
||||
}
|
||||
|
||||
function sampleCycle(graph, component) {
|
||||
const componentSet = new Set(component);
|
||||
|
||||
for (const start of component) {
|
||||
if (graph.get(start)?.has(start)) return [start, start];
|
||||
|
||||
for (const dependency of graph.get(start) ?? []) {
|
||||
if (!componentSet.has(dependency)) continue;
|
||||
const pathToStart = findPath(graph, componentSet, dependency, start, new Set([start]));
|
||||
if (pathToStart) return [start, ...pathToStart];
|
||||
}
|
||||
}
|
||||
|
||||
return component;
|
||||
}
|
||||
|
||||
function findPath(graph, allowedNodes, current, target, seen) {
|
||||
if (current === target) return [current];
|
||||
if (seen.has(current)) return null;
|
||||
seen.add(current);
|
||||
|
||||
for (const dependency of graph.get(current) ?? []) {
|
||||
if (!allowedNodes.has(dependency)) continue;
|
||||
const path = findPath(graph, allowedNodes, dependency, target, seen);
|
||||
if (path) return [current, ...path];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasSelfDependency(graph, node) {
|
||||
return Boolean(node && graph.get(node)?.has(node));
|
||||
}
|
||||
|
||||
function isCheckedSourceFile(file) {
|
||||
return (
|
||||
file.startsWith("src/") &&
|
||||
!file.startsWith("src/generated/") &&
|
||||
(file.endsWith(".ts") || file.endsWith(".tsx")) &&
|
||||
!file.endsWith(".d.ts")
|
||||
);
|
||||
}
|
||||
|
||||
function relativePath(file) {
|
||||
return path.relative(cwd, file).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function reportDiagnostic(diagnostic) {
|
||||
console.error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
|
||||
}
|
||||
380
scripts/lint/eslint-plugin-codex-panel.mjs
Normal file
380
scripts/lint/eslint-plugin-codex-panel.mjs
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import ts from "typescript";
|
||||
|
||||
const imperativeDomWriteMethods = new Set([
|
||||
"addClass",
|
||||
"addClasses",
|
||||
"append",
|
||||
"appendChild",
|
||||
"after",
|
||||
"before",
|
||||
"createDiv",
|
||||
"createEl",
|
||||
"createSpan",
|
||||
"empty",
|
||||
"hide",
|
||||
"insertAdjacentElement",
|
||||
"insertAdjacentHTML",
|
||||
"insertAdjacentText",
|
||||
"insertBefore",
|
||||
"prepend",
|
||||
"removeClass",
|
||||
"removeClasses",
|
||||
"remove",
|
||||
"removeChild",
|
||||
"replaceChildren",
|
||||
"replaceWith",
|
||||
"setCssProps",
|
||||
"setCssStyles",
|
||||
"setText",
|
||||
"show",
|
||||
"setAttr",
|
||||
"toggleClass",
|
||||
]);
|
||||
const imperativeDomEventMethods = new Set(["addEventListener", "removeEventListener"]);
|
||||
const imperativeDomAssignmentProperties = new Set([
|
||||
"checked",
|
||||
"innerHTML",
|
||||
"onblur",
|
||||
"onchange",
|
||||
"onclick",
|
||||
"ondblclick",
|
||||
"onfocus",
|
||||
"oninput",
|
||||
"onkeydown",
|
||||
"onkeyup",
|
||||
"onmousedown",
|
||||
"onmousemove",
|
||||
"onmouseup",
|
||||
"onpointerdown",
|
||||
"onpointerup",
|
||||
"onscroll",
|
||||
"onselect",
|
||||
"outerHTML",
|
||||
"textContent",
|
||||
"value",
|
||||
]);
|
||||
|
||||
const codexPanelEslintPlugin = {
|
||||
rules: {
|
||||
"no-self-referential-initializer-callback": {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Disallow callbacks in variable initializers from referencing the variable being initialized.",
|
||||
},
|
||||
messages: {
|
||||
selfReference: "Avoid referencing '{{name}}' from a callback inside its own initializer; declare it first with an explicit type.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
VariableDeclarator(node) {
|
||||
if (node.id.type !== "Identifier" || !node.init) return;
|
||||
if (node.init.type !== "NewExpression") return;
|
||||
const reference = findInitializerCallbackReference(node.init, node.id.name);
|
||||
if (reference) context.report({ node: reference, messageId: "selfReference", data: { name: node.id.name } });
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
"no-chat-state-direct-mutation": {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Disallow direct ChatState mutation in chat modules.",
|
||||
},
|
||||
messages: {
|
||||
assign: "Route ChatState updates through ChatStateStore.dispatch().",
|
||||
mutateCollection: "Clone ChatState collections and update them through ChatStateStore.dispatch().",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
const mutatingCollectionMethods = new Set(["add", "clear", "delete", "push", "set"]);
|
||||
const chatStateAliasVariables = new WeakSet();
|
||||
let parserServices = null;
|
||||
let checker = null;
|
||||
const typeChecker = () => {
|
||||
if (!parserServices) {
|
||||
parserServices = parserServicesFromContext(context);
|
||||
checker = parserServices.program.getTypeChecker();
|
||||
}
|
||||
return checker;
|
||||
};
|
||||
const variableForIdentifier = (node) => {
|
||||
let scope = context.sourceCode.getScope(node);
|
||||
while (scope) {
|
||||
const variable = scope.variables.find((item) => item.name === node.name);
|
||||
if (variable) return variable;
|
||||
scope = scope.upper;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const markChatStateAlias = (node) => {
|
||||
const variable = variableForIdentifier(node);
|
||||
if (variable) chatStateAliasVariables.add(variable);
|
||||
};
|
||||
const isChatStateAlias = (node) => {
|
||||
if (node?.type !== "Identifier") return false;
|
||||
const variable = variableForIdentifier(node);
|
||||
return Boolean(variable && chatStateAliasVariables.has(variable));
|
||||
};
|
||||
const typeAt = (node) => {
|
||||
const services = parserServices ?? parserServicesFromContext(context);
|
||||
if (!parserServices) {
|
||||
parserServices = services;
|
||||
checker = services.program.getTypeChecker();
|
||||
}
|
||||
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
|
||||
return typeChecker().getTypeAtLocation(tsNode);
|
||||
};
|
||||
const isChatStateValue = (node) => typeIncludesChatState(typeAt(node), typeChecker());
|
||||
const isChatStateAliasSource = (node) =>
|
||||
(isChatStateTarget(node) || isChatStateValue(node)) && typeCanCarryChatStateMutation(typeAt(node));
|
||||
const isChatStateTarget = (node) => {
|
||||
if (isChatStateAlias(node)) return true;
|
||||
if (!isMemberExpression(node)) return false;
|
||||
if (isChatStateMember(node)) return true;
|
||||
const root = rootMemberObject(node);
|
||||
if (!root) return false;
|
||||
if (isChatStateAlias(root)) return true;
|
||||
return typeIncludesChatState(typeAt(root), typeChecker());
|
||||
};
|
||||
return {
|
||||
VariableDeclarator(node) {
|
||||
if (node.id.type !== "Identifier" || !node.init) return;
|
||||
if (isChatStateAliasSource(node.init)) markChatStateAlias(node.id);
|
||||
},
|
||||
AssignmentExpression(node) {
|
||||
if (isMemberExpression(node.left) && isChatStateTarget(node.left)) context.report({ node: node.left, messageId: "assign" });
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (!isMemberExpression(node.callee)) return;
|
||||
const method = staticPropertyName(node.callee.property);
|
||||
if (!method || !mutatingCollectionMethods.has(method)) return;
|
||||
if (isChatStateTarget(node.callee.object)) context.report({ node: node.callee, messageId: "mutateCollection" });
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
"no-imperative-dom": {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Disallow imperative DOM writes and event wiring outside explicit bridge files.",
|
||||
},
|
||||
messages: {
|
||||
event: "Keep imperative DOM event wiring in an explicit bridge module or Obsidian-owned UI boundary.",
|
||||
write: "Keep imperative DOM writes in an explicit bridge module or Obsidian-owned UI boundary.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
let parserServices = null;
|
||||
let checker = null;
|
||||
|
||||
const typeChecker = () => {
|
||||
if (!parserServices) {
|
||||
parserServices = parserServicesFromContext(context);
|
||||
checker = parserServices.program.getTypeChecker();
|
||||
}
|
||||
return checker;
|
||||
};
|
||||
|
||||
const isDomTarget = (node) => {
|
||||
const services = parserServices ?? parserServicesFromContext(context);
|
||||
if (!parserServices) {
|
||||
parserServices = services;
|
||||
checker = services.program.getTypeChecker();
|
||||
}
|
||||
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
|
||||
return typeIncludesDom(typeChecker().getTypeAtLocation(tsNode), typeChecker());
|
||||
};
|
||||
|
||||
return {
|
||||
AssignmentExpression(node) {
|
||||
if (!isMemberExpression(node.left)) return;
|
||||
const property = staticPropertyName(node.left.property);
|
||||
if (!property || !imperativeDomAssignmentProperties.has(property)) return;
|
||||
if (isDomTarget(node.left.object)) context.report({ node: node.left, messageId: "write" });
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (!isMemberExpression(node.callee)) return;
|
||||
const method = staticPropertyName(node.callee.property);
|
||||
if (!method) return;
|
||||
if (imperativeDomWriteMethods.has(method) && isDomTarget(node.callee.object)) {
|
||||
context.report({ node: node.callee, messageId: "write" });
|
||||
return;
|
||||
}
|
||||
if (imperativeDomEventMethods.has(method) && isDomTarget(node.callee.object)) {
|
||||
context.report({ node: node.callee, messageId: "event" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function isChatStateMember(node) {
|
||||
if (!isMemberExpression(node)) return false;
|
||||
|
||||
let current = node;
|
||||
while (isMemberExpression(current)) {
|
||||
if (isIdentifier(current.object, "state")) return true;
|
||||
if (isThisStateMember(current.object)) return true;
|
||||
current = current.object;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isThisStateMember(node) {
|
||||
return isMemberExpression(node) && node.object?.type === "ThisExpression" && staticPropertyName(node.property) === "state";
|
||||
}
|
||||
|
||||
function rootMemberObject(node) {
|
||||
if (!isMemberExpression(node)) return null;
|
||||
let current = node;
|
||||
while (isMemberExpression(current)) current = current.object;
|
||||
return current;
|
||||
}
|
||||
|
||||
function isMemberExpression(node) {
|
||||
return node?.type === "MemberExpression";
|
||||
}
|
||||
|
||||
function isIdentifier(node, name) {
|
||||
return node?.type === "Identifier" && node.name === name;
|
||||
}
|
||||
|
||||
function staticPropertyName(node) {
|
||||
return node?.type === "Identifier" ? node.name : node?.type === "Literal" && typeof node.value === "string" ? node.value : null;
|
||||
}
|
||||
|
||||
function parserServicesFromContext(context) {
|
||||
const services = context.sourceCode.parserServices;
|
||||
if (!services?.program || !services.esTreeNodeToTSNodeMap) {
|
||||
throw new Error("codex-panel/no-imperative-dom requires TypeScript parser services.");
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
function typeIncludesDom(type, checker, seen = new Set()) {
|
||||
if (!type || seen.has(type.id)) return false;
|
||||
seen.add(type.id);
|
||||
|
||||
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return false;
|
||||
if (type.isUnionOrIntersection()) return type.types.some((item) => typeIncludesDom(item, checker, seen));
|
||||
|
||||
const typeName = checker.typeToString(type);
|
||||
if (domTypeName(typeName)) return true;
|
||||
|
||||
const symbolName = type.getSymbol()?.getName() ?? type.aliasSymbol?.getName() ?? "";
|
||||
if (domTypeName(symbolName)) return true;
|
||||
|
||||
const apparent = checker.getApparentType(type);
|
||||
if (apparent !== type && typeIncludesDom(apparent, checker, seen)) return true;
|
||||
|
||||
const bases = typeof type.getBaseTypes === "function" ? (type.getBaseTypes() ?? []) : [];
|
||||
return bases.some((base) => typeIncludesDom(base, checker, seen));
|
||||
}
|
||||
|
||||
function typeIncludesChatState(type, checker, seen = new Set()) {
|
||||
if (!type || seen.has(type.id)) return false;
|
||||
seen.add(type.id);
|
||||
|
||||
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return false;
|
||||
if (type.isUnionOrIntersection()) return type.types.some((item) => typeIncludesChatState(item, checker, seen));
|
||||
|
||||
const typeName = checker.typeToString(type);
|
||||
if (chatStateTypeName(typeName)) return true;
|
||||
|
||||
const symbolName = type.getSymbol()?.getName() ?? type.aliasSymbol?.getName() ?? "";
|
||||
if (chatStateTypeName(symbolName)) return true;
|
||||
|
||||
const apparent = checker.getApparentType(type);
|
||||
if (apparent !== type && typeIncludesChatState(apparent, checker, seen)) return true;
|
||||
|
||||
const bases = typeof type.getBaseTypes === "function" ? (type.getBaseTypes() ?? []) : [];
|
||||
return bases.some((base) => typeIncludesChatState(base, checker, seen));
|
||||
}
|
||||
|
||||
function typeCanCarryChatStateMutation(type, seen = new Set()) {
|
||||
if (!type || seen.has(type.id)) return false;
|
||||
seen.add(type.id);
|
||||
|
||||
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return false;
|
||||
if (type.isUnionOrIntersection()) return type.types.some((item) => typeCanCarryChatStateMutation(item, seen));
|
||||
|
||||
const primitiveLikeFlags =
|
||||
ts.TypeFlags.StringLike |
|
||||
ts.TypeFlags.NumberLike |
|
||||
ts.TypeFlags.BooleanLike |
|
||||
ts.TypeFlags.BigIntLike |
|
||||
ts.TypeFlags.ESSymbolLike |
|
||||
ts.TypeFlags.Null |
|
||||
ts.TypeFlags.Undefined |
|
||||
ts.TypeFlags.Void;
|
||||
return (type.flags & primitiveLikeFlags) === 0;
|
||||
}
|
||||
|
||||
function domTypeName(name) {
|
||||
return /\b(?:Document|Element|HTML[A-Za-z]*Element|HTMLElement|Node|SVG[A-Za-z]*Element|SVGElement|Window)\b/.test(name);
|
||||
}
|
||||
|
||||
function chatStateTypeName(name) {
|
||||
return /\bChatState\b/.test(name);
|
||||
}
|
||||
|
||||
function findInitializerCallbackReference(root, name) {
|
||||
let reference = null;
|
||||
|
||||
const visit = (node, inCallback) => {
|
||||
if (!node || reference) return;
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) visit(item, inCallback);
|
||||
return;
|
||||
}
|
||||
if (typeof node !== "object" || typeof node.type !== "string") return;
|
||||
|
||||
if (isFunctionNode(node)) {
|
||||
if (functionShadowsName(node, name)) return;
|
||||
visit(node.body, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (inCallback && node.type === "Identifier" && node.name === name) {
|
||||
reference = node;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (key === "parent") continue;
|
||||
if (node.type === "MemberExpression" && key === "property" && !node.computed) continue;
|
||||
if (node.type === "Property" && key === "key" && !node.computed) continue;
|
||||
visit(value, inCallback);
|
||||
}
|
||||
};
|
||||
|
||||
visit(root, false);
|
||||
return reference;
|
||||
}
|
||||
|
||||
function isFunctionNode(node) {
|
||||
return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
|
||||
}
|
||||
|
||||
function functionShadowsName(node, name) {
|
||||
return node.params.some((param) => patternContainsName(param, name)) || patternContainsName(node.id, name);
|
||||
}
|
||||
|
||||
function patternContainsName(node, name) {
|
||||
if (!node) return false;
|
||||
if (Array.isArray(node)) return node.some((item) => patternContainsName(item, name));
|
||||
if (typeof node !== "object" || typeof node.type !== "string") return false;
|
||||
if (node.type === "Identifier") return node.name === name;
|
||||
return Object.entries(node).some(([key, value]) => key !== "parent" && patternContainsName(value, name));
|
||||
}
|
||||
|
||||
export default codexPanelEslintPlugin;
|
||||
67
scripts/lint/stylelint-no-specificity-where.mjs
Normal file
67
scripts/lint/stylelint-no-specificity-where.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import stylelint from "stylelint";
|
||||
|
||||
const ruleName = "codex-panel/no-specificity-where";
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
rejected: (selector) => `Do not hide class, id, or attribute selectors inside :where(): "${selector}"`,
|
||||
});
|
||||
|
||||
function noSpecificityWhereRule(primary) {
|
||||
return (root, result) => {
|
||||
if (primary === false) return;
|
||||
|
||||
root.walkRules((ruleNode) => {
|
||||
const selector = ruleNode.selector;
|
||||
if (!selector.includes(":where(")) return;
|
||||
|
||||
for (const range of whereRanges(selector)) {
|
||||
const body = selector.slice(range.bodyStart, range.bodyEnd);
|
||||
if (!/[.#[]/.test(body)) continue;
|
||||
|
||||
stylelint.utils.report({
|
||||
message: messages.rejected,
|
||||
messageArgs: [selector.slice(range.start, range.end)],
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
index: range.start,
|
||||
endIndex: range.end,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function whereRanges(selector) {
|
||||
const ranges = [];
|
||||
let searchFrom = 0;
|
||||
|
||||
while (searchFrom < selector.length) {
|
||||
const start = selector.indexOf(":where(", searchFrom);
|
||||
if (start === -1) break;
|
||||
|
||||
const bodyStart = start + ":where(".length;
|
||||
let depth = 1;
|
||||
let index = bodyStart;
|
||||
|
||||
for (; index < selector.length && depth > 0; index += 1) {
|
||||
const char = selector[index];
|
||||
if (char === "(") depth += 1;
|
||||
if (char === ")") depth -= 1;
|
||||
}
|
||||
|
||||
if (depth === 0) {
|
||||
ranges.push({ start, bodyStart, bodyEnd: index - 1, end: index });
|
||||
searchFrom = index;
|
||||
} else {
|
||||
searchFrom = bodyStart;
|
||||
}
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
noSpecificityWhereRule.ruleName = ruleName;
|
||||
noSpecificityWhereRule.messages = messages;
|
||||
|
||||
export default stylelint.createPlugin(ruleName, noSpecificityWhereRule);
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import conventionalCommits from "conventional-changelog-conventionalcommits";
|
||||
import { CommitParser } from "conventional-commits-parser";
|
||||
|
||||
const releaseNoteTypes = new Set(["feat", "fix", "perf"]);
|
||||
const parser = new CommitParser(conventionalCommits().parser);
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(`release notes generation failed: ${message}`);
|
||||
}
|
||||
|
||||
function runGit(args, cwd) {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
shell: false,
|
||||
});
|
||||
if (result.error) fail(result.error.message);
|
||||
if (result.status !== 0) {
|
||||
fail(`${args.join(" ")} exited with ${result.status}: ${result.stderr.trim()}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function formatSubject(subject) {
|
||||
const sentence = subject.replace(/^([a-z])/, (letter) => letter.toUpperCase());
|
||||
return /[.!?]$/.test(sentence) ? sentence : `${sentence}.`;
|
||||
}
|
||||
|
||||
export function releaseNoteForCommit(message) {
|
||||
const commit = parser.parse(message);
|
||||
if (!commit.subject) return null;
|
||||
|
||||
const isBreaking = commit.notes.length > 0;
|
||||
if (!isBreaking && !releaseNoteTypes.has(commit.type ?? "")) return null;
|
||||
|
||||
return formatSubject(commit.subject);
|
||||
}
|
||||
|
||||
export function renderReleaseNotes(messages) {
|
||||
const entries = messages.map(releaseNoteForCommit).filter((entry) => entry !== null);
|
||||
const bullets = entries.length > 0 ? entries.map((entry) => `- ${entry}`).join("\n") : "- ";
|
||||
return `## Changes\n\n${bullets}\n`;
|
||||
}
|
||||
|
||||
export function readCommitMessagesSince(tag, cwd = process.cwd()) {
|
||||
runGit(["rev-parse", "--verify", `refs/tags/${tag}`], cwd);
|
||||
const output = runGit(["log", "--format=%B%x00", `${tag}..HEAD`], cwd);
|
||||
return output
|
||||
.split("\0")
|
||||
.map((message) => message.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function generateReleaseNotes(tag, cwd = process.cwd()) {
|
||||
return renderReleaseNotes(readCommitMessagesSince(tag, cwd));
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const previousTag = process.argv[2];
|
||||
if (!previousTag) {
|
||||
console.error("usage: npm run release:notes -- X.Y.Z");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
process.stdout.write(generateReleaseNotes(previousTag));
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function fail(message) {
|
||||
console.error(`release preflight failed: ${message}`);
|
||||
|
|
@ -82,15 +81,9 @@ if (maybeRun("jj", ["root"])) {
|
|||
assertGitReleaseState(packageVersion);
|
||||
}
|
||||
|
||||
const versionKeys = Object.keys(JSON.parse(readFileSync("versions.json", "utf8")));
|
||||
const previousTag = versionKeys.at(-2);
|
||||
if (!previousTag) fail("versions.json must contain a release before the prepared version");
|
||||
run("git", ["rev-parse", "--verify", `refs/tags/${previousTag}`], { capture: true });
|
||||
run("npm", ["run", "commitlint", "--", "--from", previousTag, "--to", "main", "--verbose"]);
|
||||
run("npm", ["run", "release:check"]);
|
||||
run("npm", ["run", "api:baseline"]);
|
||||
run("npm", ["run", "generate:app-server-types:check"]);
|
||||
run("npm", ["ci", "--dry-run"]);
|
||||
run("npm", ["run", "check"]);
|
||||
run("npm", ["run", "check:ci"]);
|
||||
|
||||
console.log(`release preflight passed for ${packageVersion}`);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { generateReleaseNotes } from "./notes.mjs";
|
||||
import { compareVersions, isExpectedNextVersion, parseVersion } from "./versioning.mjs";
|
||||
|
||||
function fail(message) {
|
||||
|
|
@ -39,13 +38,6 @@ try {
|
|||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
let releaseNotes;
|
||||
try {
|
||||
releaseNotes = generateReleaseNotes(previousVersionKey);
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
packageJson.version = releaseVersion;
|
||||
packageLockJson.version = releaseVersion;
|
||||
if (!packageLockJson.packages?.[""]) fail('package-lock.json is missing packages[""]');
|
||||
|
|
@ -59,7 +51,7 @@ await writeFile("manifest.json", `${JSON.stringify(manifestJson, null, 2)}\n`);
|
|||
await writeFile("versions.json", `${JSON.stringify(versionsJson, null, 2)}\n`);
|
||||
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(notesPath, releaseNotes);
|
||||
await writeFile(notesPath, "## Changes\n\n- \n");
|
||||
|
||||
console.log(`prepared release ${releaseVersion}`);
|
||||
console.log(`review and edit ${notesPath}, then run npm run release:preflight after committing`);
|
||||
console.log(`edit ${notesPath}, then run npm run release:preflight after committing`);
|
||||
|
|
|
|||
56
src/app-server/catalog.ts
Normal file
56
src/app-server/catalog.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { AppServerClient } from "./connection/client";
|
||||
import {
|
||||
appServerHookOperationFromHookItem,
|
||||
type CatalogModel,
|
||||
hookItemsFromCatalogHooks,
|
||||
modelMetadataFromCatalogModels,
|
||||
skillMetadataFromCatalogSkills,
|
||||
} from "./protocol/catalog";
|
||||
import type { HookItem, ModelMetadata, SkillMetadata } from "../domain/catalog/metadata";
|
||||
|
||||
export interface HookData {
|
||||
hooks: HookItem[];
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ModelMetadataClient {
|
||||
listModels(includeHidden: boolean): Promise<{ data: readonly CatalogModel[] }>;
|
||||
}
|
||||
|
||||
export async function listModelMetadata(client: ModelMetadataClient, options: { includeHidden?: boolean } = {}): Promise<ModelMetadata[]> {
|
||||
const response = await client.listModels(options.includeHidden ?? false);
|
||||
return modelMetadataFromCatalogModels(response.data);
|
||||
}
|
||||
|
||||
export async function listSkillCatalog(
|
||||
client: AppServerClient,
|
||||
cwd: string,
|
||||
options: { forceReload?: boolean; enabledOnly?: boolean } = {},
|
||||
): Promise<{ skills: SkillMetadata[]; totalCount: number }> {
|
||||
const response = options.forceReload === undefined ? await client.listSkills(cwd) : await client.listSkills(cwd, options.forceReload);
|
||||
const skills = response.data.flatMap((entry) => entry.skills);
|
||||
return {
|
||||
skills: skillMetadataFromCatalogSkills(options.enabledOnly === false ? skills : skills.filter((skill) => skill.enabled)),
|
||||
totalCount: skills.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listHookData(client: AppServerClient, cwd: string): Promise<HookData> {
|
||||
const response = await client.listHooks(cwd);
|
||||
const entry = response.data.find((item) => item.cwd === cwd);
|
||||
if (!entry) return { hooks: [], warnings: [], errors: [] };
|
||||
return {
|
||||
hooks: hookItemsFromCatalogHooks(entry.hooks),
|
||||
warnings: entry.warnings,
|
||||
errors: entry.errors.map((error) => JSON.stringify(error)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function trustHookItem(client: AppServerClient, hook: HookItem): Promise<void> {
|
||||
await client.trustHook(appServerHookOperationFromHookItem(hook));
|
||||
}
|
||||
|
||||
export async function setHookItemEnabled(client: AppServerClient, hook: HookItem, enabled: boolean): Promise<void> {
|
||||
await client.setHookEnabled(appServerHookOperationFromHookItem(hook), enabled);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { AppServerClient } from "./client";
|
||||
|
||||
export type AppServerClientRequestPolicy = { kind: "reject"; message: string };
|
||||
export type AppServerClientRequestPolicy = { kind: "interactive" } | { kind: "reject"; message: string };
|
||||
|
||||
export interface AppServerClientAccessOptions {
|
||||
serverRequests?: AppServerClientRequestPolicy;
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
import { CLIENT_VERSION } from "../../constants";
|
||||
import type { InitializeCapabilities } from "../../generated/app-server/InitializeCapabilities";
|
||||
import type { InitializeParams } from "../../generated/app-server/InitializeParams";
|
||||
import compatibility from "./compatibility.json";
|
||||
|
||||
interface AppServerCompatibility {
|
||||
codexAppServer: {
|
||||
testedCliVersion: string;
|
||||
typeGeneration: {
|
||||
arguments: string[];
|
||||
};
|
||||
initialize: {
|
||||
capabilities: Pick<InitializeCapabilities, "experimentalApi" | "requestAttestation">;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const appServerCompatibility = compatibility satisfies AppServerCompatibility;
|
||||
|
||||
export function codexPanelAppServerInitializeParams(): InitializeParams {
|
||||
return {
|
||||
clientInfo: {
|
||||
name: "obsidian_codex_panel",
|
||||
title: "Codex Panel",
|
||||
version: CLIENT_VERSION,
|
||||
},
|
||||
capabilities: {
|
||||
experimentalApi: appServerCompatibility.codexAppServer.initialize.capabilities.experimentalApi,
|
||||
requestAttestation: appServerCompatibility.codexAppServer.initialize.capabilities.requestAttestation,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,74 +1,121 @@
|
|||
import type { InitializeParams } from "../../generated/app-server/InitializeParams";
|
||||
import type { InitializeResponse } from "../../generated/app-server/InitializeResponse";
|
||||
import type { CollaborationMode } from "../../generated/app-server/CollaborationMode";
|
||||
import type { RequestId } from "../../generated/app-server/RequestId";
|
||||
import type { ServerNotification } from "../../generated/app-server/ServerNotification";
|
||||
import type { ServerRequest } from "../../generated/app-server/ServerRequest";
|
||||
import type { ReasoningEffort } from "../../generated/app-server/ReasoningEffort";
|
||||
import type { ApprovalsReviewer } from "../../generated/app-server/v2/ApprovalsReviewer";
|
||||
import type { AppsListParams } from "../../generated/app-server/v2/AppsListParams";
|
||||
import type { AppsListResponse } from "../../generated/app-server/v2/AppsListResponse";
|
||||
import type { CollaborationModeListResponse } from "../../generated/app-server/v2/CollaborationModeListResponse";
|
||||
import type { ConfigReadResponse } from "../../generated/app-server/v2/ConfigReadResponse";
|
||||
import type { ConfigWriteResponse } from "../../generated/app-server/v2/ConfigWriteResponse";
|
||||
import type { EnvironmentInfoResponse } from "../../generated/app-server/v2/EnvironmentInfoResponse";
|
||||
import type { FsReadFileResponse } from "../../generated/app-server/v2/FsReadFileResponse";
|
||||
import type { GetAccountRateLimitsResponse } from "../../generated/app-server/v2/GetAccountRateLimitsResponse";
|
||||
import type { HooksListResponse } from "../../generated/app-server/v2/HooksListResponse";
|
||||
import type { CollaborationModeListResponse } from "../../generated/app-server/v2/CollaborationModeListResponse";
|
||||
import type { ListMcpServerStatusParams } from "../../generated/app-server/v2/ListMcpServerStatusParams";
|
||||
import type { ListMcpServerStatusResponse } from "../../generated/app-server/v2/ListMcpServerStatusResponse";
|
||||
import type { ModelListResponse } from "../../generated/app-server/v2/ModelListResponse";
|
||||
import type { ModelProviderCapabilitiesReadResponse } from "../../generated/app-server/v2/ModelProviderCapabilitiesReadResponse";
|
||||
import type { PermissionProfileListResponse } from "../../generated/app-server/v2/PermissionProfileListResponse";
|
||||
import type { PluginInstalledParams } from "../../generated/app-server/v2/PluginInstalledParams";
|
||||
import type { PluginInstalledResponse } from "../../generated/app-server/v2/PluginInstalledResponse";
|
||||
import type { PluginReadParams } from "../../generated/app-server/v2/PluginReadParams";
|
||||
import type { PluginReadResponse } from "../../generated/app-server/v2/PluginReadResponse";
|
||||
import type { SkillsListResponse } from "../../generated/app-server/v2/SkillsListResponse";
|
||||
import type { ThreadArchiveResponse } from "../../generated/app-server/v2/ThreadArchiveResponse";
|
||||
import type { ThreadCompactStartResponse } from "../../generated/app-server/v2/ThreadCompactStartResponse";
|
||||
import type { ThreadDeleteResponse } from "../../generated/app-server/v2/ThreadDeleteResponse";
|
||||
import type { ThreadForkResponse } from "../../generated/app-server/v2/ThreadForkResponse";
|
||||
import type { ThreadGoalClearResponse } from "../../generated/app-server/v2/ThreadGoalClearResponse";
|
||||
import type { ThreadGoalGetResponse } from "../../generated/app-server/v2/ThreadGoalGetResponse";
|
||||
import type { ThreadGoalSetResponse } from "../../generated/app-server/v2/ThreadGoalSetResponse";
|
||||
import type { ThreadInjectItemsResponse } from "../../generated/app-server/v2/ThreadInjectItemsResponse";
|
||||
import type { ThreadItemsListResponse } from "../../generated/app-server/v2/ThreadItemsListResponse";
|
||||
import type { ThreadListResponse } from "../../generated/app-server/v2/ThreadListResponse";
|
||||
import type { ThreadReadResponse } from "../../generated/app-server/v2/ThreadReadResponse";
|
||||
import type { ThreadResumeResponse } from "../../generated/app-server/v2/ThreadResumeResponse";
|
||||
import type { ThreadRollbackResponse } from "../../generated/app-server/v2/ThreadRollbackResponse";
|
||||
import type { ThreadSetNameResponse } from "../../generated/app-server/v2/ThreadSetNameResponse";
|
||||
import type { ThreadSettingsUpdateResponse } from "../../generated/app-server/v2/ThreadSettingsUpdateResponse";
|
||||
import type { ThreadCompactStartResponse } from "../../generated/app-server/v2/ThreadCompactStartResponse";
|
||||
import type { SortDirection } from "../../generated/app-server/v2/SortDirection";
|
||||
import type { ThreadStartResponse } from "../../generated/app-server/v2/ThreadStartResponse";
|
||||
import type { ThreadTurnsListResponse } from "../../generated/app-server/v2/ThreadTurnsListResponse";
|
||||
import type { ThreadUnarchiveResponse } from "../../generated/app-server/v2/ThreadUnarchiveResponse";
|
||||
import type { ThreadUnsubscribeResponse } from "../../generated/app-server/v2/ThreadUnsubscribeResponse";
|
||||
import type { TurnItemsView } from "../../generated/app-server/v2/TurnItemsView";
|
||||
import type { TurnInterruptResponse } from "../../generated/app-server/v2/TurnInterruptResponse";
|
||||
import type { TurnStartResponse } from "../../generated/app-server/v2/TurnStartResponse";
|
||||
import type { TurnSteerResponse } from "../../generated/app-server/v2/TurnSteerResponse";
|
||||
import type { UserInput } from "../../generated/app-server/v2/UserInput";
|
||||
import { CLIENT_VERSION } from "../../constants";
|
||||
import { JsonRpcClient } from "./json-rpc-client";
|
||||
import { StdioAppServerTransport, type AppServerTransport, type AppServerTransportHandlers } from "./transport";
|
||||
import type { ClientRequestMethod, ClientRequestParams, RpcOutboundMessage } from "./rpc-messages";
|
||||
import { type AppServerTransport, type AppServerTransportHandlers, StdioAppServerTransport } from "./transport";
|
||||
import type { ServerNotification } from "../../generated/app-server/ServerNotification";
|
||||
import type { ServerRequest } from "../../generated/app-server/ServerRequest";
|
||||
import type { JsonValue } from "../../generated/app-server/serde_json/JsonValue";
|
||||
import type { CodexInput } from "../../domain/chat/input";
|
||||
import type { ThreadGoalUpdate } from "../../domain/threads/goal";
|
||||
import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../domain/runtime/thread-settings";
|
||||
import type { AppServerHookOperation } from "../protocol/catalog";
|
||||
import { additionalContextFromCodexInput, toAppServerUserInput } from "../protocol/request-input";
|
||||
import { appServerThreadGoalUpdate } from "../protocol/thread-goal";
|
||||
import { appServerRuntimeSettingsPatch } from "../protocol/thread-settings";
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
||||
|
||||
export interface AppServerClientHandlers {
|
||||
onNotification: (notification: ServerNotification) => void;
|
||||
onServerRequest: (request: ServerRequest, responder: AppServerServerRequestResponder) => void;
|
||||
onServerRequest: (request: ServerRequest) => void;
|
||||
onLog: (message: string) => void;
|
||||
onExit: (code: number | null, signal: NodeJS.Signals | null) => void;
|
||||
}
|
||||
|
||||
export interface AppServerServerRequestResponder {
|
||||
respond(result: unknown): void;
|
||||
reject(code: number, message: string): void;
|
||||
}
|
||||
|
||||
export type AppServerTransportFactory = (handlers: AppServerTransportHandlers) => AppServerTransport;
|
||||
|
||||
export interface AppServerClientOptions {
|
||||
codexPath: string;
|
||||
cwd: string;
|
||||
handlers: AppServerClientHandlers;
|
||||
initializeParams: InitializeParams;
|
||||
requestTimeoutMs?: number;
|
||||
transportFactory?: AppServerTransportFactory;
|
||||
interface AppServerTurnRuntimeOverrides {
|
||||
serviceTier?: RuntimeServiceTierRequest;
|
||||
collaborationMode?: CollaborationMode;
|
||||
model?: string | null;
|
||||
effort?: ReasoningEffort | null;
|
||||
approvalsReviewer?: ApprovalsReviewer | null;
|
||||
}
|
||||
|
||||
export interface ClientResponseByMethod {
|
||||
type AppServerTurnRuntimeParams = Pick<
|
||||
ClientRequestParams<"turn/start">,
|
||||
"serviceTier" | "collaborationMode" | "model" | "effort" | "approvalsReviewer"
|
||||
>;
|
||||
|
||||
export interface AppServerStartThreadOptions {
|
||||
cwd: string;
|
||||
serviceTier?: RuntimeServiceTierRequest;
|
||||
}
|
||||
|
||||
export interface AppServerThreadListOptions {
|
||||
archived?: boolean;
|
||||
cursor?: string | null;
|
||||
limit?: number | null;
|
||||
}
|
||||
|
||||
export interface AppServerStartEphemeralThreadOptions {
|
||||
cwd: string;
|
||||
serviceName: string;
|
||||
developerInstructions: string;
|
||||
}
|
||||
|
||||
export interface AppServerStartTurnOptions {
|
||||
threadId: string;
|
||||
cwd: string;
|
||||
input: string | CodexInput;
|
||||
clientUserMessageId?: string | null;
|
||||
runtime?: AppServerTurnRuntimeOverrides;
|
||||
}
|
||||
|
||||
export interface AppServerStartStructuredTurnOptions {
|
||||
threadId: string;
|
||||
cwd: string;
|
||||
text: string;
|
||||
outputSchema: JsonValue;
|
||||
runtime?: AppServerTurnRuntimeOverrides;
|
||||
}
|
||||
|
||||
interface ClientResponseByMethod {
|
||||
initialize: InitializeResponse;
|
||||
"config/batchWrite": ConfigWriteResponse;
|
||||
"config/read": ConfigReadResponse;
|
||||
|
|
@ -84,18 +131,16 @@ export interface ClientResponseByMethod {
|
|||
"thread/read": ThreadReadResponse;
|
||||
"thread/archive": ThreadArchiveResponse;
|
||||
"thread/delete": ThreadDeleteResponse;
|
||||
"thread/unsubscribe": ThreadUnsubscribeResponse;
|
||||
"thread/unarchive": ThreadUnarchiveResponse;
|
||||
"thread/rollback": ThreadRollbackResponse;
|
||||
"thread/name/set": ThreadSetNameResponse;
|
||||
"thread/settings/update": ThreadSettingsUpdateResponse;
|
||||
"thread/turns/list": ThreadTurnsListResponse;
|
||||
"thread/items/list": ThreadItemsListResponse;
|
||||
"skills/list": SkillsListResponse;
|
||||
"app/list": AppsListResponse;
|
||||
"plugin/installed": PluginInstalledResponse;
|
||||
"plugin/read": PluginReadResponse;
|
||||
"model/list": ModelListResponse;
|
||||
"permissionProfile/list": PermissionProfileListResponse;
|
||||
"account/rateLimits/read": GetAccountRateLimitsResponse;
|
||||
"mcpServerStatus/list": ListMcpServerStatusResponse;
|
||||
"collaborationMode/list": CollaborationModeListResponse;
|
||||
|
|
@ -105,50 +150,54 @@ export interface ClientResponseByMethod {
|
|||
"turn/steer": TurnSteerResponse;
|
||||
"turn/interrupt": TurnInterruptResponse;
|
||||
"fs/readFile": FsReadFileResponse;
|
||||
"environment/info": EnvironmentInfoResponse;
|
||||
}
|
||||
|
||||
export type TypedClientRequestMethod = Extract<ClientRequestMethod, keyof ClientResponseByMethod>;
|
||||
type TypedClientRequestMethod = Extract<ClientRequestMethod, keyof ClientResponseByMethod>;
|
||||
|
||||
type AppServerClientLifecycleState =
|
||||
| { kind: "disconnected" }
|
||||
| { kind: "starting"; transport: AppServerTransport }
|
||||
| { kind: "initialized"; transport: AppServerTransport; initializeResponse: InitializeResponse };
|
||||
|
||||
function toUserInput(input: string | CodexInput): UserInput[] {
|
||||
if (typeof input !== "string") return toAppServerUserInput(input);
|
||||
return toAppServerUserInput([{ type: "text", text: input }]);
|
||||
}
|
||||
|
||||
function toAdditionalContext(input: string | CodexInput): ClientRequestParams<"turn/start">["additionalContext"] | undefined {
|
||||
if (typeof input === "string") return undefined;
|
||||
return additionalContextFromCodexInput(input);
|
||||
}
|
||||
|
||||
function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | undefined): AppServerTurnRuntimeParams {
|
||||
const params: AppServerTurnRuntimeParams = {};
|
||||
if (runtime?.serviceTier !== undefined) params.serviceTier = runtime.serviceTier;
|
||||
if (runtime?.collaborationMode !== undefined) params.collaborationMode = runtime.collaborationMode;
|
||||
if (runtime?.model !== undefined) params.model = runtime.model;
|
||||
if (runtime?.effort !== undefined) params.effort = runtime.effort;
|
||||
if (runtime?.approvalsReviewer !== undefined) params.approvalsReviewer = runtime.approvalsReviewer;
|
||||
return params;
|
||||
}
|
||||
|
||||
export class AppServerClient {
|
||||
private lifecycle: AppServerClientLifecycleState = { kind: "disconnected" };
|
||||
private readonly rpc: JsonRpcClient;
|
||||
private readonly intentionallyStoppedTransports = new WeakSet<AppServerTransport>();
|
||||
private readonly codexPath: string;
|
||||
private readonly cwd: string;
|
||||
private readonly handlers: AppServerClientHandlers;
|
||||
private readonly initializeParams: InitializeParams;
|
||||
private readonly requestTimeoutMs: number;
|
||||
private readonly transportFactory: AppServerTransportFactory | undefined;
|
||||
|
||||
constructor(options: AppServerClientOptions) {
|
||||
this.codexPath = options.codexPath;
|
||||
this.cwd = options.cwd;
|
||||
this.handlers = options.handlers;
|
||||
this.initializeParams = options.initializeParams;
|
||||
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
this.transportFactory = options.transportFactory;
|
||||
constructor(
|
||||
private readonly codexPath: string,
|
||||
private readonly cwd: string,
|
||||
private readonly handlers: AppServerClientHandlers,
|
||||
private readonly requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
private readonly transportFactory?: AppServerTransportFactory,
|
||||
) {
|
||||
this.rpc = new JsonRpcClient({
|
||||
requestTimeoutMs: this.requestTimeoutMs,
|
||||
send: (message) => {
|
||||
this.send(message);
|
||||
},
|
||||
onNotification: this.handlers.onNotification,
|
||||
onServerRequest: (request) => {
|
||||
this.handlers.onServerRequest(request, {
|
||||
respond: (result) => {
|
||||
this.respondToServerRequest(request.id, result);
|
||||
},
|
||||
reject: (code, message) => {
|
||||
this.rejectServerRequest(request.id, code, message);
|
||||
},
|
||||
});
|
||||
},
|
||||
onServerRequest: this.handlers.onServerRequest,
|
||||
onLog: this.handlers.onLog,
|
||||
});
|
||||
}
|
||||
|
|
@ -197,7 +246,17 @@ export class AppServerClient {
|
|||
this.lifecycle = { kind: "starting", transport };
|
||||
try {
|
||||
transport.start();
|
||||
const init = await this.request("initialize", this.initializeParams);
|
||||
const init = await this.request("initialize", {
|
||||
clientInfo: {
|
||||
name: "obsidian_codex_panel",
|
||||
title: "Codex Panel",
|
||||
version: CLIENT_VERSION,
|
||||
},
|
||||
capabilities: {
|
||||
experimentalApi: true,
|
||||
requestAttestation: false,
|
||||
},
|
||||
});
|
||||
this.notify({ method: "initialized" });
|
||||
this.lifecycle = { kind: "initialized", transport, initializeResponse: init };
|
||||
return init;
|
||||
|
|
@ -231,6 +290,257 @@ export class AppServerClient {
|
|||
return this.lifecycle.initializeResponse;
|
||||
}
|
||||
|
||||
readEffectiveConfig(cwd: string): Promise<ConfigReadResponse> {
|
||||
return this.request("config/read", { cwd, includeLayers: true });
|
||||
}
|
||||
|
||||
listHooks(cwd: string): Promise<HooksListResponse> {
|
||||
return this.request("hooks/list", { cwds: [cwd] });
|
||||
}
|
||||
|
||||
trustHook(hook: AppServerHookOperation): Promise<ConfigWriteResponse> {
|
||||
return this.writeHookState(hook.key, {
|
||||
enabled: true,
|
||||
trusted_hash: hook.currentHash,
|
||||
});
|
||||
}
|
||||
|
||||
setHookEnabled(hook: AppServerHookOperation, enabled: boolean): Promise<ConfigWriteResponse> {
|
||||
const state: Record<string, JsonValue> = hook.trustStatus === "trusted" ? { enabled, trusted_hash: hook.currentHash } : { enabled };
|
||||
return this.writeHookState(hook.key, state);
|
||||
}
|
||||
|
||||
startThread(options: AppServerStartThreadOptions): Promise<ThreadStartResponse> {
|
||||
const { cwd, serviceTier } = options;
|
||||
return this.request("thread/start", {
|
||||
cwd,
|
||||
serviceName: "codex-panel",
|
||||
...(serviceTier !== undefined ? { serviceTier } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
startEphemeralThread(options: AppServerStartEphemeralThreadOptions): Promise<ThreadStartResponse> {
|
||||
const { cwd, serviceName, developerInstructions } = options;
|
||||
return this.request("thread/start", {
|
||||
cwd,
|
||||
serviceName,
|
||||
developerInstructions,
|
||||
ephemeral: true,
|
||||
sandbox: "read-only",
|
||||
approvalPolicy: "never",
|
||||
multiAgentMode: "none",
|
||||
environments: [],
|
||||
});
|
||||
}
|
||||
|
||||
resumeThread(threadId: string, cwd: string): Promise<ThreadResumeResponse> {
|
||||
return this.request("thread/resume", {
|
||||
threadId,
|
||||
cwd,
|
||||
excludeTurns: true,
|
||||
initialTurnsPage: { limit: 20, sortDirection: "desc", itemsView: "full" },
|
||||
});
|
||||
}
|
||||
|
||||
forkThread(threadId: string, cwd: string): Promise<ThreadForkResponse> {
|
||||
return this.request("thread/fork", {
|
||||
threadId,
|
||||
cwd,
|
||||
excludeTurns: true,
|
||||
});
|
||||
}
|
||||
|
||||
listThreads(cwd: string, options: AppServerThreadListOptions = {}): Promise<ThreadListResponse> {
|
||||
return this.request("thread/list", {
|
||||
cwd,
|
||||
...(options.cursor ? { cursor: options.cursor } : {}),
|
||||
...(options.limit === undefined ? {} : { limit: options.limit }),
|
||||
archived: options.archived ?? false,
|
||||
sortKey: "recency_at",
|
||||
sortDirection: "desc",
|
||||
});
|
||||
}
|
||||
|
||||
archiveThread(threadId: string): Promise<ThreadArchiveResponse> {
|
||||
return this.request("thread/archive", { threadId });
|
||||
}
|
||||
|
||||
deleteThread(threadId: string): Promise<ThreadDeleteResponse> {
|
||||
return this.request("thread/delete", { threadId });
|
||||
}
|
||||
|
||||
readThread(threadId: string, includeTurns = true): Promise<ThreadReadResponse> {
|
||||
return this.request("thread/read", { threadId, includeTurns });
|
||||
}
|
||||
|
||||
readFile(path: string, options: { timeoutMs?: number } = {}): Promise<FsReadFileResponse> {
|
||||
return this.request("fs/readFile", { path }, options);
|
||||
}
|
||||
|
||||
unarchiveThread(threadId: string): Promise<ThreadUnarchiveResponse> {
|
||||
return this.request("thread/unarchive", { threadId });
|
||||
}
|
||||
|
||||
rollbackThread(threadId: string, numTurns = 1): Promise<ThreadRollbackResponse> {
|
||||
return this.request("thread/rollback", { threadId, numTurns });
|
||||
}
|
||||
|
||||
setThreadName(threadId: string, name: string): Promise<ThreadSetNameResponse> {
|
||||
return this.request("thread/name/set", { threadId, name });
|
||||
}
|
||||
|
||||
getThreadGoal(threadId: string): Promise<ThreadGoalGetResponse> {
|
||||
return this.request("thread/goal/get", { threadId });
|
||||
}
|
||||
|
||||
setThreadGoal(threadId: string, params: ThreadGoalUpdate): Promise<ThreadGoalSetResponse> {
|
||||
return this.request("thread/goal/set", { threadId, ...appServerThreadGoalUpdate(params) });
|
||||
}
|
||||
|
||||
clearThreadGoal(threadId: string): Promise<ThreadGoalClearResponse> {
|
||||
return this.request("thread/goal/clear", { threadId });
|
||||
}
|
||||
|
||||
injectThreadItems(threadId: string, items: ClientRequestParams<"thread/inject_items">["items"]): Promise<ThreadInjectItemsResponse> {
|
||||
return this.request("thread/inject_items", { threadId, items });
|
||||
}
|
||||
|
||||
updateThreadSettings(threadId: string, settings: RuntimeSettingsPatch): Promise<ThreadSettingsUpdateResponse> {
|
||||
return this.request("thread/settings/update", { threadId, ...appServerRuntimeSettingsPatch(settings) });
|
||||
}
|
||||
|
||||
threadTurnsList(
|
||||
threadId: string,
|
||||
cursor: string | null = null,
|
||||
limit = 20,
|
||||
sortDirection: SortDirection = "desc",
|
||||
itemsView: TurnItemsView = "full",
|
||||
): Promise<ThreadTurnsListResponse> {
|
||||
return this.request("thread/turns/list", {
|
||||
threadId,
|
||||
cursor,
|
||||
limit,
|
||||
sortDirection,
|
||||
itemsView,
|
||||
});
|
||||
}
|
||||
|
||||
listSkills(cwd: string, forceReload = false): Promise<SkillsListResponse> {
|
||||
return this.request("skills/list", {
|
||||
cwds: [cwd],
|
||||
forceReload,
|
||||
});
|
||||
}
|
||||
|
||||
listApps(params: AppsListParams = { limit: 100 }): Promise<AppsListResponse> {
|
||||
return this.request("app/list", params);
|
||||
}
|
||||
|
||||
listInstalledPlugins(cwd: string): Promise<PluginInstalledResponse> {
|
||||
const params: PluginInstalledParams = { cwds: [cwd] };
|
||||
return this.request("plugin/installed", params);
|
||||
}
|
||||
|
||||
readPlugin(params: PluginReadParams): Promise<PluginReadResponse> {
|
||||
return this.request("plugin/read", params);
|
||||
}
|
||||
|
||||
listModels(includeHidden = false): Promise<ModelListResponse> {
|
||||
return this.request("model/list", {
|
||||
includeHidden,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
|
||||
readAccountRateLimits(): Promise<GetAccountRateLimitsResponse> {
|
||||
return this.request("account/rateLimits/read", undefined);
|
||||
}
|
||||
|
||||
listMcpServerStatus(
|
||||
params: ListMcpServerStatusParams = { detail: "toolsAndAuthOnly", limit: 100 },
|
||||
): Promise<ListMcpServerStatusResponse> {
|
||||
return this.request("mcpServerStatus/list", params);
|
||||
}
|
||||
|
||||
listCollaborationModes(): Promise<CollaborationModeListResponse> {
|
||||
return this.request("collaborationMode/list", {});
|
||||
}
|
||||
|
||||
readModelProviderCapabilities(): Promise<ModelProviderCapabilitiesReadResponse> {
|
||||
return this.request("modelProvider/capabilities/read", {});
|
||||
}
|
||||
|
||||
compactThread(threadId: string): Promise<ThreadCompactStartResponse> {
|
||||
return this.request("thread/compact/start", { threadId });
|
||||
}
|
||||
|
||||
startTurn(options: AppServerStartTurnOptions): Promise<TurnStartResponse> {
|
||||
const { threadId, cwd, input, clientUserMessageId, runtime } = options;
|
||||
const additionalContext = toAdditionalContext(input);
|
||||
const params: ClientRequestParams<"turn/start"> = {
|
||||
threadId,
|
||||
cwd,
|
||||
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
|
||||
...(additionalContext !== undefined ? { additionalContext } : {}),
|
||||
...appServerTurnRuntimeParams(runtime),
|
||||
input: toUserInput(input),
|
||||
};
|
||||
return this.request("turn/start", params);
|
||||
}
|
||||
|
||||
startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<TurnStartResponse> {
|
||||
const { threadId, cwd, text, outputSchema, runtime } = options;
|
||||
const params: ClientRequestParams<"turn/start"> = {
|
||||
threadId,
|
||||
cwd,
|
||||
input: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
text_elements: [],
|
||||
},
|
||||
],
|
||||
outputSchema,
|
||||
...appServerTurnRuntimeParams(runtime),
|
||||
};
|
||||
return this.request("turn/start", params);
|
||||
}
|
||||
|
||||
steerTurn(
|
||||
threadId: string,
|
||||
expectedTurnId: string,
|
||||
input: string | CodexInput,
|
||||
clientUserMessageId?: string | null,
|
||||
): Promise<TurnSteerResponse> {
|
||||
const additionalContext = toAdditionalContext(input);
|
||||
return this.request("turn/steer", {
|
||||
threadId,
|
||||
expectedTurnId,
|
||||
input: toUserInput(input),
|
||||
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
|
||||
...(additionalContext !== undefined ? { additionalContext } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
interruptTurn(threadId: string, turnId: string): Promise<TurnInterruptResponse> {
|
||||
return this.request("turn/interrupt", { threadId, turnId });
|
||||
}
|
||||
|
||||
private writeHookState(key: string, state: Record<string, JsonValue>): Promise<ConfigWriteResponse> {
|
||||
return this.request("config/batchWrite", {
|
||||
edits: [
|
||||
{
|
||||
keyPath: "hooks.state",
|
||||
value: {
|
||||
[key]: state,
|
||||
},
|
||||
mergeStrategy: "upsert",
|
||||
},
|
||||
],
|
||||
reloadUserConfig: true,
|
||||
});
|
||||
}
|
||||
|
||||
respondToServerRequest(requestId: RequestId, result: unknown): void {
|
||||
this.rpc.respond(requestId, result);
|
||||
}
|
||||
|
|
@ -239,7 +549,7 @@ export class AppServerClient {
|
|||
this.rpc.reject(requestId, code, message);
|
||||
}
|
||||
|
||||
request<M extends TypedClientRequestMethod>(
|
||||
private request<M extends TypedClientRequestMethod>(
|
||||
method: M,
|
||||
params: ClientRequestParams<M>,
|
||||
options: { timeoutMs?: number } = {},
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue