mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Compare commits
465 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc4d82c26 | ||
|
|
811a4b7158 | ||
|
|
05ea87643a | ||
|
|
bb673b2a75 | ||
|
|
01a20dcf70 | ||
|
|
73be2beea7 | ||
|
|
14adac592d | ||
|
|
f4616cf945 | ||
|
|
67289765c6 | ||
|
|
ab0a9047f4 | ||
|
|
488344a74d | ||
|
|
6bdd9e5972 | ||
|
|
5b6fce1a4d | ||
|
|
05f9740e8e | ||
|
|
6b2a20cf0d | ||
|
|
d0965d19b1 | ||
|
|
381e2a5467 | ||
|
|
03305a47ef | ||
|
|
d74747fbf0 | ||
|
|
f707da0274 | ||
|
|
2fb7d29841 | ||
|
|
8a798a5bca | ||
|
|
689368fe8d | ||
|
|
5cd106973c | ||
|
|
f3d987ee03 | ||
|
|
bbe311c4de | ||
|
|
4f3ebcaa31 | ||
|
|
96b88c1715 | ||
|
|
193b680f06 | ||
|
|
2d9f98f434 | ||
|
|
ffcc814ba5 | ||
|
|
1f5aa50862 | ||
|
|
a2c896d0ae | ||
|
|
205893cc1f | ||
|
|
b00f6292c4 | ||
|
|
8d952fcfbf | ||
|
|
54dcbd8759 | ||
|
|
381646ebfb | ||
|
|
aaa04507e0 | ||
|
|
a92f3b031d | ||
|
|
567c744624 | ||
|
|
11c233b8fe | ||
|
|
aadcf179bc | ||
|
|
52c350093a | ||
|
|
a35c9f4263 | ||
|
|
dc467695f4 | ||
|
|
1ef344b02a | ||
|
|
f93570848f | ||
|
|
cf0548a0d0 | ||
|
|
ea665af455 | ||
|
|
7ec4acf2b1 | ||
|
|
44128caf2c | ||
|
|
e7efd773b3 | ||
|
|
9c71b0eb88 | ||
|
|
d3b426d500 | ||
|
|
d114c3e473 | ||
|
|
eecf73dc77 | ||
|
|
358cdd7b7c | ||
|
|
5cd898fabe | ||
|
|
8351156589 | ||
|
|
ef5d1d803d | ||
|
|
760421613d | ||
|
|
46ecf3addd | ||
|
|
93c4ba50a1 | ||
|
|
d568f9abb1 | ||
|
|
d1337a6c85 | ||
|
|
eb6d4a3c2d | ||
|
|
4820fd5821 | ||
|
|
f588719440 | ||
|
|
6524409313 | ||
|
|
d8f4f8a899 | ||
|
|
9b2585b2bd | ||
|
|
4581ee4c5f | ||
|
|
56a7662b2e | ||
|
|
93d0021391 | ||
|
|
dfeeb0e4b2 | ||
|
|
ffb11490ce | ||
|
|
51994c6777 | ||
|
|
5e2007c899 | ||
|
|
4ddb17a27b | ||
|
|
48bd261f64 | ||
|
|
58f8805328 | ||
|
|
6800bcc339 | ||
|
|
562513099f | ||
|
|
e056fee378 | ||
|
|
2de242819e | ||
|
|
9ad8ade044 | ||
|
|
1eb074b361 | ||
|
|
716d6a2db5 | ||
|
|
4b0218169c | ||
|
|
ec3bcd0312 | ||
|
|
7dfc023360 | ||
|
|
900d5cd53c | ||
|
|
01f5de79b7 | ||
|
|
a7fbb2bddc | ||
|
|
eff82d1166 | ||
|
|
3dd4c8b9a3 | ||
|
|
60882f68ea | ||
|
|
dfff322541 | ||
|
|
40153a1da5 | ||
|
|
ed942d1c9b | ||
|
|
86fc06998b | ||
|
|
c37832d0a3 | ||
|
|
760bdf3016 | ||
|
|
d525d97f9e | ||
|
|
bf8c87046b | ||
|
|
edf2b70902 | ||
|
|
cb6e89b1ae | ||
|
|
f7efd475a4 | ||
|
|
48401b6201 | ||
|
|
e4e69ca060 | ||
|
|
a97f80e25e | ||
|
|
359bf1c21e | ||
|
|
cb172edfb7 | ||
|
|
d202849313 | ||
|
|
a099e1fffc | ||
|
|
f7e5319c3e | ||
|
|
98a17574e2 | ||
|
|
779d12f401 | ||
|
|
a7317fd518 | ||
|
|
5e3eaf9b9f | ||
|
|
4faa986e17 | ||
|
|
805e7bfdf4 | ||
|
|
8ae6aab1fb | ||
|
|
bbd981f255 | ||
|
|
c23c453597 | ||
|
|
7d6b81d367 | ||
|
|
d010e2ac47 | ||
|
|
04341f7258 | ||
|
|
e7ad2ca4c4 | ||
|
|
2e97525d4f | ||
|
|
ca1a71eaac | ||
|
|
9624f19a95 | ||
|
|
635353cb99 | ||
|
|
10da7f273e | ||
|
|
09e2eb05ce | ||
|
|
788d5cc949 | ||
|
|
f8efaf1806 | ||
|
|
41ad68a419 | ||
|
|
88694b2a6a | ||
|
|
8fc051ebd4 | ||
|
|
84b920d0da | ||
|
|
00402e0d56 | ||
|
|
2c254339cf | ||
|
|
10c69cec3f | ||
|
|
34aca42dc0 | ||
|
|
3db509a98a | ||
|
|
08cd3f624e | ||
|
|
bd74d47ec1 | ||
|
|
4d138a3f9b | ||
|
|
57cab74ecf | ||
|
|
c053888fcd | ||
|
|
b92b6d5fd3 | ||
|
|
b30b913c04 | ||
|
|
9d2b750d77 | ||
|
|
8cd3bafaff | ||
|
|
ce427a9a6c | ||
|
|
acd6c418d8 | ||
|
|
859ccb2ac1 | ||
|
|
2364664c2e | ||
|
|
eb56414cd7 | ||
|
|
26adfa4d46 | ||
|
|
a1ad965dcc | ||
|
|
160c344008 | ||
|
|
697b748aaf | ||
|
|
d174f3273c | ||
|
|
bcb9759a91 | ||
|
|
c039e8be7a | ||
|
|
45f1a47848 | ||
|
|
17835a8b7d | ||
|
|
e1e3786531 | ||
|
|
0c587f72cd | ||
|
|
dec80ff6cf | ||
|
|
3845da80e0 | ||
|
|
305a257ace | ||
|
|
ad50c56090 | ||
|
|
78a8432bed | ||
|
|
e2f5b88f2e | ||
|
|
d21922f814 | ||
|
|
1ff3aa8c14 | ||
|
|
919f9f14b3 | ||
|
|
58bbaf6a5f | ||
|
|
164c307bd1 | ||
|
|
5f22d21dc7 | ||
|
|
d9024ba983 | ||
|
|
0cb39fb6dc | ||
|
|
ee0cfa9891 | ||
|
|
2a0fb96ce6 | ||
|
|
7f09418f7c | ||
|
|
5e362add5b | ||
|
|
b6f6d04147 | ||
|
|
c0e4fc590c | ||
|
|
e9ca2b0448 | ||
|
|
c375f110a2 | ||
|
|
dccd63b75b | ||
|
|
03a026512e | ||
|
|
9616fe588d | ||
|
|
d52841240a | ||
|
|
d417157162 | ||
|
|
75bf1a0fb8 | ||
|
|
d28895b214 | ||
|
|
2ca7dc42d2 | ||
|
|
ca62b40929 | ||
|
|
5a2dff36c5 | ||
|
|
37051838a8 | ||
|
|
b3a770d1b1 | ||
|
|
34e85b9588 | ||
|
|
e44f95e368 | ||
|
|
a297859ec1 | ||
|
|
085af148e6 | ||
|
|
2debad388b | ||
|
|
682a23d712 | ||
|
|
d544791b3a | ||
|
|
35af82b5ed | ||
|
|
5c840a277f | ||
|
|
4202748cc6 | ||
|
|
36ed7fceb3 | ||
|
|
d81044c17b | ||
|
|
3f2c4fd970 | ||
|
|
3060834fda | ||
|
|
2353f725d1 | ||
|
|
0ce5ceedd5 | ||
|
|
19d35f7e33 | ||
|
|
e83dc59521 | ||
|
|
48c0b80f39 | ||
|
|
df9585499c | ||
|
|
d6fc86bb2e | ||
|
|
84367e795b | ||
|
|
3b42c1dbd4 | ||
|
|
a330f181b5 | ||
|
|
22be754296 | ||
|
|
a2fd30850b | ||
|
|
dffca699f0 | ||
|
|
42af40c299 | ||
|
|
a9e68e159d | ||
|
|
1946ecffe0 | ||
|
|
08ccb61737 | ||
|
|
4f6732da5d | ||
|
|
eeece5e14d | ||
|
|
c610bc377e | ||
|
|
3c684d239b | ||
|
|
95496f356f | ||
|
|
eeefedfc8f | ||
|
|
be63c6171d | ||
|
|
943ddd6b26 | ||
|
|
947430abbc | ||
|
|
b622c9e427 | ||
|
|
99257950d7 | ||
|
|
fe1ec64355 | ||
|
|
4dd222ab2c | ||
|
|
c0a954d4ab | ||
|
|
b045f74a86 | ||
|
|
1e3e34dd84 | ||
|
|
ee93b0c810 | ||
|
|
a3d3809e8b | ||
|
|
982760f401 | ||
|
|
9f8fb2c5c8 | ||
|
|
17b1dc8574 | ||
|
|
1d90eaf602 | ||
|
|
ba5e292487 | ||
|
|
504dae0886 | ||
|
|
ceb85bd251 | ||
|
|
b4e4f6251e | ||
|
|
210a4632f2 | ||
|
|
36f15b10ef | ||
|
|
394a900023 | ||
|
|
a35d0108d4 | ||
|
|
ee3efb7c6c | ||
|
|
d2cb61e097 | ||
|
|
60dba267ba | ||
|
|
d5390d9752 | ||
|
|
8568b224dc | ||
|
|
65702b3bb0 | ||
|
|
564c6b43f9 | ||
|
|
070fd261fc | ||
|
|
6575ad7bf3 | ||
|
|
48ca57ebe8 | ||
|
|
12189e5ddd | ||
|
|
b74e049360 | ||
|
|
636a890ed6 | ||
|
|
f0651bc0a8 | ||
|
|
07c29add11 | ||
|
|
9842ce38c4 | ||
|
|
7be9a1de24 | ||
|
|
5a1d55de22 | ||
|
|
4ed285cedd | ||
|
|
332f11ab7e | ||
|
|
478e5ef563 | ||
|
|
158845c374 | ||
|
|
a82442a987 | ||
|
|
8b25c32ce9 | ||
|
|
82ef80ece4 | ||
|
|
783b375617 | ||
|
|
a7406997e5 | ||
|
|
4daeae0cab | ||
|
|
0159606b6b | ||
|
|
f3c5a527d7 | ||
|
|
7fd1d8e254 | ||
|
|
2b543f5904 | ||
|
|
2cb3844011 | ||
|
|
9447d53d57 | ||
|
|
3d4454debe | ||
|
|
d749f4e031 | ||
|
|
e6a9b7d2b2 | ||
|
|
b7f1176838 | ||
|
|
f77fdc649f | ||
|
|
9d20e03cbb | ||
|
|
202328ffac | ||
|
|
bf26130fcc | ||
|
|
25bda423cc | ||
|
|
7c86d89810 | ||
|
|
70bcd45e5b | ||
|
|
316806c069 | ||
|
|
1f95937dcf | ||
|
|
26a5267ce7 | ||
|
|
207aefdf11 | ||
|
|
526b7e981e | ||
|
|
074a379b68 | ||
|
|
2aca116035 | ||
|
|
470c00a1f1 | ||
|
|
bf1e96d19e | ||
|
|
bd44303366 | ||
|
|
489f30d97c | ||
|
|
0b603354c4 | ||
|
|
34973dd7d7 | ||
|
|
e548d70356 | ||
|
|
193f84883e | ||
|
|
d860c328be | ||
|
|
2b04ed61e0 | ||
|
|
b316481acd | ||
|
|
f068607509 | ||
|
|
ca53d77fed | ||
|
|
727f5393af | ||
|
|
a14e3d19e7 | ||
|
|
32fb1d629c | ||
|
|
56b4c7d0b9 | ||
|
|
19dc423f21 | ||
|
|
7b1b789f3b | ||
|
|
85739b4766 | ||
|
|
e515eb904e | ||
|
|
2f187d7e94 | ||
|
|
0f428292f8 | ||
|
|
f9649cd870 | ||
|
|
e5f65736d6 | ||
|
|
e39d4b90a4 | ||
|
|
582bc0195d | ||
|
|
7a9f179020 | ||
|
|
0bca5b3477 | ||
|
|
b52fc163dd | ||
|
|
3471abb91e | ||
|
|
318ffb4532 | ||
|
|
b47a43e763 | ||
|
|
5abc9cf2c3 | ||
|
|
49c45c7166 | ||
|
|
e91476c1b8 | ||
|
|
1382c3ff50 | ||
|
|
89131fd84c | ||
|
|
697f7a59d2 | ||
|
|
6a85d4f6c6 | ||
|
|
7f6089f728 | ||
|
|
bcf7e10e6e | ||
|
|
638cecb991 | ||
|
|
c35a07bc9c | ||
|
|
6c9936a68c | ||
|
|
61bb05da0f | ||
|
|
3759f76e90 | ||
|
|
bf5a4cad47 | ||
|
|
3f62afa94f | ||
|
|
5323337d84 | ||
|
|
660544687e | ||
|
|
f71f40b61a | ||
|
|
d7bb49efbe | ||
|
|
4e107dadd3 | ||
|
|
1a90cf977d | ||
|
|
5ad3c18d34 | ||
|
|
7c5da3d520 | ||
|
|
dd538d9e44 | ||
|
|
c8bc272ab7 | ||
|
|
9766c9edae | ||
|
|
bc88c40201 | ||
|
|
147136ba52 | ||
|
|
195c891566 | ||
|
|
0a13754de7 | ||
|
|
b8a085b4bf | ||
|
|
dd280f74e7 | ||
|
|
2888c0cff0 | ||
|
|
d05b083c12 | ||
|
|
fef24552e9 | ||
|
|
649dfba360 | ||
|
|
a23ccfbd82 | ||
|
|
199a757618 | ||
|
|
3c21ef6485 | ||
|
|
30f5a79097 | ||
|
|
2de2e03409 | ||
|
|
6fb9e60dea | ||
|
|
498632cd7d | ||
|
|
6ac54c494e | ||
|
|
f125a4cf17 | ||
|
|
6afe931d01 | ||
|
|
2898e0f74a | ||
|
|
fe41e82a64 | ||
|
|
f475fc4491 | ||
|
|
75937b332a | ||
|
|
ca26a53e1b | ||
|
|
79a2c7fcbf | ||
|
|
1a42558d68 | ||
|
|
8edb115e08 | ||
|
|
b393000132 | ||
|
|
47c4ed4631 | ||
|
|
ce99e76a2b | ||
|
|
321323532b | ||
|
|
845194a65a | ||
|
|
e57eb45933 | ||
|
|
b148ada1d0 | ||
|
|
beb7497b5c | ||
|
|
3ab8b82683 | ||
|
|
79d0d6ea55 | ||
|
|
f4f99af890 | ||
|
|
c9d5ff46a5 | ||
|
|
15d9753789 | ||
|
|
439aca3eb0 | ||
|
|
31c493ed63 | ||
|
|
79a72586a6 | ||
|
|
2992621354 | ||
|
|
28857413b3 | ||
|
|
8f14f89098 | ||
|
|
412508534c | ||
|
|
0d5f3e9a3f | ||
|
|
a2af9320e0 | ||
|
|
c2547fd026 | ||
|
|
936afbfc96 | ||
|
|
c5de0a7709 | ||
|
|
a24a56fabf | ||
|
|
e74b118d04 | ||
|
|
d51b6338c0 | ||
|
|
5b7c3fcda2 | ||
|
|
5d0c83d05b | ||
|
|
683a703d85 | ||
|
|
4cb72e957d | ||
|
|
60ae60c1e1 | ||
|
|
f5c02b8340 | ||
|
|
128e777d9e | ||
|
|
c63bc1b9df | ||
|
|
4d9d9762ef | ||
|
|
46dd9135f0 | ||
|
|
b94c2ac804 | ||
|
|
612782ded9 | ||
|
|
7d36b552a0 | ||
|
|
b6629a75db | ||
|
|
2ff5c89e2b | ||
|
|
f474ff49c7 | ||
|
|
d38bdaf32c | ||
|
|
24b38f8163 | ||
|
|
5feae7757e | ||
|
|
1aef614890 | ||
|
|
4d4aae59db | ||
|
|
d6214bbdc1 | ||
|
|
98ec2aa6a3 | ||
|
|
71feebcde0 | ||
|
|
2d490c6fca | ||
|
|
1639c37ac3 | ||
|
|
18327ff883 | ||
|
|
711e300f1b | ||
|
|
1b95c91034 | ||
|
|
1e3e4c9f84 |
890 changed files with 71912 additions and 45198 deletions
|
|
@ -5,33 +5,24 @@ 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, `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.
|
||||
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.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run `npm run check` after regenerated bindings or compatibility changes.
|
||||
- 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.
|
||||
- Use the live Obsidian workflow when runtime behavior needs verification.
|
||||
- Report the tested Codex CLI version, changed compatibility behavior, and optional follow-ups.
|
||||
|
|
|
|||
|
|
@ -5,15 +5,11 @@ 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.
|
||||
- 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`.
|
||||
- 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.
|
||||
- Do not clear console or error buffers unless the user approves; clearing can destroy useful failure context.
|
||||
|
||||
## Workflow
|
||||
|
|
@ -24,13 +20,13 @@ Use this skill for live Obsidian checks after building Codex Panel or when inves
|
|||
npm run build
|
||||
```
|
||||
|
||||
2. Before reloading the live plugin, ask the user whether it is OK to reload their current Obsidian session. If approved:
|
||||
2. If approved, reload the plugin when the live session needs the new build:
|
||||
|
||||
```bash
|
||||
obsidian plugin:reload id=codex-panel
|
||||
```
|
||||
|
||||
3. Open the relevant Codex Panel surface when needed:
|
||||
3. Open the relevant surface when needed:
|
||||
|
||||
```bash
|
||||
obsidian command id=codex-panel:open-panel
|
||||
|
|
@ -47,54 +43,12 @@ Use this skill for live Obsidian checks after building Codex Panel or when inves
|
|||
obsidian dev:screenshot path=<scratch-path>/codex-panel.png
|
||||
```
|
||||
|
||||
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`.
|
||||
5. Use focused Codex Panel-owned selectors found in the relevant implementation or styles.
|
||||
|
||||
## Dynamic UI Investigations
|
||||
|
||||
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
|
||||
```
|
||||
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.
|
||||
|
||||
## Reporting
|
||||
|
||||
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.
|
||||
Report the observed behavior, runtime errors, intrusive actions taken, and evidence paths. State when an approval-gated check was skipped.
|
||||
|
|
|
|||
|
|
@ -5,28 +5,23 @@ description: Use when preparing, checking, committing, tagging, pushing, or repa
|
|||
|
||||
# Codex Panel Release
|
||||
|
||||
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.
|
||||
`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.
|
||||
- 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`.
|
||||
- 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.
|
||||
|
||||
## Delegation Procedure
|
||||
|
||||
1. Read `docs/release.md`, `package.json`, `manifest.json`, `versions.json`, and existing `.github/release-notes/` files.
|
||||
1. Read `docs/release.md` and inspect the current version metadata and recent release notes.
|
||||
2. Identify the target release version and the commit range since the previous released tag.
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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,6 +4,8 @@ updates:
|
|||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
|
|
@ -11,6 +13,9 @@ updates:
|
|||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
prefix-development: "chore(deps)"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
|
|
@ -24,3 +29,6 @@ updates:
|
|||
update-types:
|
||||
- "version-update:semver-minor"
|
||||
- "version-update:semver-major"
|
||||
- dependency-name: "typescript"
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
|
|
|
|||
7
.github/release-notes/3.4.3.md
vendored
Normal file
7
.github/release-notes/3.4.3.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
## Changes
|
||||
|
||||
- Adds support for Codex CLI 0.142.0 and its updated app-server protocol.
|
||||
- Aligns thread lists and the thread picker with Codex's recency-based ordering, including refreshes after new turns start.
|
||||
- Improves restored panel handling so reopened thread panels appear in Threads view and hydrate when focused or foregrounded.
|
||||
- Improves slash-command thread suggestions for `/archive`, `/rename`, `/resume`, and `/refer`, and lets `/reconnect` and `/compact` run while disconnected.
|
||||
- Returns focus to the composer after responding to pending approvals, user inputs, or MCP elicitations.
|
||||
3
.github/release-notes/3.4.4.md
vendored
Normal file
3
.github/release-notes/3.4.4.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## 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
Normal file
5
.github/release-notes/3.4.5.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
## 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
Normal file
4
.github/release-notes/3.4.6.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
## 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
Normal file
6
.github/release-notes/4.0.0.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
## 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
Normal file
4
.github/release-notes/4.0.1.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
## 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
Normal file
3
.github/release-notes/4.0.2.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## 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
Normal file
4
.github/release-notes/4.1.0.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
## 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
Normal file
4
.github/release-notes/4.1.1.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
## 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
Normal file
4
.github/release-notes/4.1.2.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
## 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
Normal file
4
.github/release-notes/4.2.0.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
## 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
Normal file
4
.github/release-notes/4.3.0.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
## 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
Normal file
5
.github/release-notes/4.3.1.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
## 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
Normal file
4
.github/release-notes/4.4.0.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
## 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
Normal file
5
.github/release-notes/4.4.1.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
## 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
Normal file
6
.github/release-notes/4.5.0.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
## 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
Normal file
3
.github/release-notes/4.5.1.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## Changes
|
||||
|
||||
- Polish chat panel wording around conversations, context, and composer guidance.
|
||||
6
.github/release-notes/4.5.2.md
vendored
Normal file
6
.github/release-notes/4.5.2.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
## 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
Normal file
4
.github/release-notes/4.6.0.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
## 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
Normal file
3
.github/release-notes/4.6.1.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## 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
Normal file
5
.github/release-notes/4.7.0.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
## 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
Normal file
4
.github/release-notes/4.7.1.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
## 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
Normal file
5
.github/release-notes/5.0.0.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
## 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
Normal file
5
.github/release-notes/5.0.1.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
## 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
Normal file
5
.github/release-notes/5.0.2.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
## 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
Normal file
3
.github/release-notes/5.0.3.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## Changes
|
||||
|
||||
- Release assets now carry individually verifiable provenance attestations.
|
||||
3
.github/release-notes/5.0.4.md
vendored
Normal file
3
.github/release-notes/5.0.4.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## 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
Normal file
6
.github/release-notes/5.1.0.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
## 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
Normal file
6
.github/release-notes/5.1.1.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
## 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
Normal file
6
.github/release-notes/5.1.2.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
## 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
Normal file
4
.github/release-notes/5.2.0.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
## 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
Normal file
5
.github/release-notes/5.2.1.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
## 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,23 +11,76 @@ permissions:
|
|||
|
||||
jobs:
|
||||
check:
|
||||
name: npm run check:ci
|
||||
name: npm run check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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: 24
|
||||
node-version: 26
|
||||
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:ci
|
||||
run: npm run check
|
||||
|
|
|
|||
34
.github/workflows/release.yml
vendored
34
.github/workflows/release.yml
vendored
|
|
@ -7,8 +7,6 @@ on:
|
|||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
|
@ -17,34 +15,44 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 26
|
||||
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: Run checks and build
|
||||
run: npm run check:ci
|
||||
- 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: Generate artifact attestations
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4
|
||||
with:
|
||||
subject-path: |
|
||||
main.js
|
||||
manifest.json
|
||||
styles.css
|
||||
- name: Run checks and build
|
||||
run: npm run check
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,4 +1,6 @@
|
|||
node_modules/
|
||||
.pnpm-store/
|
||||
.stryker-tmp/
|
||||
.jj/
|
||||
.codex/
|
||||
main.js
|
||||
|
|
@ -7,4 +9,6 @@ data.json
|
|||
*.log
|
||||
.DS_Store
|
||||
dist/
|
||||
coverage/
|
||||
reports/
|
||||
tmp/
|
||||
|
|
|
|||
1
.node-version
Normal file
1
.node-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
26
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
node_modules/
|
||||
.jj/
|
||||
.codex/
|
||||
main.js
|
||||
styles.css
|
||||
data.json
|
||||
dist/
|
||||
tmp/
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"printWidth": 140,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
13
AGENTS.md
13
AGENTS.md
|
|
@ -1,6 +1,11 @@
|
|||
This repository contains the Codex Panel Obsidian plugin.
|
||||
|
||||
Jujutsu is the recommended local change-management workflow when available.
|
||||
## 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.
|
||||
|
||||
## What To Read
|
||||
|
||||
|
|
@ -10,4 +15,8 @@ Jujutsu is the recommended local change-management workflow when available.
|
|||
- 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.
|
||||
|
||||
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.
|
||||
## 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.
|
||||
|
|
|
|||
68
README.md
68
README.md
|
|
@ -1,27 +1,29 @@
|
|||
# Codex Panel
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
If the Codex CLI is already installed and authenticated, Codex Panel uses that local setup.
|
||||
|
||||

|
||||
|
||||
## Why use it
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## How it works
|
||||
|
||||
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 starts `codex app-server` locally. Each open panel keeps its own active thread and draft, with Codex working from the vault root.
|
||||
|
||||
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.
|
||||
Codex Panel stores panel preferences only, not API keys or provider credentials.
|
||||
|
||||
## 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 vault where Codex is allowed to work according to your Codex CLI configuration.
|
||||
- A local vault where Codex is allowed to work.
|
||||
|
||||
Codex Panel does not support Obsidian mobile.
|
||||
|
||||
|
|
@ -33,48 +35,46 @@ You can also open the plugin page directly: <https://community.obsidian.md/plugi
|
|||
|
||||
## Quick start
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Using Codex Panel
|
||||
If Obsidian cannot find `codex`, set **Settings -> Codex Panel -> Codex executable** to an absolute path such as `/opt/homebrew/bin/codex`.
|
||||
|
||||
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.
|
||||
## Working with Codex Panel
|
||||
|
||||
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.
|
||||
### Keep work separate or pick it up later
|
||||
|
||||
### Compose with vault context
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
### Bring in the right context
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Review edits in Obsidian
|
||||
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.
|
||||
|
||||
Codex file changes can be reviewed in an Obsidian diff view with a changed-files list and copy-diff action.
|
||||
### Guide a running turn
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Adjust a thread
|
||||
### Review and keep the result
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
Use `/help` for the current slash command list.
|
||||
|
||||
## 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. |
|
||||
| `codex.testedCliVersion` | `0.141.0` | Track app-server compatibility by Codex CLI 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. |
|
||||
| `codexAppServer.testedCliVersion` | `0.145.0` | Exact CLI patch used to generate and verify bindings; compatibility is tracked by minor version. |
|
||||
|
||||
Codex Panel depends on the experimental `codex app-server` API.
|
||||
|
||||
|
|
|
|||
BIN
assets/screenshot.webp
Normal file
BIN
assets/screenshot.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
221
biome.jsonc
Normal file
221
biome.jsonc
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
commitlint.config.mjs
Normal file
9
commitlint.config.mjs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
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,57 +6,87 @@ 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, thread display, composing, approvals and user input, vault-aware link handoff, archive export, settings for panel preferences, diagnostics, and selection rewrite review.
|
||||
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.
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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. Current optional and nullable fields are still part of the contract and must be normalized before display.
|
||||
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.
|
||||
|
||||
Runtime settings move through three panel-owned layers before they reach Codex. Chat state stores active runtime values reported by app-server plus pending runtime intents requested by the user; those intents are not app-server payload values. Effective runtime projections combine pending intent, active thread state, and runtime config for display and UI decisions. Only the app-server boundary converts intent into transport values: `undefined` omits a field, `null` explicitly clears or resets it, and concrete values set it. Config values are inputs to effective projection and selected start-thread defaults; they should not be treated as synonymous with transport `null`.
|
||||
Runtime controls should express visible user intent for the active thread rather than copy Codex configuration. Diagnostics should expose only actionable troubleshooting facts.
|
||||
|
||||
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 converts Fast mode enablement to the model catalog's Fast tier id when available, and converts Fast mode disablement or service-tier reset to an explicit clear request at the transport boundary.
|
||||
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.
|
||||
|
||||
## Code Boundaries
|
||||
|
||||
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. Feature-local app-server integration modules may route and apply those projections, but they should not hand-copy generated request or response shapes when a shared app-server protocol adapter can own that mapping.
|
||||
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.
|
||||
|
||||
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 rest of chat state and UI should still use panel-owned display models.
|
||||
Application workflows should depend on feature-owned contracts rather than app-server clients, RPC details, connection checks, vault-path wiring, or raw protocol projections.
|
||||
|
||||
Source modules should be organized by reason to change, not by the single Obsidian plugin entrypoint. Obsidian lifecycle, app-server transport, chat state, runtime display, thread management, selection rewrite, settings, and workspace coordination should each stay close to the state or API they own.
|
||||
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.
|
||||
|
||||
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/`.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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, message stream, composer, and request controls.
|
||||
Runtime UI composition is Preact-owned. Preact components should render the panel shell, toolbar, thread stream, composer, and request controls.
|
||||
|
||||
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.
|
||||
Obsidian and app-server boundaries stay outside Preact components. External lifecycles, app-server connections, editor/workspace APIs, and rendering bridges belong in boundary modules.
|
||||
|
||||
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.
|
||||
Chat-visible state should have one authoritative owner. Components should consume narrow projections of that state rather than mirror it into another reactive store.
|
||||
|
||||
Imperative DOM bridges are allowed when an external API or measurement problem requires an `HTMLElement`, such as Obsidian markdown rendering, rendered link binding, diff rendering, icon rendering, textarea measurement, and message stream scroll anchoring. They should not become a second UI composition system inside Preact-owned surfaces.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Selection rewrite is intentionally scoped: use the live editor buffer, ask Codex for a replacement candidate, show a focused diff, and apply only after user confirmation. Avoid expanding it into a broader writing assistant without a separate design decision.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Message stream display should separate primary conversation from diagnostic detail and progress/status. Preserve stable item identity across history reads, streaming updates, delayed rendering, and scroll anchoring.
|
||||
Thread 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.
|
||||
|
||||
|
|
@ -64,8 +94,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: 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.
|
||||
Prefer tests for visible behavior and state-transition invariants across panels, threads, requests, streams, and display fallbacks.
|
||||
|
||||
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.
|
||||
Avoid tests that freeze incidental implementation details 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.
|
||||
|
|
|
|||
|
|
@ -1,21 +1,47 @@
|
|||
# Development
|
||||
|
||||
Use this document for day-to-day implementation workflow: commands, source layout, generated files, naming, and common pitfalls. For the rationale behind responsibility boundaries and UI/runtime ownership, see `docs/design.md`.
|
||||
Use this document for day-to-day implementation mechanics: commands, generated files, loaded artifacts, source layout, naming, validation, and common pitfalls. For product boundaries and design rationale, see `docs/design.md`.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run format
|
||||
npm run fix
|
||||
npm run check
|
||||
npm run build
|
||||
npm run test:coverage
|
||||
npm run test:mutation
|
||||
```
|
||||
|
||||
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, and Prettier check in parallel, then runs the CSS build verification and production esbuild bundle as the final step. `npm run check` and `npm run check:ci` use the same script runner; the local path uses caches where available, while `npm run check:ci` uses the non-cached validation commands used by CI.
|
||||
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.
|
||||
|
||||
## 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` after source changes.
|
||||
`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.
|
||||
|
||||
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`.
|
||||
|
||||
|
|
@ -23,57 +49,45 @@ 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
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Source Layout
|
||||
## Placement Rules
|
||||
|
||||
The source tree is organized by responsibility rather than by the original single Obsidian plugin entrypoint:
|
||||
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.
|
||||
|
||||
- `src/main.ts` registers Obsidian views, commands, settings, and lifecycle hooks.
|
||||
- `src/app-server/` owns the app-server boundary. Generated app-server payloads should stay behind this boundary; exported app-server services should expose panel-owned domain models or small local projection types unless an explicit boundary exception is documented. Server request protocol adapters in `src/app-server/protocol/` own method-specific request normalization and response payload construction.
|
||||
- `src/features/chat/` owns the main Codex chat surface: Obsidian `ItemView` classes, panel orchestration, request/app-server controllers, composer behavior, display models, chat-only UI renderers, runtime state and status projection, approvals, user input, thread actions, and panel-specific state. Chat modules under `features/chat/app-server/` are feature-local integration code: they route app-server events and convert app-server projections into chat state, pending requests, 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; app-server protocol and service modules adapt generated payloads into domain models at the boundary.
|
||||
- `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.
|
||||
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.
|
||||
|
||||
## Implementation Conventions
|
||||
## CSS Rules
|
||||
|
||||
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/`. Lower-level modules must not import `src/features/`, and generated app-server types should stay behind `src/app-server/`. `docs/design.md` records the rationale and exception rules for these boundaries.
|
||||
CSS should stay native to Obsidian. Prefer Obsidian variables and Codex Panel tokens for color, typography, spacing, and layout dimensions instead of hardcoded values.
|
||||
|
||||
Codex Panel's runtime UI is Preact-owned. 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. Chat panel-visible state belongs in `ChatStateStore` and should flow through named reducer actions and the shell-state adapter. Revisit `docs/design.md` before adding a parallel UI state or runtime path.
|
||||
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.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
Prefer functions and factories. Reserve classes for mutable resource ownership, external class APIs, and `Error` types.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 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 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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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 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.
|
||||
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.
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```sh
|
||||
npm run release:prepare -- X.Y.Z
|
||||
# Edit .github/release-notes/X.Y.Z.md.
|
||||
# Review and edit .github/release-notes/X.Y.Z.md.
|
||||
jj status
|
||||
jj commit -m "Bump version to X.Y.Z"
|
||||
jj commit -m "chore(release): X.Y.Z"
|
||||
jj bookmark move main --to @-
|
||||
npm run release:preflight
|
||||
jj tag set X.Y.Z -r main
|
||||
|
|
@ -20,8 +20,10 @@ jj git push --remote origin --bookmark main
|
|||
git push origin X.Y.Z
|
||||
```
|
||||
|
||||
`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: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 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 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`.
|
||||
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`.
|
||||
|
|
|
|||
|
|
@ -1,263 +1,34 @@
|
|||
import js from "@eslint/js";
|
||||
import json from "@eslint/json";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import codexPanelEslintPlugin from "./scripts/lint/eslint-plugin-codex-panel.mjs";
|
||||
import { PlainTextParser } from "eslint-plugin-obsidianmd/dist/lib/plainTextParser.js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
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: "Keep chat signals in panel/shell-state.tsx as a reducer-to-Preact notification 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 chatAppServerRequestBridgeBannedModules = nonAppServerBannedAppServerProtocolModules.filter(
|
||||
(moduleName) => moduleName !== "server-requests",
|
||||
);
|
||||
const chatAppServerRequestBridgeBannedImportPatterns = chatAppServerRequestBridgeBannedModules.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 chatAppServerRequestBridgeFiles = ["src/features/chat/app-server/requests/**/*.{ts,tsx}"];
|
||||
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],
|
||||
};
|
||||
}
|
||||
const sourceTypeScriptFiles = ["src/**/*.{ts,tsx}"];
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: ["main.js", "node_modules/**", "src/generated/**"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
basePath: "src",
|
||||
plugins: {
|
||||
obsidianmd,
|
||||
},
|
||||
},
|
||||
...obsidianmd.configs.recommended.map(obsidianSourceRecommendedConfig).filter(Boolean),
|
||||
...tseslint.configs.strictTypeChecked.map((config) => ({
|
||||
...config,
|
||||
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,
|
||||
},
|
||||
files: sourceTypeScriptFiles,
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
projectService: true,
|
||||
},
|
||||
globals: {
|
||||
AbortSignal: "readonly",
|
||||
|
|
@ -273,292 +44,72 @@ export default defineConfig([
|
|||
},
|
||||
},
|
||||
rules: {
|
||||
...projectTypeScriptRules,
|
||||
// Biome import restrictions do not cover `import("...").T`; keep that type syntax out before source-boundary lint runs.
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
disallowTypeAnnotations: true,
|
||||
fixStyle: "separate-type-imports",
|
||||
prefer: "type-imports",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/require-await": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: nodeJavaScriptFiles,
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
URL: "readonly",
|
||||
console: "readonly",
|
||||
process: "readonly",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["tests/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
...testTypeScriptRelaxations,
|
||||
...restrictedSyntaxRule(generatedAppServerThreadImportRestrictions),
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.ts", "src/**/*.tsx"],
|
||||
files: ["manifest.json"],
|
||||
plugins: {
|
||||
"codex-panel": codexPanelEslintPlugin,
|
||||
json,
|
||||
obsidianmd,
|
||||
},
|
||||
language: "json/json",
|
||||
rules: {
|
||||
"codex-panel/no-self-referential-initializer-callback": "error",
|
||||
"obsidianmd/ui/sentence-case": [
|
||||
"error",
|
||||
{
|
||||
acronyms: ["MCP"],
|
||||
brands: ["Codex", "Codex Panel", "Obsidian"],
|
||||
},
|
||||
],
|
||||
"obsidianmd/validate-manifest": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
ignores: ["src/features/chat/**/*.{ts,tsx}", ...nonChatImperativeDomBridgeFiles],
|
||||
files: ["LICENSE"],
|
||||
plugins: {
|
||||
obsidianmd,
|
||||
},
|
||||
languageOptions: {
|
||||
parser: PlainTextParser,
|
||||
},
|
||||
rules: {
|
||||
...restrictedSyntaxRule(sourceSyntaxRestrictions),
|
||||
"codex-panel/no-imperative-dom": "error",
|
||||
"obsidianmd/validate-license": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/features/chat/**/*.{ts,tsx}"],
|
||||
ignores: ["src/features/chat/panel/shell-state.tsx", ...chatImperativeDomBridgeFiles],
|
||||
rules: {
|
||||
...restrictedSyntaxRule(chatSourceSyntaxRestrictions),
|
||||
"codex-panel/no-imperative-dom": "error",
|
||||
"codex-panel/no-chat-state-direct-mutation": "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: [
|
||||
{
|
||||
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/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: chatAppServerRequestBridgeFiles,
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: chatAppServerRequestBridgeBannedImportPatterns,
|
||||
message:
|
||||
"Chat request bridges 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,
|
||||
]);
|
||||
|
||||
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",
|
||||
{
|
||||
enforceCamelCaseLower: true,
|
||||
acronyms: ["MCP"],
|
||||
brands: ["Codex", "Codex Panel", "Obsidian"],
|
||||
},
|
||||
],
|
||||
];
|
||||
}),
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
"ignoreBinaries": ["codex", "taskkill"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "codex-panel",
|
||||
"name": "Codex Panel",
|
||||
"version": "3.4.2",
|
||||
"version": "5.2.1",
|
||||
"minAppVersion": "1.12.0",
|
||||
"description": "Codex in your sidebar.",
|
||||
"author": "murashit",
|
||||
|
|
|
|||
4822
package-lock.json
generated
4822
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": "3.4.2",
|
||||
"version": "5.2.1",
|
||||
"description": "Codex in your Obsidian sidebar.",
|
||||
"main": "main.js",
|
||||
"author": "murashit",
|
||||
|
|
@ -13,49 +13,65 @@
|
|||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"generate:app-server-types": "node scripts/generate-app-server-types.mjs",
|
||||
"lint": "node scripts/check.mjs --lint",
|
||||
"lint:ci": "node scripts/check.mjs --ci --lint",
|
||||
"generate:app-server-types:check": "node scripts/generate-app-server-types.mjs --check",
|
||||
"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 --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"
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^25.9.3",
|
||||
"concurrently": "^10.0.3",
|
||||
"@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",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-obsidianmd": "^0.4.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^6.16.1",
|
||||
"knip": "^6.25.0",
|
||||
"moment": "^2.30.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.61.0",
|
||||
"vitest": "^4.1.8"
|
||||
"typescript-eslint": "^8.64.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@preact/signals": "^2.9.1",
|
||||
"@tanstack/query-core": "^5.101.0",
|
||||
"@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",
|
||||
"micromark": "^4.0.2",
|
||||
"preact": "^10.29.2"
|
||||
"obsidian-daily-notes-interface": "^0.9.5",
|
||||
"preact": "^10.29.7",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,28 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const asJson = args.has("--json");
|
||||
const validArgs = new Set(["--json"]);
|
||||
|
||||
for (const arg of args) {
|
||||
if (!validArgs.has(arg)) {
|
||||
console.error("Usage: node scripts/api-baseline.mjs [--json]");
|
||||
process.exit(1);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
failures.push(message);
|
||||
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 parseSemver(value) {
|
||||
|
|
@ -41,11 +50,137 @@ 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("codex.testedCliVersion") ?? null,
|
||||
codexTestedCliVersion: table.get("codexAppServer.testedCliVersion") ?? null,
|
||||
obsidianApiTypesVersion: table.get("obsidian") ?? null,
|
||||
obsidianMinAppVersion: table.get("manifest.minAppVersion") ?? null,
|
||||
};
|
||||
|
|
@ -67,7 +202,7 @@ function readMarkdownTableValues(markdown) {
|
|||
const header = splitMarkdownTableRow(lines[index]);
|
||||
if (!header) continue;
|
||||
const separator = splitMarkdownTableRow(lines[index + 1]);
|
||||
if (!separator || !separator.every((cell) => /^:?-{3,}:?$/.test(cell))) continue;
|
||||
if (!separator?.every((cell) => /^:?-{3,}:?$/.test(cell))) continue;
|
||||
|
||||
const keyColumn = header.findIndex((cell) => normalizeTableHeader(cell) === "key");
|
||||
const versionColumn = header.findIndex((cell) => normalizeTableHeader(cell) === "version");
|
||||
|
|
@ -112,126 +247,22 @@ function displayValue(value) {
|
|||
return value ?? "(missing)";
|
||||
}
|
||||
|
||||
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)}.`,
|
||||
);
|
||||
function arraysEqual(left, right) {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
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"),
|
||||
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"),
|
||||
]);
|
||||
|
||||
return {
|
||||
appServerGenerateSource,
|
||||
clientSource,
|
||||
appServerCompatibilityJson,
|
||||
manifestJson,
|
||||
packageJson,
|
||||
packageLockJson,
|
||||
|
|
@ -240,8 +271,12 @@ async function readBaselineInputs() {
|
|||
};
|
||||
}
|
||||
|
||||
async function readJson(file) {
|
||||
return JSON.parse(await readFile(file, "utf8"));
|
||||
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;
|
||||
}
|
||||
|
||||
function printReport(report) {
|
||||
|
|
@ -250,10 +285,11 @@ 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(` generate-ts --experimental: ${report.codex.appServerGenerationExperimental ? "yes" : "no"}`);
|
||||
console.log(` generation arguments: ${report.codex.generationArguments?.join(" ") ?? "(missing)"}`);
|
||||
console.log(` initialize experimentalApi: ${report.codex.initializeExperimentalApi ? "yes" : "no"}`);
|
||||
console.log(` initialize requestAttestation disabled: ${report.codex.initializeRequestAttestationDisabled ? "yes" : "no"}`);
|
||||
console.log("");
|
||||
|
|
|
|||
49
scripts/app-server-compatibility.mjs
Normal file
49
scripts/app-server-compatibility.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
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,6 +21,7 @@ export async function buildStyles() {
|
|||
}
|
||||
|
||||
export async function renderStyles() {
|
||||
const { version } = JSON.parse(await readFile("package.json", "utf8"));
|
||||
const parts = [];
|
||||
|
||||
const sourceFiles = await readStyleOrder();
|
||||
|
|
@ -29,7 +30,7 @@ export async function renderStyles() {
|
|||
parts.push(content.trimEnd());
|
||||
}
|
||||
|
||||
return `${parts.join("\n\n")}\n`;
|
||||
return `/* Codex Panel v${version} */\n\n${parts.join("\n\n")}\n`;
|
||||
}
|
||||
|
||||
async function readStyleOrder() {
|
||||
|
|
|
|||
|
|
@ -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/lint/check-css-usage.mjs");
|
||||
console.error("Usage: node scripts/check-css-usage.mjs");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
@ -15,6 +15,8 @@ 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");
|
||||
|
|
@ -22,14 +24,12 @@ 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,8 +39,13 @@ for (const [className, locations] of cssClasses) {
|
|||
candidates.push({ className, locations });
|
||||
}
|
||||
|
||||
if (candidates.length + testOnlyCandidates.length > 0) {
|
||||
printCandidates({ testOnlyCandidates, candidates });
|
||||
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 });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
@ -56,12 +61,11 @@ async function orderedCssFiles() {
|
|||
async function collectCssClasses(files) {
|
||||
const result = new Map();
|
||||
for (const file of files) {
|
||||
const text = await readFile(file, "utf8");
|
||||
const text = stripCssComments(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 withoutComment.matchAll(classPattern)) {
|
||||
for (const match of line.matchAll(classPattern)) {
|
||||
const className = match[2];
|
||||
if (!className.startsWith("codex-panel")) continue;
|
||||
const locations = result.get(className) ?? [];
|
||||
|
|
@ -73,6 +77,38 @@ 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;
|
||||
|
|
@ -92,9 +128,12 @@ 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) {
|
||||
let offset = text.indexOf(needle);
|
||||
if (offset === -1) continue;
|
||||
const match = pattern.exec(text);
|
||||
if (!match) continue;
|
||||
const offset = match.index + match[1].length;
|
||||
const line = text.slice(0, offset).split("\n").length;
|
||||
locations.push(`${relative(file)}:${line}`);
|
||||
}
|
||||
|
|
@ -122,8 +161,16 @@ async function collectFiles(directory, extensions, excludedPrefixes, result) {
|
|||
}
|
||||
}
|
||||
|
||||
function printCandidates({ testOnlyCandidates, candidates }) {
|
||||
function printCandidates({ testOnlyCandidates, candidates, unusedCustomProperties, dynamicPrefixes }) {
|
||||
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:");
|
||||
|
|
@ -142,6 +189,15 @@ function printCandidates({ testOnlyCandidates, candidates }) {
|
|||
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) {
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
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" },
|
||||
];
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
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,21 +1,105 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readAppServerGenerationPolicy } from "./app-server-compatibility.mjs";
|
||||
|
||||
const generatedDir = path.resolve("src/generated/app-server");
|
||||
const generatedRelativeDir = "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.";
|
||||
|
||||
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 });
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeGeneratedTypes() {
|
||||
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) {
|
||||
const files = await listTypeScriptFiles(generatedDir);
|
||||
|
||||
await Promise.all(
|
||||
|
|
@ -40,6 +124,24 @@ 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 {
|
||||
|
|
@ -55,14 +157,25 @@ function addNormalizationNotice(source) {
|
|||
return source.replace(generatedHeader, `${generatedHeader}\n${normalizationNotice}`);
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
if (result.error) {
|
||||
console.error(`Failed to run ${command} ${args.join(" ")}: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
throw new Error(`Failed to run ${command} ${args.join(" ")}: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) process.exit(result.status ?? 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;
|
||||
}
|
||||
|
|
|
|||
13
scripts/grit/dom/no-dom-events-imports.grit
Normal file
13
scripts/grit/dom/no-dom-events-imports.grit
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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")
|
||||
}
|
||||
56
scripts/grit/dom/no-implicit-dom-bridges.grit
Normal file
56
scripts/grit/dom/no-implicit-dom-bridges.grit
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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")
|
||||
}
|
||||
13
scripts/grit/dom/no-preact-root-imports.grit
Normal file
13
scripts/grit/dom/no-preact-root-imports.grit
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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")
|
||||
}
|
||||
53
scripts/grit/no-restricted-css-policy.grit
Normal file
53
scripts/grit/no-restricted-css-policy.grit
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
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-.")
|
||||
}
|
||||
}
|
||||
5
scripts/grit/runtime/no-app-server-direct-rpcs.grit
Normal file
5
scripts/grit/runtime/no-app-server-direct-rpcs.grit
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
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")
|
||||
}
|
||||
18
scripts/grit/runtime/no-state-module-side-effects.grit
Normal file
18
scripts/grit/runtime/no-state-module-side-effects.grit
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
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")
|
||||
}
|
||||
8
scripts/grit/source-shape/no-handwritten-reexports.grit
Normal file
8
scripts/grit/source-shape/no-handwritten-reexports.grit
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
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")
|
||||
}
|
||||
5
scripts/grit/source-shape/no-misplaced-tsx.grit
Normal file
5
scripts/grit/source-shape/no-misplaced-tsx.grit
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
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")
|
||||
}
|
||||
5
scripts/grit/source-shape/no-unsafe-iterator-value.grit
Normal file
5
scripts/grit/source-shape/no-unsafe-iterator-value.grit
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
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"));
|
||||
}
|
||||
|
|
@ -1,380 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
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);
|
||||
73
scripts/release/notes.mjs
Normal file
73
scripts/release/notes.mjs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
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,4 +1,5 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function fail(message) {
|
||||
console.error(`release preflight failed: ${message}`);
|
||||
|
|
@ -81,9 +82,15 @@ 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:ci"]);
|
||||
run("npm", ["run", "check"]);
|
||||
|
||||
console.log(`release preflight passed for ${packageVersion}`);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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) {
|
||||
|
|
@ -38,6 +39,13 @@ 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[""]');
|
||||
|
|
@ -51,7 +59,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, "## Changes\n\n- \n");
|
||||
await writeFile(notesPath, releaseNotes);
|
||||
|
||||
console.log(`prepared release ${releaseVersion}`);
|
||||
console.log(`edit ${notesPath}, then run npm run release:preflight after committing`);
|
||||
console.log(`review and edit ${notesPath}, then run npm run release:preflight after committing`);
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
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: "interactive" } | { kind: "reject"; message: string };
|
||||
export type AppServerClientRequestPolicy = { kind: "reject"; message: string };
|
||||
|
||||
export interface AppServerClientAccessOptions {
|
||||
serverRequests?: AppServerClientRequestPolicy;
|
||||
|
|
|
|||
32
src/app-server/connection/client-profile.ts
Normal file
32
src/app-server/connection/client-profile.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue