mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Compare commits
883
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b65ed38d2 | ||
|
|
344f4c087d | ||
|
|
4b73ce68e3 | ||
|
|
d37599a794 | ||
|
|
3f98e36c56 | ||
|
|
042a8cfbee | ||
|
|
e726c36f67 | ||
|
|
dd25238427 | ||
|
|
432ebadd44 | ||
|
|
289260531b | ||
|
|
f97dd2adab | ||
|
|
1700e9fe20 | ||
|
|
f638604fe7 | ||
|
|
b0659279ee | ||
|
|
79c1f82448 | ||
|
|
eaf747c107 | ||
|
|
aac60b74dc | ||
|
|
598ec42390 | ||
|
|
cec5031f62 | ||
|
|
a5a0efe92b | ||
|
|
7c5d1324e7 | ||
|
|
52aaf84c3e | ||
|
|
5f3db07b76 | ||
|
|
10b34b0218 | ||
|
|
d6b8207218 | ||
|
|
956671eef5 | ||
|
|
28e1a9e22f | ||
|
|
27dcfb8bb7 | ||
|
|
038991fa97 | ||
|
|
2994bb4ed0 | ||
|
|
d84dcc75de | ||
|
|
7c3f3d3ac7 | ||
|
|
7e4e260273 | ||
|
|
4aa249daf5 | ||
|
|
b4bd1f81a0 | ||
|
|
de8b5d98ec | ||
|
|
7685947a69 | ||
|
|
b158c4fcd7 | ||
|
|
dc8f8e585a | ||
|
|
ee7dcdee7e | ||
|
|
8d1ed31b0b | ||
|
|
54e948fdc0 | ||
|
|
441fe5accd | ||
|
|
37d25d4293 | ||
|
|
079185b232 | ||
|
|
558fbe2188 | ||
|
|
022415e98e | ||
|
|
a1575cb554 | ||
|
|
97b5e61ad8 | ||
|
|
f8258f6a1d | ||
|
|
68107d119e | ||
|
|
e96f578b63 | ||
|
|
cfaae8f7e2 | ||
|
|
1a90e813e8 | ||
|
|
9d0a12c38a | ||
|
|
a1c98ac4e6 | ||
|
|
b72493f47f | ||
|
|
49223e720b | ||
|
|
c5044119ce | ||
|
|
f366e7cbe2 | ||
|
|
e336ee5cfa | ||
|
|
17c3a7cb5d | ||
|
|
53a0e273d6 | ||
|
|
83e1396bdd | ||
|
|
d5bbb43e3a | ||
|
|
24b6a632dd | ||
|
|
17c3223883 | ||
|
|
c79675e6a9 | ||
|
|
f260755f46 | ||
|
|
3927c2b7dc | ||
|
|
4e4ab23cda | ||
|
|
3f030e64dd | ||
|
|
d8becaf868 | ||
|
|
994cd36c44 | ||
|
|
7ac5373af9 | ||
|
|
32fcede65e | ||
|
|
6438c18b73 | ||
|
|
f07e9f5c70 | ||
|
|
c97e3bf7a6 | ||
|
|
674692c926 | ||
|
|
c3d07b89bd | ||
|
|
f89cbc140e | ||
|
|
3bd892ebd3 | ||
|
|
f322af2742 | ||
|
|
a9fa48f68c | ||
|
|
471c76c0e7 | ||
|
|
799619557b | ||
|
|
5f914b8a01 | ||
|
|
aa897ae9bc | ||
|
|
4881fd5fc6 | ||
|
|
49c29f160a | ||
|
|
aa8f73e632 | ||
|
|
2bb870f6e7 | ||
|
|
e44dfe1898 | ||
|
|
b5fac4a2ff | ||
|
|
66f326fcf0 | ||
|
|
3cdb452d5d | ||
|
|
f49c1cd3d4 | ||
|
|
dc983e5493 | ||
|
|
9cc9eb0889 | ||
|
|
ca4f0cdeae | ||
|
|
4790af08a4 | ||
|
|
6ffab92f0f | ||
|
|
f1ea4cc5a2 | ||
|
|
5b13e1da81 | ||
|
|
d0a2ff12f1 | ||
|
|
64d7eb99cc | ||
|
|
cad00aef08 | ||
|
|
6bd6e3f953 | ||
|
|
b9f79be106 | ||
|
|
5a43eaf5a6 | ||
|
|
63ccfa50be | ||
|
|
18e0065086 | ||
|
|
d7ab2b27b6 | ||
|
|
3afe52bd4a | ||
|
|
d89c058275 | ||
|
|
2af87a760d | ||
|
|
5c6d2f0891 | ||
|
|
31eafb394b | ||
|
|
61e353d919 | ||
|
|
1a999eaa54 | ||
|
|
9910496b90 | ||
|
|
2b9fdfe4d5 | ||
|
|
14c561af8d | ||
|
|
4fd2f66949 | ||
|
|
53e038b09b | ||
|
|
3b7ccb63e2 | ||
|
|
1f07f949b5 | ||
|
|
a60036b3d7 | ||
|
|
2eb3a2fd2d | ||
|
|
8b6adf067e | ||
|
|
b408610277 | ||
|
|
2faa6c3dac | ||
|
|
9b3c3569ec | ||
|
|
a4654d9d00 | ||
|
|
6e5f71c796 | ||
|
|
067f3d3038 | ||
|
|
6d70221fe6 | ||
|
|
427376f78b | ||
|
|
ccfd36923d | ||
|
|
89567726f8 | ||
|
|
9cd111b5f2 | ||
|
|
096aaac9f3 | ||
|
|
8835a6d75d | ||
|
|
bfb868aec3 | ||
|
|
4e8fb6e4cb | ||
|
|
7c53a6be5a | ||
|
|
64b932f128 | ||
|
|
bfad85a3af | ||
|
|
be822adec8 | ||
|
|
4c0f02ef47 | ||
|
|
ca6319f06d | ||
|
|
1d57a8eee1 | ||
|
|
0bb9632b60 | ||
|
|
cbe195d449 | ||
|
|
e800fec74d | ||
|
|
9d0725ad31 | ||
|
|
241412f29b | ||
|
|
d96d00b64a | ||
|
|
cde88a7c93 | ||
|
|
d5733e8274 | ||
|
|
3befe3d34b | ||
|
|
3ad778e7e8 | ||
|
|
9a286032f2 | ||
|
|
363a8feab2 | ||
|
|
bd8c3142a6 | ||
|
|
16c60c8b6b | ||
|
|
63ac1ba03e | ||
|
|
9e190e9e57 | ||
|
|
f287fb3800 | ||
|
|
f9b601504b | ||
|
|
9140365251 | ||
|
|
81233e5cfb | ||
|
|
e296d88969 | ||
|
|
fbce73ae55 | ||
|
|
ce79274ccc | ||
|
|
8db2e8ac41 | ||
|
|
12352b94f5 | ||
|
|
e393b6a984 | ||
|
|
3f63a6fc3c | ||
|
|
6600c891f3 | ||
|
|
33cfd2d8ad | ||
|
|
5a8714805c | ||
|
|
232e3f7ad4 | ||
|
|
10adf6658b | ||
|
|
29742006df | ||
|
|
addc1b10a0 | ||
|
|
ee0d7dc0cb | ||
|
|
7b1f3f28ed | ||
|
|
8c44ecd61b | ||
|
|
7d92ddf0c9 | ||
|
|
5c497cc6a8 | ||
|
|
b7bd7d18b2 | ||
|
|
b4b72432b9 | ||
|
|
ac1f5e2c8c | ||
|
|
98d77cc816 | ||
|
|
d5812ddd34 | ||
|
|
cf7c275fb7 | ||
|
|
93b2661513 | ||
|
|
1f84cb9c5c | ||
|
|
4269aaa163 | ||
|
|
bc62bb9565 | ||
|
|
be56bbd05b | ||
|
|
eb44fd545d | ||
|
|
c9f911b7c9 | ||
|
|
3a189d155b | ||
|
|
0211d5c367 | ||
|
|
c88f884fc9 | ||
|
|
9f7c35cd8f | ||
|
|
ac81a7c0ab | ||
|
|
8ebb0b5a90 | ||
|
|
03f81bcd64 | ||
|
|
9f6c8bb434 | ||
|
|
f56c74adf9 | ||
|
|
3c88d217f2 | ||
|
|
967a06ba39 | ||
|
|
71593f1a24 | ||
|
|
5d38a13c46 | ||
|
|
fe91162869 | ||
|
|
74328d7e42 | ||
|
|
6046337877 | ||
|
|
16b2c31ebb | ||
|
|
a58a5f615f | ||
|
|
2cac2d7e8b | ||
|
|
1695d3f518 | ||
|
|
96a2e9b481 | ||
|
|
23ba8f3204 | ||
|
|
b284bb00c0 | ||
|
|
cb0ae89143 | ||
|
|
344aac21f9 | ||
|
|
21d7f24f61 | ||
|
|
029622f07b | ||
|
|
251535d371 | ||
|
|
5c6fa7c864 | ||
|
|
be4023dd66 | ||
|
|
be5f3390f7 | ||
|
|
59ef5f5400 | ||
|
|
dbd2423e7b | ||
|
|
2ac95df75f | ||
|
|
7821b465a5 | ||
|
|
b90ed5bdf7 | ||
|
|
1d7e2288f7 | ||
|
|
61eeb8171a | ||
|
|
426acd222e | ||
|
|
cff8766aa6 | ||
|
|
5cfe7ea4df | ||
|
|
d38daef567 | ||
|
|
09782a4d84 | ||
|
|
839e36abd4 | ||
|
|
3485ce47bc | ||
|
|
273ee3b632 | ||
|
|
486ecbfda0 | ||
|
|
b722cc277e | ||
|
|
f994586eeb | ||
|
|
b7285064fd | ||
|
|
f5a94ce702 | ||
|
|
3dc7cfe10b | ||
|
|
f70fba1dd6 | ||
|
|
8e8bc5ddde | ||
|
|
d18e82c546 | ||
|
|
779ee7a008 | ||
|
|
7cd21476f7 | ||
|
|
f0359259f9 | ||
|
|
7137e6ec14 | ||
|
|
6ae4f0539a | ||
|
|
bc58aed0d7 | ||
|
|
608f107f8d | ||
|
|
a010cc983f | ||
|
|
00d0adc3c4 | ||
|
|
ddc5a50529 | ||
|
|
27734a1a02 | ||
|
|
489c1af426 | ||
|
|
365204d04c | ||
|
|
14c61db43d | ||
|
|
65e1b845a7 | ||
|
|
6a9583a2ce | ||
|
|
eddd95ba41 | ||
|
|
a376280c91 | ||
|
|
1e9a5cfa20 | ||
|
|
d204b36b55 | ||
|
|
a03f23e720 | ||
|
|
210d94af18 | ||
|
|
218ee46b7d | ||
|
|
38c28a1c8f | ||
|
|
f5458bb03e | ||
|
|
70f6e785ce | ||
|
|
0ad31cfc4d | ||
|
|
bae8da3ddc | ||
|
|
b22eba7af9 | ||
|
|
cd7510e054 | ||
|
|
054913d17d | ||
|
|
dee4b05695 | ||
|
|
b1393998a7 | ||
|
|
2ab2197c01 | ||
|
|
ace78de749 | ||
|
|
0b95fd5a65 | ||
|
|
f8f5caaa09 | ||
|
|
3361ce2b60 | ||
|
|
c0a54dcaf9 | ||
|
|
9f5fa6049f | ||
|
|
db0f6837ce | ||
|
|
bf5482db8c | ||
|
|
2e5b5cb186 | ||
|
|
8060d10fcc | ||
|
|
979b521563 | ||
|
|
a166fbbd08 | ||
|
|
856e055d30 | ||
|
|
1bfb018d90 | ||
|
|
ebb7fe07b7 | ||
|
|
d765267c43 | ||
|
|
698a98a9c8 | ||
|
|
873dfdbfbd | ||
|
|
2db24e2868 | ||
|
|
e68d1d1b5f | ||
|
|
b7efa02adc | ||
|
|
c807b8ef7d | ||
|
|
6b5f396e1e | ||
|
|
182be8676d | ||
|
|
f6f47ede62 | ||
|
|
eb117249d1 | ||
|
|
f28d9e9b06 | ||
|
|
bc6c232ef0 | ||
|
|
8e78ac76bd | ||
|
|
d8d70812b8 | ||
|
|
6e44332fd1 | ||
|
|
bc6015eff2 | ||
|
|
0dca5835f6 | ||
|
|
0a8c24bca8 | ||
|
|
7485b3d71f | ||
|
|
efdee7dcdb | ||
|
|
d7d3dcde58 | ||
|
|
9330b907fc | ||
|
|
256ff7a0b6 | ||
|
|
6ddcc15206 | ||
|
|
a56e934841 | ||
|
|
efde51b8c1 | ||
|
|
2a7c6c3c06 | ||
|
|
5bab17b668 | ||
|
|
d7d28a1b55 | ||
|
|
790bd1e2bf | ||
|
|
17231e545c | ||
|
|
17d9d19c91 | ||
|
|
3d37abf490 | ||
|
|
92f504d886 | ||
|
|
b0d098e065 | ||
|
|
a526a8ae19 | ||
|
|
1146d3820a | ||
|
|
a4dda428f5 | ||
|
|
5982586f24 | ||
|
|
e6b4aae714 | ||
|
|
ede29bdf1b | ||
|
|
b5b2bd2193 | ||
|
|
21ca382306 | ||
|
|
25e379546e | ||
|
|
0443ef55d1 | ||
|
|
465c413691 | ||
|
|
06b9105cee | ||
|
|
2b5979162a | ||
|
|
5399f14012 | ||
|
|
f2ffb06f6c | ||
|
|
6cf0e1df51 | ||
|
|
bfa20ef747 | ||
|
|
5955d22c1b | ||
|
|
ce4426858b | ||
|
|
4ec75ecda4 | ||
|
|
b51a33c4e1 | ||
|
|
c3837464a3 | ||
|
|
e7372d4c03 | ||
|
|
69eddd7846 | ||
|
|
de3e4c6a78 | ||
|
|
3abc5a0435 | ||
|
|
8e20d48b1d | ||
|
|
ff57c217aa | ||
|
|
b60e7452b7 | ||
|
|
1c03ad335c | ||
|
|
87873b726f | ||
|
|
3448a3a2ef | ||
|
|
c625fb8845 | ||
|
|
cab9698fc7 | ||
|
|
7410bd8a82 | ||
|
|
f17670b731 | ||
|
|
33a0653ea5 | ||
|
|
b1c29e9a41 | ||
|
|
ad92892063 | ||
|
|
1a827e0930 | ||
|
|
a1b37bb3c9 | ||
|
|
061d25a54c | ||
|
|
b554fa63d1 | ||
|
|
b5bcb9aeed | ||
|
|
3d3e6dcdcc | ||
|
|
9999a755dd | ||
|
|
43ce68e80a | ||
|
|
6d5f698357 | ||
|
|
64a5b2ee5c | ||
|
|
9857ed53e3 | ||
|
|
0aa9d94826 | ||
|
|
f2622f0032 | ||
|
|
c0d5f2d470 | ||
|
|
4a5141406e | ||
|
|
b036d1c0c0 | ||
|
|
0cfe6cb03d | ||
|
|
8be8cf08bf | ||
|
|
9a772131b6 | ||
|
|
1cdd5d8db0 | ||
|
|
fc9e13c922 | ||
|
|
580897730a | ||
|
|
6f7dac31d6 | ||
|
|
78e4881c37 | ||
|
|
1bb342e9e1 | ||
|
|
7ca68fb5bd | ||
|
|
72e7e0f406 | ||
|
|
94a1d87813 | ||
|
|
fee54a09c3 | ||
|
|
72a327f12f | ||
|
|
7f8d8027dc | ||
|
|
cbc12b0365 | ||
|
|
414ae266ab | ||
|
|
ecca5e1e13 | ||
|
|
1d12e10628 | ||
|
|
89cabf9fa2 | ||
|
|
7ca8059b0f | ||
|
|
29ea90f800 | ||
|
|
426ea30fe1 | ||
|
|
7d6a0da6a9 | ||
|
|
ea31f073a7 | ||
|
|
b0d08cb0b5 | ||
|
|
88e5a5badb | ||
|
|
296c735500 | ||
|
|
341c508a42 | ||
|
|
78e585c5f4 | ||
|
|
81da4c9093 | ||
|
|
064a88b9c7 | ||
|
|
14fd8c179b | ||
|
|
4a146ebada | ||
|
|
a6a02778a1 | ||
|
|
662259154a | ||
|
|
978ffee183 | ||
|
|
3ace4ab5a3 | ||
|
|
2d64cf4b30 | ||
|
|
87aee1c02c | ||
|
|
f7cf674152 | ||
|
|
9d5ca297e3 | ||
|
|
c90d1d8dda | ||
|
|
dd59519a85 | ||
|
|
33fc8997f8 | ||
|
|
8aead74ab9 | ||
|
|
1e128c4da0 | ||
|
|
eb65a28a75 | ||
|
|
9d400323d0 | ||
|
|
6d7d3093b3 | ||
|
|
2b07e9e822 | ||
|
|
7ae1fc6cd8 | ||
|
|
e1df71817a | ||
|
|
608695fc95 | ||
|
|
a08a577e18 | ||
|
|
55b2d2b130 | ||
|
|
5b84b2c04d | ||
|
|
3af0744324 | ||
|
|
07567ed31d | ||
|
|
d6e097476e | ||
|
|
540eb306f4 | ||
|
|
f62428ba0d | ||
|
|
d579169d54 | ||
|
|
8daaa649eb | ||
|
|
2d5a4eefec | ||
|
|
d550430c8a | ||
|
|
78724acfcf | ||
|
|
20c7112812 | ||
|
|
a7c996f3ea | ||
|
|
4fbcbb4849 | ||
|
|
74102ac6c2 | ||
|
|
11a6fa9b45 | ||
|
|
507a6f29a5 | ||
|
|
e725bc5de3 | ||
|
|
c15d4c50a1 | ||
|
|
e1bc4a096b | ||
|
|
b524de9298 | ||
|
|
bfb5fb922f | ||
|
|
3f4d55343a | ||
|
|
73d8d2c2c3 | ||
|
|
fcfe73c50d | ||
|
|
ffb6b0a5f6 | ||
|
|
5564015a8b | ||
|
|
62eeb10d38 | ||
|
|
2de7fdde10 | ||
|
|
5a96d1769e | ||
|
|
a443a55bec | ||
|
|
9b2d9fdd1f | ||
|
|
05ad9b2475 | ||
|
|
7962d02be5 | ||
|
|
5c35288162 | ||
|
|
aa6732da19 | ||
|
|
65ac11e952 | ||
|
|
0b21cff020 | ||
|
|
062e0a00fb | ||
|
|
53ae78c609 | ||
|
|
b046c4b569 | ||
|
|
46cb89b232 | ||
|
|
e20abb19fb | ||
|
|
bf76f47357 | ||
|
|
9c2d3d067d | ||
|
|
ae7ede3201 | ||
|
|
e5ff431b7a | ||
|
|
66a676f2f5 | ||
|
|
d33dedd038 | ||
|
|
1f2837f86a | ||
|
|
927cf68110 | ||
|
|
cd72ad0008 | ||
|
|
2d2e85e340 | ||
|
|
4da1b9a02a | ||
|
|
73758f7f77 | ||
|
|
c433c36687 | ||
|
|
3e9d7b7cf7 | ||
|
|
2a56d2afb8 | ||
|
|
bff41ad66a | ||
|
|
1d088911e1 | ||
|
|
f23eb96555 | ||
|
|
0d4e7defb6 | ||
|
|
023aec22ca | ||
|
|
bf4cee9898 | ||
|
|
147ff131f4 | ||
|
|
3e5edd03a6 | ||
|
|
0afefa4889 | ||
|
|
49a762832b | ||
|
|
3cf5cab1d2 | ||
|
|
5968554e4c | ||
|
|
8d826b2f31 | ||
|
|
32e30dd4f5 | ||
|
|
bb06905aa2 | ||
|
|
2684fa1d62 | ||
|
|
de75a69cad | ||
|
|
f2c42c314c | ||
|
|
4b50a191ba | ||
|
|
11fadebb87 | ||
|
|
943ce5b853 | ||
|
|
137093350b | ||
|
|
000ee66cfe | ||
|
|
58a8b7316e | ||
|
|
1e6d3d3357 | ||
|
|
9f681b87a0 | ||
|
|
dc21233767 | ||
|
|
27315a7090 | ||
|
|
bebe79d109 | ||
|
|
4ca0270115 | ||
|
|
374f56651c | ||
|
|
eefa6f843d | ||
|
|
2b84c587f2 | ||
|
|
706cddafb0 | ||
|
|
a6921e2bfb | ||
|
|
3cc396f42c | ||
|
|
59999b9b37 | ||
|
|
e68321e16c | ||
|
|
ad76969ce2 | ||
|
|
c170aad364 | ||
|
|
9e8bebc3bf | ||
|
|
d5cdbfd4d1 | ||
|
|
1811b36205 | ||
|
|
7840f4bccb | ||
|
|
9199dcb583 | ||
|
|
cbc4b77a4f | ||
|
|
ffb881436a | ||
|
|
9cf87efc6b | ||
|
|
b71454d63a | ||
|
|
b0feb33ec4 | ||
|
|
900d0abd27 | ||
|
|
a3d3a1e951 | ||
|
|
8fa3f33f37 | ||
|
|
360a4ff026 | ||
|
|
8c6a16f3d7 | ||
|
|
9d21581259 | ||
|
|
3837576a5f | ||
|
|
eb5a097999 | ||
|
|
db2d20ac56 | ||
|
|
f276c51772 | ||
|
|
ac2214ed77 | ||
|
|
8fe7bb5329 | ||
|
|
8028a6aa24 | ||
|
|
5b4e8865e5 | ||
|
|
356d526116 | ||
|
|
cca69b8108 | ||
|
|
75235a7853 | ||
|
|
0421783ebe | ||
|
|
a1be4ff0f9 | ||
|
|
8ab7f89e36 | ||
|
|
ddcb0f9981 | ||
|
|
6ca5ddda73 | ||
|
|
41d0933d4d | ||
|
|
5ad62c7097 | ||
|
|
76714ac3ad | ||
|
|
15b40835b8 | ||
|
|
dc5b541b21 | ||
|
|
f376ffb96b | ||
|
|
d5ce3a955a | ||
|
|
d2cb7e334e | ||
|
|
9d61ca13cb | ||
|
|
51bd17d335 | ||
|
|
1cd2764eb2 | ||
|
|
3fb81032e0 | ||
|
|
da942ea900 | ||
|
|
d534c617d2 | ||
|
|
ded3d0ab81 | ||
|
|
a4b4a6b655 | ||
|
|
be5d8140c9 | ||
|
|
7d54487354 | ||
|
|
93fae5dd67 | ||
|
|
504888b359 | ||
|
|
99e95658df | ||
|
|
5a68010022 | ||
|
|
a456e47796 | ||
|
|
27abf816f5 | ||
|
|
c6188a8345 | ||
|
|
22ff0d34b7 | ||
|
|
96d1d1e1aa | ||
|
|
6f75e90fcd | ||
|
|
f78f507745 | ||
|
|
89c148a530 | ||
|
|
e11c04ac87 | ||
|
|
9b070052a0 | ||
|
|
bb3d2cd95c | ||
|
|
d417bd8b90 | ||
|
|
f72a593eb4 | ||
|
|
f6cd06fc01 | ||
|
|
e77d143ce1 | ||
|
|
256219c4bf | ||
|
|
6af72f4c90 | ||
|
|
3a598ffe43 | ||
|
|
05797097c6 | ||
|
|
8b0f5054dd | ||
|
|
1c760f3ec4 | ||
|
|
699f383b57 | ||
|
|
6009988d3c | ||
|
|
8cda9777a5 | ||
|
|
049c432fc3 | ||
|
|
3ea89e8275 | ||
|
|
19fd7f42b1 | ||
|
|
0410be33cd | ||
|
|
002f9b0cea | ||
|
|
669f4264c7 | ||
|
|
dc6511b99e | ||
|
|
032968ea2d | ||
|
|
b2f02b0ff6 | ||
|
|
b31825d05b | ||
|
|
c4fe0223ab | ||
|
|
6ce79d8a52 | ||
|
|
b68abb7df4 | ||
|
|
1281ed31df | ||
|
|
e7377fb104 | ||
|
|
edb4dfa9ed | ||
|
|
7db13e1fc0 | ||
|
|
b9d769364d | ||
|
|
164fb99ace | ||
|
|
db26db266d | ||
|
|
fc3461dd54 | ||
|
|
8c8c9eef24 | ||
|
|
df7451c2cf | ||
|
|
9537c7657c | ||
|
|
6c105e971e | ||
|
|
cbbe6a812e | ||
|
|
0a572a9528 | ||
|
|
3ef3ebe897 | ||
|
|
0c284ed75b | ||
|
|
838d2f3c5d | ||
|
|
2a24fbcee1 | ||
|
|
9ef3499418 | ||
|
|
08d9982981 | ||
|
|
be8a76868b | ||
|
|
d1ea63587b | ||
|
|
96a9115ee7 | ||
|
|
2ac6cdeb46 | ||
|
|
659da15cc2 | ||
|
|
9b2e1279bc | ||
|
|
3af160bb02 | ||
|
|
73ccc5f483 | ||
|
|
3a824dfff0 | ||
|
|
2b05c415c1 | ||
|
|
7ffa585f0d | ||
|
|
0690387b70 | ||
|
|
ba1957ef52 | ||
|
|
abf9022fd9 | ||
|
|
7fa3febf55 | ||
|
|
11b08c9a9f | ||
|
|
c1f528e120 | ||
|
|
65bd51fe92 | ||
|
|
2dc664b323 | ||
|
|
65737ef4a5 | ||
|
|
9a2fc883b6 | ||
|
|
753613dcdc | ||
|
|
a412d0c230 | ||
|
|
33e847c1a3 | ||
|
|
ac2f5fe7d6 | ||
|
|
4c2c068c3a | ||
|
|
102a7e2650 | ||
|
|
ba40490ca4 | ||
|
|
62a3ec8319 | ||
|
|
a430e87104 | ||
|
|
acc5a440c7 | ||
|
|
b99c8821d3 | ||
|
|
dc03d9b358 | ||
|
|
3a6579f78d | ||
|
|
55b2537129 | ||
|
|
b5b238ffd3 | ||
|
|
4606373e5b | ||
|
|
4353f09e54 | ||
|
|
fc3c4bf566 | ||
|
|
c05c523182 | ||
|
|
629c012f48 | ||
|
|
e7d37f87bb | ||
|
|
664a10c724 | ||
|
|
308a042076 | ||
|
|
b554ce4a87 | ||
|
|
5f257da4f6 | ||
|
|
1196ba5959 | ||
|
|
067a70d19f | ||
|
|
c021fc1548 | ||
|
|
2e95c2cc15 | ||
|
|
692a55c84c | ||
|
|
c5ffdea370 | ||
|
|
9117b40e7b | ||
|
|
25a6cefa34 | ||
|
|
399b7d83da | ||
|
|
e2128776b3 | ||
|
|
327a0ad9b0 | ||
|
|
47326c28d3 | ||
|
|
084fa20b3d | ||
|
|
e05af09c28 | ||
|
|
cec78a383d | ||
|
|
b86909af52 | ||
|
|
faa34aeaaa | ||
|
|
5fed562db2 | ||
|
|
164765cd27 | ||
|
|
489492d9b9 | ||
|
|
bec34ee360 | ||
|
|
79298a6620 | ||
|
|
af8a876adc | ||
|
|
f4d6517bbb | ||
|
|
576119663f | ||
|
|
d6635b7818 | ||
|
|
a6fc710d8c | ||
|
|
e67d2f2f2b | ||
|
|
3799ea2b4e | ||
|
|
2bd16beccf | ||
|
|
72b0d12d7c | ||
|
|
6777adac54 | ||
|
|
083f86b19d | ||
|
|
1fafc3b1ba | ||
|
|
cdbb2c92ca | ||
|
|
ea4144ccc7 | ||
|
|
2928e34ee2 | ||
|
|
d98e800077 | ||
|
|
8b86c1914d | ||
|
|
e815277fa2 | ||
|
|
810e263cbe | ||
|
|
70a0914017 | ||
|
|
8011f2579c | ||
|
|
187665748b | ||
|
|
ee1e8c60e7 | ||
|
|
a9fd98c563 | ||
|
|
0de6e94647 | ||
|
|
dc52573ce2 | ||
|
|
509e4d739f | ||
|
|
355ec4fe62 | ||
|
|
d9c0de73b7 | ||
|
|
8d07d552d7 | ||
|
|
8324ce2ce0 | ||
|
|
ae20f59dea | ||
|
|
eaa049433a | ||
|
|
06b6122ec9 | ||
|
|
ec3b52f963 | ||
|
|
2fb5de3ba2 | ||
|
|
c9fa586db1 | ||
|
|
3301d2fe03 | ||
|
|
3adddf4b70 | ||
|
|
4b175b814e | ||
|
|
bc3da2dda2 | ||
|
|
e7c87e3439 | ||
|
|
ddde05c057 | ||
|
|
265781ea5e | ||
|
|
1865110492 | ||
|
|
43f1b89535 | ||
|
|
284d76e333 | ||
|
|
7b32135328 | ||
|
|
94ce207581 | ||
|
|
f0db134b6e | ||
|
|
0e3aaaafe8 | ||
|
|
78d76ad8a6 | ||
|
|
6b261611a5 | ||
|
|
6f7d4ec8eb | ||
|
|
a494b64055 | ||
|
|
e19d5865b0 | ||
|
|
bc81da43c2 | ||
|
|
68a487c9de | ||
|
|
d5bebcc3a5 | ||
|
|
dd79ef659c | ||
|
|
e22ab9355c | ||
|
|
f3c0b9ed8d | ||
|
|
ac1297d02c | ||
|
|
dfbb6c770e | ||
|
|
d66afd734d | ||
|
|
cfbdbca5bb | ||
|
|
49f6c74818 | ||
|
|
d7acce49a3 | ||
|
|
18a128b865 | ||
|
|
80f573e899 | ||
|
|
0ca2b6d4ea | ||
|
|
198d961646 | ||
|
|
9d522913f9 | ||
|
|
69b5209071 | ||
|
|
0160f60c6d | ||
|
|
abce4d16fc | ||
|
|
b0c1c48ad4 | ||
|
|
a2c6b8102a | ||
|
|
959a55a9f8 | ||
|
|
4452f3c0c6 | ||
|
|
88f660e43f | ||
|
|
04b0165961 | ||
|
|
7e932d27da | ||
|
|
a1e03ea389 | ||
|
|
de52ea6590 | ||
|
|
9ad5d02e31 | ||
|
|
f3234b1c3e | ||
|
|
53acd2596c | ||
|
|
cec3a799af | ||
|
|
e10bc6c7cf | ||
|
|
0082ade234 | ||
|
|
b562a0f721 | ||
|
|
77f4704b20 | ||
|
|
53e9d05587 | ||
|
|
b9f73e3e9a | ||
|
|
07e7090cc7 | ||
|
|
7ad8f9f182 | ||
|
|
408a38a7d6 | ||
|
|
7842f5a8d0 | ||
|
|
c3cd6248ae | ||
|
|
f3ef7c074b | ||
|
|
246db5a820 | ||
|
|
b042f2594d | ||
|
|
102542c1b0 | ||
|
|
ff3b4c6ff4 | ||
|
|
7514bd4811 | ||
|
|
d29b920581 | ||
|
|
936dfb193f | ||
|
|
7615e146a5 | ||
|
|
64d5e8ca24 | ||
|
|
9f6ae4ba55 | ||
|
|
d37c064df3 | ||
|
|
34486e6486 | ||
|
|
ded6e4c1a0 | ||
|
|
5bdbd80f37 | ||
|
|
4f28bac749 | ||
|
|
f37128414a | ||
|
|
7ca890ea53 | ||
|
|
85762001a4 | ||
|
|
aa125d917d | ||
|
|
88f14ffcde | ||
|
|
bc2be4dfab | ||
|
|
53626785b3 | ||
|
|
f1b61a9f1c | ||
|
|
2ac221661b | ||
|
|
25ac565f8b | ||
|
|
20d14fa4e0 | ||
|
|
5dfd1648c5 | ||
|
|
1d5ac67324 | ||
|
|
f4ff790d2c | ||
|
|
bd2de29470 | ||
|
|
d0bfef0682 | ||
|
|
121d87b1d0 | ||
|
|
d5b218283d | ||
|
|
d4a3c8bd80 | ||
|
|
3bc8c0590d | ||
|
|
79b9335192 | ||
|
|
dedf4e158e | ||
|
|
8642646fa7 | ||
|
|
98f63c7172 | ||
|
|
297470ac2e | ||
|
|
b043230680 | ||
|
|
bc38d15b6d | ||
|
|
342b61f633 | ||
|
|
390dc22c96 | ||
|
|
f919cfa4c8 | ||
|
|
d1b14d8bfb | ||
|
|
2faab3280a | ||
|
|
2a3271b8b3 | ||
|
|
f45aa85aef |
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: install-anti-slop
|
||||
description: Install, configure, update, or upgrade vendored anti-slop Oxlint plugins. Use when adding anti-slop, picking up upstream rules or fixes, or migrating an existing installation while preserving local customizations.
|
||||
---
|
||||
|
||||
# Install or update anti-slop
|
||||
|
||||
Anti-slop is vendored code: the target repository owns its rules, diagnostics, tests, and configuration. Preserve those choices when bringing in upstream changes.
|
||||
|
||||
## Choose the path
|
||||
|
||||
Read the repository's agent instructions and `git status`. Identify its package manager, Oxlint/Vite+ configuration, and any existing anti-slop entry points, including renamed or relocated copies referenced by `jsPlugins`.
|
||||
|
||||
- **Existing installation — update, upgrade, migrate, or reconfigure:** read [Update a vendored installation](references/update.md) and follow that procedure instead of the fresh-install steps below.
|
||||
- **No installation — fresh install:** follow the procedure below. If the user requested an update but no installation can be found, confirm the target before installing.
|
||||
|
||||
Complete when the operation and target path are established and pre-existing work is identified.
|
||||
|
||||
## Fresh install
|
||||
|
||||
1. Copy the bundled plugin from this skill. Run from the target repository:
|
||||
|
||||
```bash
|
||||
node <skill-directory>/scripts/install.mjs
|
||||
```
|
||||
|
||||
This creates `tools/oxlint/anti-slop/`. Pass another relative destination as the first argument when the repository has an established tooling layout. The script refuses to replace an existing destination; route existing copies through the update procedure rather than `--force`.
|
||||
|
||||
Preserve the nested `vendor/eslint-stylistic/LICENSE` and `UPSTREAM.md`; they travel with the copied rule. Readability enforcement is self-contained and requires no Stylistic plugin dependency.
|
||||
|
||||
Complete when the files, including vendored license and provenance, exist at the agreed destination without replacing an existing copy.
|
||||
|
||||
2. Install current compatible dependencies rather than trusting versions remembered by the agent:
|
||||
- If the repository already depends on `oxlint`, read its installed version from the package manager or lockfile and install `@oxlint/plugins` at exactly that version. Pin it exactly rather than by range so future upgrades move both packages together.
|
||||
- Only when the repository has no `oxlint` dependency, query `npm view oxlint version` and `npm view @oxlint/plugins version`, then install the same current version of both packages.
|
||||
- `oxlint` is a development dependency. The copied source imports `@oxlint/plugins`, so install it as a development dependency for a local-only plugin.
|
||||
- Do not replace the package manager or rewrite unrelated dependency ranges.
|
||||
|
||||
Complete when matching compatible versions are installed and unrelated dependency ranges are preserved.
|
||||
|
||||
3. Register the generic plugin, configure ignores, and enable all generic rules. For `oxlint.config.ts` or `.oxlintrc.json`, merge these fields with the existing configuration:
|
||||
|
||||
```ts
|
||||
ignorePatterns: [
|
||||
".agent/**",
|
||||
".agents/**",
|
||||
".claude/**",
|
||||
".codex/**",
|
||||
".continue/**",
|
||||
".cursor/**",
|
||||
".gemini/**",
|
||||
".opencode/**",
|
||||
".pi/**",
|
||||
".roo/**",
|
||||
".windsurf/**",
|
||||
"tools/oxlint/anti-slop/**",
|
||||
],
|
||||
jsPlugins: [
|
||||
{ name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" },
|
||||
],
|
||||
```
|
||||
|
||||
Keep every existing ignore. Adjust the final pattern when the plugin was copied elsewhere. Inspect the repository for other project-local agent tooling directories and add them rather than linting installed skills, hooks, or generated agent configuration as application source. Do not broadly ignore all dot-directories, because some repositories keep owned source or checks in them.
|
||||
|
||||
For Vite+, add these fields to `lint.ignorePatterns` and `lint.jsPlugins`. Also merge the same patterns into `fmt.ignorePatterns` so `vp check` does not reformat installed agent assets or the vendored plugin. Merge existing entries instead of replacing them.
|
||||
|
||||
Enable these rules at `"error"`, including the native Oxlint companion rule:
|
||||
|
||||
```json
|
||||
{
|
||||
"oxc/no-accumulating-spread": "error",
|
||||
"anti-slop/no-array-filter-map": "error",
|
||||
"anti-slop/no-reduce-accumulator-copy": "error",
|
||||
"anti-slop/no-chained-type-assertions": "error",
|
||||
"anti-slop/no-conditional-empty-object-spread": "error",
|
||||
"anti-slop/no-known-value-widening": "error",
|
||||
"anti-slop/no-module-mocking": "error",
|
||||
"anti-slop/no-object-parameters": "error",
|
||||
"anti-slop/no-reflect-apply": "error",
|
||||
"anti-slop/no-reflect-get": "error",
|
||||
"anti-slop/no-runtime-typeof": "error",
|
||||
"anti-slop/no-shape-in-symbol-names": "error",
|
||||
"anti-slop/no-unknown-parameters": "error",
|
||||
"anti-slop/no-unknown-returns": "error",
|
||||
"anti-slop/no-unknown-type-aliases": "error",
|
||||
"anti-slop/no-unsafe-dictionary-type": "error",
|
||||
"anti-slop/no-widen-then-assert": "error",
|
||||
"anti-slop/require-readable-spacing": "error",
|
||||
"anti-slop/require-safety-comment-for-type-assertion": "error"
|
||||
}
|
||||
```
|
||||
|
||||
For `no-array-filter-map`, prefer lazy `.values().filter(...).map(...).toArray()` pipelines only when the target runtime supports iterator helpers; otherwise use an appropriate single `flatMap` or locally mutating reducer. Review callback order, indexes, sparse arrays, `thisArg`, and filtering semantics rather than mechanically rewriting chains. Unknown receiver types are deliberately not inferred by this AST/scope rule.
|
||||
|
||||
Pair `no-reduce-accumulator-copy` with native `oxc/no-accumulating-spread`: the custom rule catches supported non-spread copies such as `Object.assign({}, acc, item)`, `Array.from(acc)`, and array accumulator `concat`/`slice` calls. Mutating a fresh local accumulator is allowed; copying individual input items is also allowed. Named callbacks, indirect helpers, and nested accumulator properties are not fully analyzed, so do not claim all quadratic reducers are ruled out.
|
||||
|
||||
If the repository declares `effect` in a package manifest, or the user explicitly requests Effect rules, also register the opt-in Effect plugin:
|
||||
|
||||
```ts
|
||||
jsPlugins: [
|
||||
{
|
||||
name: "anti-slop-effect",
|
||||
specifier: "./tools/oxlint/anti-slop/effect/index.ts",
|
||||
},
|
||||
],
|
||||
rules: {
|
||||
"anti-slop-effect/no-manual-effect-error-tag": "error",
|
||||
"anti-slop-effect/no-manual-tag-comparison": "error",
|
||||
"anti-slop-effect/no-manual-tagged-construction": "error",
|
||||
"anti-slop-effect/no-service-constructor-imports": "error",
|
||||
"anti-slop-effect/prefer-effect-match": "error",
|
||||
},
|
||||
```
|
||||
|
||||
Merge these entries with the generic plugin configuration rather than replacing it. Do not enable the Effect plugin merely because Effect appears transitively in a lockfile; require a direct package-manifest dependency or an explicit user request. The rule covers relative project imports. Report package-alias imports as a current limitation rather than pretending they are enforced.
|
||||
|
||||
Complete when the generic rules and eligible Effect rules are registered and existing configuration is preserved.
|
||||
|
||||
4. Run the repository's lint command and typecheck. For Vite+, run the repository's full `vp check` command after adding both lint and format ignores. If findings appear in owned project source, report them and fix them only when the user asked for migration/cleanup. Do not suppress rules, weaken rule severity, add unsafe casts, or mechanically launder types to make lint pass.
|
||||
|
||||
When cleanup is authorized, apply `require-readable-spacing` with lint autofix, then run the repository's formatter and lint again. Confirm a second fix/format pass leaves files unchanged. Keep whitespace fixes separate from semantic edits, preserve documentation attachment and overload groups, and do not enable an entire competing formatting preset.
|
||||
|
||||
Complete when checks have run, fix/format stability has been verified for authorized cleanup, and every failure is resolved or reported with its diagnostics.
|
||||
|
||||
5. Record provenance in `UPSTREAM.md` beside the vendored entry point: source repository, exact source commit or recoverable pristine snapshot when available, installed plugin paths, and intentional deviations. Verify that the revision identifies the actual copied assets; a package version or the current upstream HEAD alone is insufficient. If provenance cannot be established, record it as unknown rather than guessing.
|
||||
|
||||
Review the final diff and report the installed path, source identity, dependency/configuration changes, and check results. Complete when the record and report describe the files actually installed and any remaining findings.
|
||||
@@ -0,0 +1,21 @@
|
||||
import { eslintCompatPlugin } from "@oxlint/plugins";
|
||||
|
||||
import { noManualEffectErrorTagRule } from "./rules/no-manual-effect-error-tag.ts";
|
||||
import { noManualTagComparisonRule } from "./rules/no-manual-tag-comparison.ts";
|
||||
import { noManualTaggedConstructionRule } from "./rules/no-manual-tagged-construction.ts";
|
||||
import { noServiceConstructorImportsRule } from "./rules/no-service-constructor-imports.ts";
|
||||
import { preferEffectMatchRule } from "./rules/prefer-effect-match.ts";
|
||||
|
||||
/** Opt-in Oxlint rules for Effect service and Layer architecture. */
|
||||
const antiSlopEffectPlugin = eslintCompatPlugin({
|
||||
meta: { name: "anti-slop-effect" },
|
||||
rules: {
|
||||
"no-manual-effect-error-tag": noManualEffectErrorTagRule,
|
||||
"no-manual-tag-comparison": noManualTagComparisonRule,
|
||||
"no-manual-tagged-construction": noManualTaggedConstructionRule,
|
||||
"no-service-constructor-imports": noServiceConstructorImportsRule,
|
||||
"prefer-effect-match": preferEffectMatchRule,
|
||||
},
|
||||
});
|
||||
|
||||
export default antiSlopEffectPlugin;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import {
|
||||
isInsideBroadEffectHandler,
|
||||
isReasonTagMember,
|
||||
isTagMember,
|
||||
tagMemberFromComparison,
|
||||
} from "../shared/tagged-values.ts";
|
||||
|
||||
export const noManualEffectErrorTagRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Use Effect tagged error handlers instead of manually branching on `_tag` in a catch handler.",
|
||||
},
|
||||
messages: {
|
||||
tag: "Use Effect.catchTag or Effect.catchTags instead of manually discriminating a tagged error in a broad Effect catch handler.",
|
||||
reason:
|
||||
"Use Effect.catchReason or Effect.catchReasons instead of manually discriminating a tagged `reason` in a broad Effect catch handler.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
return {
|
||||
BinaryExpression(node) {
|
||||
const tagMember = tagMemberFromComparison(node);
|
||||
if (
|
||||
tagMember === undefined ||
|
||||
!isInsideBroadEffectHandler(node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
messageId: isReasonTagMember(tagMember) ? "reason" : "tag",
|
||||
});
|
||||
},
|
||||
SwitchStatement(node) {
|
||||
if (
|
||||
!isTagMember(node.discriminant) ||
|
||||
!isInsideBroadEffectHandler(node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
messageId: isReasonTagMember(node.discriminant) ? "reason" : "tag",
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import {
|
||||
isInsideBroadEffectHandler,
|
||||
isTagMember,
|
||||
tagMemberFromComparison,
|
||||
} from "../shared/tagged-values.ts";
|
||||
|
||||
export const noManualTagComparisonRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Use Effect Match or Predicate helpers instead of manually branching on `_tag`.",
|
||||
},
|
||||
messages: {
|
||||
manualComparison:
|
||||
"Use Match.tag/Match.tags for tagged-value branching, or Predicate.isTagged for a simple reusable predicate.",
|
||||
manualSwitch:
|
||||
"Use Match.value(value).pipe(Match.tag/Match.tags/Match.tagsExhaustive) or the tagged enum `$match` helper instead of switching on `_tag`.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
return {
|
||||
BinaryExpression(node) {
|
||||
if (
|
||||
tagMemberFromComparison(node) === undefined ||
|
||||
isInsideBroadEffectHandler(node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
context.report({ node, messageId: "manualComparison" });
|
||||
},
|
||||
SwitchStatement(node) {
|
||||
if (
|
||||
!isTagMember(node.discriminant) ||
|
||||
isInsideBroadEffectHandler(node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
context.report({ node, messageId: "manualSwitch" });
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import {
|
||||
isMatchPatternObject,
|
||||
isStringLiteral,
|
||||
propertyName,
|
||||
} from "../shared/tagged-values.ts";
|
||||
|
||||
export const noManualTaggedConstructionRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Construct tagged values with their existing Effect constructor instead of writing `_tag` manually.",
|
||||
},
|
||||
messages: {
|
||||
manualConstruction:
|
||||
"Use the existing Schema tagged `.make`, tagged class/error constructor, or Data.taggedEnum variant constructor instead of writing a literal `_tag` object.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
return {
|
||||
ObjectExpression(node) {
|
||||
if (isMatchPatternObject(node)) return;
|
||||
const tag = node.properties.find(
|
||||
(property) =>
|
||||
property.type === "Property" &&
|
||||
propertyName(property) === "_tag" &&
|
||||
isStringLiteral(property.value),
|
||||
);
|
||||
if (tag !== undefined) {
|
||||
context.report({ node: tag, messageId: "manualConstruction" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
const SERVICE_CONSTRUCTOR_NAME = /^make[A-Z]/u;
|
||||
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
|
||||
|
||||
function isProjectLocalImport(source: string): boolean {
|
||||
return source.startsWith("./") || source.startsWith("../");
|
||||
}
|
||||
|
||||
function getImportedName(specifier: ESTree.ImportSpecifier): string {
|
||||
if (specifier.imported.type === "Identifier") return specifier.imported.name;
|
||||
return specifier.imported.value;
|
||||
}
|
||||
|
||||
/** Keep dependency-bearing Effect service constructors local to their owning capability modules. */
|
||||
export const noServiceConstructorImportsRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow project-local make<CapabilityName> imports outside test and spec files.",
|
||||
},
|
||||
messages: {
|
||||
serviceConstructorImport:
|
||||
'Do not import Effect service constructor "{{name}}" into runtime code. Import the owning Layer, yield the contextual service, and allow its requirements to propagate to the composition root.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const isTestFile = TEST_FILE.test(context.filename.replaceAll("\\", "/"));
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (isTestFile || !isProjectLocalImport(node.source.value)) return;
|
||||
|
||||
for (const specifier of node.specifiers) {
|
||||
if (specifier.type !== "ImportSpecifier") continue;
|
||||
|
||||
const importedName = getImportedName(specifier);
|
||||
if (!SERVICE_CONSTRUCTOR_NAME.test(importedName)) continue;
|
||||
|
||||
context.report({
|
||||
node: specifier,
|
||||
messageId: "serviceConstructorImport",
|
||||
data: { name: importedName },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defineRule, type ESTree } from "@oxlint/plugins";
|
||||
|
||||
const equalityOperators = new Set(["==", "===", "!=", "!=="]);
|
||||
|
||||
export const preferEffectMatchRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Use Match from Effect for chained literal ternaries over the same value.",
|
||||
},
|
||||
messages: {
|
||||
preferMatch:
|
||||
"Use Match from Effect instead of a chained literal ternary.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
const isLiteral = (node: ESTree.Node): boolean =>
|
||||
node.type === "Literal" ||
|
||||
(node.type === "TemplateLiteral" && node.expressions.length === 0);
|
||||
|
||||
const comparedValue = (node: ESTree.Expression): string | undefined => {
|
||||
if (
|
||||
node.type !== "BinaryExpression" ||
|
||||
!equalityOperators.has(node.operator)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (isLiteral(node.left)) return context.sourceCode.getText(node.right);
|
||||
if (isLiteral(node.right)) return context.sourceCode.getText(node.left);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
ConditionalExpression(node) {
|
||||
if (node.parent?.type === "ConditionalExpression") return;
|
||||
const value = comparedValue(node.test);
|
||||
if (value === undefined) return;
|
||||
|
||||
let alternate = node.alternate;
|
||||
let literalChecks = 1;
|
||||
while (alternate.type === "ConditionalExpression") {
|
||||
if (comparedValue(alternate.test) !== value) return;
|
||||
literalChecks += 1;
|
||||
alternate = alternate.alternate;
|
||||
}
|
||||
|
||||
if (literalChecks > 1) {
|
||||
context.report({ node, messageId: "preferMatch" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
const equalityOperators = new Set(["==", "===", "!=", "!=="]);
|
||||
const broadEffectCatchMethods = new Set(["catch", "catchAll", "catchIf"]);
|
||||
|
||||
export const isStringLiteral = (
|
||||
node: ESTree.Node | null | undefined,
|
||||
): node is ESTree.StringLiteral =>
|
||||
node?.type === "Literal" && typeof node.value === "string";
|
||||
|
||||
export const isTagMember = (
|
||||
node: ESTree.Node | null | undefined,
|
||||
): node is ESTree.MemberExpression =>
|
||||
node?.type === "MemberExpression" &&
|
||||
((!node.computed &&
|
||||
node.property.type === "Identifier" &&
|
||||
node.property.name === "_tag") ||
|
||||
(node.computed &&
|
||||
isStringLiteral(node.property) &&
|
||||
node.property.value === "_tag"));
|
||||
|
||||
export const tagMemberFromComparison = (
|
||||
node: ESTree.BinaryExpression,
|
||||
): ESTree.MemberExpression | undefined => {
|
||||
if (!equalityOperators.has(node.operator)) return undefined;
|
||||
if (isTagMember(node.left) && isStringLiteral(node.right)) return node.left;
|
||||
if (isTagMember(node.right) && isStringLiteral(node.left)) return node.right;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isBroadEffectCatchCall = (
|
||||
node: ESTree.Node | null | undefined,
|
||||
): node is ESTree.CallExpression =>
|
||||
node?.type === "CallExpression" &&
|
||||
node.callee.type === "MemberExpression" &&
|
||||
node.callee.object.type === "Identifier" &&
|
||||
node.callee.object.name === "Effect" &&
|
||||
!node.callee.computed &&
|
||||
node.callee.property.type === "Identifier" &&
|
||||
broadEffectCatchMethods.has(node.callee.property.name);
|
||||
|
||||
export const isInsideBroadEffectHandler = (node: ESTree.Node): boolean => {
|
||||
let current: ESTree.Node | null | undefined = node.parent;
|
||||
while (current !== null && current !== undefined) {
|
||||
if (
|
||||
current.type === "ArrowFunctionExpression" ||
|
||||
current.type === "FunctionExpression"
|
||||
) {
|
||||
return (
|
||||
isBroadEffectCatchCall(current.parent) &&
|
||||
current.parent.arguments.includes(current)
|
||||
);
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isReasonTagMember = (node: ESTree.MemberExpression): boolean =>
|
||||
node.object.type === "MemberExpression" &&
|
||||
((!node.object.computed &&
|
||||
node.object.property.type === "Identifier" &&
|
||||
node.object.property.name === "reason") ||
|
||||
(node.object.computed &&
|
||||
isStringLiteral(node.object.property) &&
|
||||
node.object.property.value === "reason"));
|
||||
|
||||
export const propertyName = (
|
||||
property: ESTree.ObjectProperty,
|
||||
): string | undefined => {
|
||||
if (!property.computed && property.key.type === "Identifier") {
|
||||
return property.key.name;
|
||||
}
|
||||
if (
|
||||
property.key.type === "Literal" &&
|
||||
typeof property.key.value === "string"
|
||||
) {
|
||||
return property.key.value;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const isMatchPatternObject = (node: ESTree.ObjectExpression): boolean => {
|
||||
const call = node.parent;
|
||||
if (call?.type !== "CallExpression" || !call.arguments.includes(node)) {
|
||||
return false;
|
||||
}
|
||||
const callee = call.callee;
|
||||
return (
|
||||
callee.type === "MemberExpression" &&
|
||||
callee.object.type === "Identifier" &&
|
||||
callee.object.name === "Match" &&
|
||||
!callee.computed &&
|
||||
callee.property.type === "Identifier" &&
|
||||
(callee.property.name === "when" || callee.property.name === "not")
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { eslintCompatPlugin } from "@oxlint/plugins";
|
||||
|
||||
import { noArrayFilterMapRule } from "./rules/no-array-filter-map.ts";
|
||||
import { noReduceAccumulatorCopyRule } from "./rules/no-reduce-accumulator-copy.ts";
|
||||
import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts";
|
||||
import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts";
|
||||
import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts";
|
||||
import { noModuleMockingRule } from "./rules/no-module-mocking.ts";
|
||||
import { noObjectParametersRule } from "./rules/no-object-parameters.ts";
|
||||
import { noReflectApplyRule } from "./rules/no-reflect-apply.ts";
|
||||
import { noReflectGetRule } from "./rules/no-reflect-get.ts";
|
||||
import { noRuntimeTypeofRule } from "./rules/no-runtime-typeof.ts";
|
||||
import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.ts";
|
||||
import { noUnknownParametersRule } from "./rules/no-unknown-parameters.ts";
|
||||
import { noUnknownReturnsRule } from "./rules/no-unknown-returns.ts";
|
||||
import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts";
|
||||
import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts";
|
||||
import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts";
|
||||
import { requireReadableSpacingRule } from "./rules/require-readable-spacing.ts";
|
||||
import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.ts";
|
||||
|
||||
/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */
|
||||
const antiSlopPlugin = eslintCompatPlugin({
|
||||
meta: { name: "anti-slop" },
|
||||
rules: {
|
||||
"no-array-filter-map": noArrayFilterMapRule,
|
||||
"no-reduce-accumulator-copy": noReduceAccumulatorCopyRule,
|
||||
"no-chained-type-assertions": noChainedTypeAssertionsRule,
|
||||
"no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule,
|
||||
"no-known-value-widening": noKnownValueWideningRule,
|
||||
"no-module-mocking": noModuleMockingRule,
|
||||
"no-object-parameters": noObjectParametersRule,
|
||||
"no-reflect-apply": noReflectApplyRule,
|
||||
"no-reflect-get": noReflectGetRule,
|
||||
"no-runtime-typeof": noRuntimeTypeofRule,
|
||||
"no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule,
|
||||
"no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule,
|
||||
"no-unknown-parameters": noUnknownParametersRule,
|
||||
"no-unknown-returns": noUnknownReturnsRule,
|
||||
"no-unknown-type-aliases": noUnknownTypeAliasesRule,
|
||||
"no-widen-then-assert": noWidenThenAssertRule,
|
||||
"require-readable-spacing": requireReadableSpacingRule,
|
||||
"require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule,
|
||||
},
|
||||
});
|
||||
|
||||
export default antiSlopPlugin;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import { arrayMethodTarget, isKnownArrayExpression, unwrapArrayExpression } from "../shared/array-method.ts";
|
||||
|
||||
/** Reject eager array filter/map pipelines; lazy iterator helpers remain allowed. */
|
||||
export const noArrayFilterMapRule = defineRule({
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: { description: "Disallow adjacent array filter/map passes in favor of lazy iterator helpers or a single transformation." },
|
||||
messages: {
|
||||
arrayFilterMap: "Avoid consecutive array `{{first}}` and `{{second}}` passes. Prefer `.values().{{first}}(...).{{second}}(...).toArray()` where iterator helpers are supported, or a single `flatMap`/mutating reducer. Preserve callback ordering, indexes, and filtering semantics.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
const outer = arrayMethodTarget(node.callee);
|
||||
if (outer === null || (outer.name !== "map" && outer.name !== "filter")) return;
|
||||
const innerCall = unwrapArrayExpression(outer.object);
|
||||
if (innerCall.type !== "CallExpression") return;
|
||||
const inner = arrayMethodTarget(innerCall.callee);
|
||||
if (inner === null || inner.name !== (outer.name === "map" ? "filter" : "map")) return;
|
||||
if (!isKnownArrayExpression(context.sourceCode, inner.object)) return;
|
||||
context.report({ node, messageId: "arrayFilterMap", data: { first: inner.name, second: outer.name } });
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion;
|
||||
|
||||
function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression {
|
||||
return node.type === "TSAsExpression" || node.type === "TSTypeAssertion";
|
||||
}
|
||||
|
||||
function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression {
|
||||
let current = expression;
|
||||
while (current.type === "ParenthesizedExpression") {
|
||||
current = current.expression;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function isConstAssertion(node: TypeAssertionExpression): boolean {
|
||||
const { typeAnnotation } = node;
|
||||
return (
|
||||
typeAnnotation.type === "TSTypeReference" &&
|
||||
typeAnnotation.typeName.type === "Identifier" &&
|
||||
typeAnnotation.typeName.name === "const"
|
||||
);
|
||||
}
|
||||
|
||||
function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean {
|
||||
let current: ESTree.Expression = node;
|
||||
let parent = node.parent;
|
||||
|
||||
while (parent.type === "ParenthesizedExpression" && parent.expression === current) {
|
||||
current = parent;
|
||||
parent = parent.parent;
|
||||
}
|
||||
|
||||
return !isTypeAssertionExpression(parent) || parent.expression !== current;
|
||||
}
|
||||
|
||||
function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean {
|
||||
let assertionCount = 0;
|
||||
let hasNonConstAssertion = false;
|
||||
let current: ESTree.Expression = node;
|
||||
|
||||
while (isTypeAssertionExpression(current)) {
|
||||
assertionCount += 1;
|
||||
hasNonConstAssertion ||= !isConstAssertion(current);
|
||||
current = unwrapParenthesizedExpression(current.expression);
|
||||
}
|
||||
|
||||
return assertionCount > 1 && hasNonConstAssertion;
|
||||
}
|
||||
|
||||
/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */
|
||||
export const noChainedTypeAssertionsRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.",
|
||||
},
|
||||
messages: {
|
||||
chained:
|
||||
"This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
const checkTypeAssertion = (node: TypeAssertionExpression) => {
|
||||
if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return;
|
||||
context.report({ node, messageId: "chained" });
|
||||
};
|
||||
|
||||
return {
|
||||
TSAsExpression: checkTypeAssertion,
|
||||
TSTypeAssertion: checkTypeAssertion,
|
||||
};
|
||||
},
|
||||
});
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
function unwrapParentheses(node: ESTree.Expression): ESTree.Expression {
|
||||
let current = node;
|
||||
while (current.type === "ParenthesizedExpression") {
|
||||
current = current.expression;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function isEmptyObjectExpression(node: ESTree.Expression): boolean {
|
||||
return node.type === "ObjectExpression" && node.properties.length === 0;
|
||||
}
|
||||
|
||||
function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean {
|
||||
const conditional = unwrapParentheses(node);
|
||||
return (
|
||||
conditional.type === "ConditionalExpression" &&
|
||||
(isEmptyObjectExpression(conditional.consequent) ||
|
||||
isEmptyObjectExpression(conditional.alternate))
|
||||
);
|
||||
}
|
||||
|
||||
/** Ban conditional empty-object spreads without changing their omission semantics. */
|
||||
export const noConditionalEmptyObjectSpreadRule = defineRule({
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow object spreads that conditionally spread an empty object to omit fields.",
|
||||
},
|
||||
messages: {
|
||||
avoid:
|
||||
"This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
return {
|
||||
SpreadElement(node) {
|
||||
if (node.parent.type !== "ObjectExpression") return;
|
||||
|
||||
if (isConditionalEmptyObjectSpread(node.argument)) {
|
||||
context.report({ node, messageId: "avoid" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import {
|
||||
classifyUnsafeDictionaryValue,
|
||||
classifyWideningTarget,
|
||||
createTypeEnvironment,
|
||||
isKnownEvidenceExpression,
|
||||
type TypeEnvironment,
|
||||
type WideningTarget,
|
||||
} from "../shared/dictionary-types.ts";
|
||||
import {
|
||||
containsUnknownType,
|
||||
functionParameterBindingName,
|
||||
functionParameterTypeAnnotation,
|
||||
} from "../shared/function-parameters.ts";
|
||||
import { resolveVariable } from "../shared/scope.ts";
|
||||
|
||||
import type { ESTree, SourceCode, Variable } from "@oxlint/plugins";
|
||||
|
||||
type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function;
|
||||
|
||||
function unwrapExpression(expression: ESTree.Expression): ESTree.Expression {
|
||||
let current = expression;
|
||||
while (
|
||||
current.type === "ParenthesizedExpression" ||
|
||||
current.type === "TSAsExpression" ||
|
||||
current.type === "TSSatisfiesExpression" ||
|
||||
current.type === "TSTypeAssertion" ||
|
||||
current.type === "TSNonNullExpression"
|
||||
) {
|
||||
current = current.expression;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null {
|
||||
if (variable.defs.length !== 1) return null;
|
||||
const [definition] = variable.defs;
|
||||
return definition?.type === "Variable" && definition.node.type === "VariableDeclarator"
|
||||
? definition.node
|
||||
: null;
|
||||
}
|
||||
|
||||
function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean {
|
||||
return (
|
||||
declarator.parent.type === "VariableDeclaration" &&
|
||||
declarator.parent.kind === "const" &&
|
||||
variable.references.every((reference) => reference.init || !reference.isWrite())
|
||||
);
|
||||
}
|
||||
|
||||
function hasKnownEvidence(
|
||||
sourceCode: SourceCode,
|
||||
expression: ESTree.Expression,
|
||||
visitedVariables = new Set<Variable>(),
|
||||
): boolean {
|
||||
if (isKnownEvidenceExpression(expression)) return true;
|
||||
const unwrapped = unwrapExpression(expression);
|
||||
if (unwrapped.type !== "Identifier") return false;
|
||||
const variable = resolveVariable(sourceCode, unwrapped);
|
||||
if (variable === null || visitedVariables.has(variable)) return false;
|
||||
const declarator = variableDeclarator(variable);
|
||||
if (
|
||||
declarator === null ||
|
||||
declarator.init === null ||
|
||||
!isStableConstVariable(variable, declarator)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
visitedVariables.add(variable);
|
||||
return hasKnownEvidence(sourceCode, declarator.init, visitedVariables);
|
||||
}
|
||||
|
||||
function isFunctionExpression(node: ESTree.Node): node is FunctionExpression {
|
||||
return (
|
||||
node.type === "ArrowFunctionExpression" ||
|
||||
node.type === "FunctionDeclaration" ||
|
||||
node.type === "FunctionExpression" ||
|
||||
node.type === "TSDeclareFunction" ||
|
||||
node.type === "TSEmptyBodyFunctionExpression"
|
||||
);
|
||||
}
|
||||
|
||||
function localFunctionForCall(
|
||||
sourceCode: SourceCode,
|
||||
callee: ESTree.Expression,
|
||||
): FunctionExpression | null {
|
||||
const unwrapped = unwrapExpression(callee);
|
||||
if (isFunctionExpression(unwrapped)) return unwrapped;
|
||||
if (unwrapped.type !== "Identifier") return null;
|
||||
const variable = resolveVariable(sourceCode, unwrapped);
|
||||
if (variable === null || variable.defs.length !== 1) return null;
|
||||
const [definition] = variable.defs;
|
||||
if (definition === undefined) return null;
|
||||
if (definition.type === "FunctionName" && isFunctionExpression(definition.node)) {
|
||||
return definition.node;
|
||||
}
|
||||
if (definition.type !== "Variable" || definition.node.type !== "VariableDeclarator") {
|
||||
return null;
|
||||
}
|
||||
const initializer = definition.node.init;
|
||||
if (initializer === null) return null;
|
||||
const unwrappedInitializer = unwrapExpression(initializer);
|
||||
return isFunctionExpression(unwrappedInitializer) ? unwrappedInitializer : null;
|
||||
}
|
||||
|
||||
function variableTypeAnnotation(
|
||||
sourceCode: SourceCode,
|
||||
variable: Variable,
|
||||
): ESTree.TSTypeAnnotation | null {
|
||||
if (variable.defs.length !== 1) return null;
|
||||
const [definition] = variable.defs;
|
||||
if (definition === undefined) return null;
|
||||
if (
|
||||
definition.type === "Variable" &&
|
||||
definition.node.type === "VariableDeclarator" &&
|
||||
definition.node.id.type === "Identifier"
|
||||
) {
|
||||
return definition.node.id.typeAnnotation ?? null;
|
||||
}
|
||||
if (definition.type !== "Parameter" || !isFunctionExpression(definition.node)) {
|
||||
return null;
|
||||
}
|
||||
const parameter = definition.node.params.find(
|
||||
(candidate) =>
|
||||
functionParameterBindingName(candidate, sourceCode) === variable.name,
|
||||
);
|
||||
return parameter === undefined ? null : (functionParameterTypeAnnotation(parameter) ?? null);
|
||||
}
|
||||
|
||||
function hasInformativeType(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
): boolean {
|
||||
return classifyUnsafeDictionaryValue(type, environment) === null;
|
||||
}
|
||||
|
||||
function hasKnownCallArgumentEvidence(
|
||||
sourceCode: SourceCode,
|
||||
expression: ESTree.Expression,
|
||||
environment: TypeEnvironment,
|
||||
visitedVariables = new Set<Variable>(),
|
||||
): boolean {
|
||||
if (expression.type === "ParenthesizedExpression" || expression.type === "TSNonNullExpression") {
|
||||
return hasKnownCallArgumentEvidence(
|
||||
sourceCode,
|
||||
expression.expression,
|
||||
environment,
|
||||
visitedVariables,
|
||||
);
|
||||
}
|
||||
if (expression.type === "TSAsExpression" || expression.type === "TSTypeAssertion") {
|
||||
return hasInformativeType(expression.typeAnnotation, environment);
|
||||
}
|
||||
if (expression.type === "TSSatisfiesExpression") {
|
||||
return hasKnownCallArgumentEvidence(
|
||||
sourceCode,
|
||||
expression.expression,
|
||||
environment,
|
||||
visitedVariables,
|
||||
);
|
||||
}
|
||||
if (expression.type === "CallExpression") {
|
||||
const owner = localFunctionForCall(sourceCode, expression.callee);
|
||||
const returnType = owner?.returnType?.typeAnnotation;
|
||||
return returnType !== undefined && hasInformativeType(returnType, environment);
|
||||
}
|
||||
if (expression.type !== "Identifier") return isKnownEvidenceExpression(expression);
|
||||
const variable = resolveVariable(sourceCode, expression);
|
||||
if (variable === null || visitedVariables.has(variable)) return false;
|
||||
const annotation = variableTypeAnnotation(sourceCode, variable);
|
||||
if (annotation !== null) {
|
||||
return hasInformativeType(annotation.typeAnnotation, environment);
|
||||
}
|
||||
const declarator = variableDeclarator(variable);
|
||||
if (
|
||||
declarator === null ||
|
||||
declarator.init === null ||
|
||||
!isStableConstVariable(variable, declarator)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
visitedVariables.add(variable);
|
||||
return hasKnownCallArgumentEvidence(
|
||||
sourceCode,
|
||||
declarator.init,
|
||||
environment,
|
||||
visitedVariables,
|
||||
);
|
||||
}
|
||||
|
||||
function typePredicateSubjectIndex(
|
||||
sourceCode: SourceCode,
|
||||
owner: FunctionExpression,
|
||||
): number | null {
|
||||
const predicate = owner.returnType?.typeAnnotation;
|
||||
if (predicate?.type !== "TSTypePredicate" || predicate.parameterName.type !== "Identifier") {
|
||||
return null;
|
||||
}
|
||||
const predicateParameterName = predicate.parameterName.name;
|
||||
const index = owner.params.findIndex(
|
||||
(parameter) =>
|
||||
functionParameterBindingName(parameter, sourceCode) === predicateParameterName,
|
||||
);
|
||||
return index === -1 ? null : index;
|
||||
}
|
||||
|
||||
function annotationTarget(
|
||||
annotation: ESTree.TSTypeAnnotation | null | undefined,
|
||||
environment: TypeEnvironment,
|
||||
): WideningTarget | null {
|
||||
return annotation === null || annotation === undefined
|
||||
? null
|
||||
: classifyWideningTarget(annotation.typeAnnotation, environment);
|
||||
}
|
||||
|
||||
function enclosingFunction(node: ESTree.Node): FunctionExpression | null {
|
||||
let current: ESTree.Node | null = node.parent;
|
||||
while (current !== null && current.type !== "Program") {
|
||||
if (
|
||||
current.type === "ArrowFunctionExpression" ||
|
||||
current.type === "FunctionDeclaration" ||
|
||||
current.type === "FunctionExpression"
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string {
|
||||
if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name;
|
||||
if (key.type === "Literal") return String(key.value);
|
||||
return sourceCode.getText(key);
|
||||
}
|
||||
|
||||
function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string {
|
||||
if (owner === null) return "anonymous function";
|
||||
if (owner.id !== null) return owner.id.name;
|
||||
const parent = owner.parent;
|
||||
if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier")
|
||||
return parent.id.name;
|
||||
if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key);
|
||||
return "anonymous function";
|
||||
}
|
||||
|
||||
function isEmptyObjectExpression(expression: ESTree.Expression): boolean {
|
||||
const unwrapped = unwrapExpression(expression);
|
||||
return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0;
|
||||
}
|
||||
|
||||
function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean {
|
||||
return destination.kind === "open dictionary" || destination.kind === "generic container";
|
||||
}
|
||||
|
||||
function hasParentAssertion(node: ESTree.Node): boolean {
|
||||
return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion";
|
||||
}
|
||||
|
||||
/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */
|
||||
export const noKnownValueWideningRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.",
|
||||
},
|
||||
messages: {
|
||||
widening:
|
||||
"The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
let environment: TypeEnvironment | null = null;
|
||||
|
||||
const reportFlow = (
|
||||
expression: ESTree.Expression,
|
||||
destination: WideningTarget | null,
|
||||
subject: string,
|
||||
) => {
|
||||
if (destination === null) return;
|
||||
if (
|
||||
isDictionaryAccumulatorTarget(destination) &&
|
||||
isEmptyObjectExpression(expression)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!hasKnownEvidence(context.sourceCode, expression)) return;
|
||||
context.report({
|
||||
node: expression,
|
||||
messageId: "widening",
|
||||
data: { subject, target: destination.kind },
|
||||
});
|
||||
};
|
||||
|
||||
const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) =>
|
||||
environment === null ? null : annotationTarget(annotation, environment);
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
environment = createTypeEnvironment(
|
||||
node,
|
||||
context.sourceCode.visitorKeys,
|
||||
);
|
||||
},
|
||||
VariableDeclarator(node) {
|
||||
if (node.init === null || node.id.type !== "Identifier") return;
|
||||
reportFlow(
|
||||
node.init,
|
||||
targetFromAnnotation(node.id.typeAnnotation),
|
||||
`binding \`${node.id.name}\``,
|
||||
);
|
||||
},
|
||||
PropertyDefinition(node) {
|
||||
if (node.value === null) return;
|
||||
reportFlow(
|
||||
node.value,
|
||||
targetFromAnnotation(node.typeAnnotation),
|
||||
`property \`${sourceKeyName(context.sourceCode, node.key)}\``,
|
||||
);
|
||||
},
|
||||
AccessorProperty(node) {
|
||||
if (node.value === null) return;
|
||||
reportFlow(
|
||||
node.value,
|
||||
targetFromAnnotation(node.typeAnnotation),
|
||||
`property \`${sourceKeyName(context.sourceCode, node.key)}\``,
|
||||
);
|
||||
},
|
||||
AssignmentExpression(node) {
|
||||
if (node.operator !== "=" || node.left.type !== "Identifier") return;
|
||||
const variable = resolveVariable(context.sourceCode, node.left);
|
||||
if (variable === null) return;
|
||||
const declarator = variableDeclarator(variable);
|
||||
if (declarator === null || declarator.id.type !== "Identifier") return;
|
||||
reportFlow(
|
||||
node.right,
|
||||
targetFromAnnotation(declarator.id.typeAnnotation),
|
||||
`binding \`${declarator.id.name}\``,
|
||||
);
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (environment === null) return;
|
||||
const owner = localFunctionForCall(context.sourceCode, node.callee);
|
||||
if (owner === null) return;
|
||||
const parameterIndex = typePredicateSubjectIndex(context.sourceCode, owner);
|
||||
if (parameterIndex === null) return;
|
||||
const parameter = owner.params[parameterIndex];
|
||||
const argument = node.arguments[parameterIndex];
|
||||
if (parameter === undefined || argument === undefined || argument.type === "SpreadElement") {
|
||||
return;
|
||||
}
|
||||
const parameterAnnotation = functionParameterTypeAnnotation(parameter);
|
||||
if (
|
||||
parameterAnnotation === null ||
|
||||
parameterAnnotation === undefined ||
|
||||
!containsUnknownType(parameterAnnotation.typeAnnotation)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!hasKnownCallArgumentEvidence(
|
||||
context.sourceCode,
|
||||
argument,
|
||||
environment,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
context.report({
|
||||
node: argument,
|
||||
messageId: "widening",
|
||||
data: {
|
||||
subject: `argument for parameter \`${functionParameterBindingName(parameter, context.sourceCode)}\` of \`${functionName(context.sourceCode, owner)}\``,
|
||||
target: "unknown",
|
||||
},
|
||||
});
|
||||
},
|
||||
ReturnStatement(node) {
|
||||
if (node.argument === null) return;
|
||||
const owner = enclosingFunction(node);
|
||||
reportFlow(
|
||||
node.argument,
|
||||
targetFromAnnotation(owner?.returnType),
|
||||
`return value of \`${functionName(context.sourceCode, owner)}\``,
|
||||
);
|
||||
},
|
||||
ArrowFunctionExpression(node) {
|
||||
if (node.body.type === "BlockStatement") return;
|
||||
reportFlow(
|
||||
node.body,
|
||||
targetFromAnnotation(node.returnType),
|
||||
`return value of \`${functionName(context.sourceCode, node)}\``,
|
||||
);
|
||||
},
|
||||
TSAsExpression(node) {
|
||||
if (environment === null || hasParentAssertion(node)) return;
|
||||
reportFlow(
|
||||
node.expression,
|
||||
classifyWideningTarget(node.typeAnnotation, environment),
|
||||
"assertion",
|
||||
);
|
||||
},
|
||||
TSTypeAssertion(node) {
|
||||
if (environment === null || hasParentAssertion(node)) return;
|
||||
reportFlow(
|
||||
node.expression,
|
||||
classifyWideningTarget(node.typeAnnotation, environment),
|
||||
"assertion",
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import { resolveVariable } from "../shared/scope.ts";
|
||||
|
||||
import type { ESTree, SourceCode } from "@oxlint/plugins";
|
||||
|
||||
const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]);
|
||||
|
||||
function importedName(node: ESTree.Node): string | null {
|
||||
if (node.type !== "ImportSpecifier") return null;
|
||||
return node.imported.type === "Identifier" ? node.imported.name : node.imported.value;
|
||||
}
|
||||
|
||||
function isTestFrameworkObject(
|
||||
sourceCode: SourceCode,
|
||||
expression: ESTree.Expression,
|
||||
): expression is ESTree.IdentifierReference {
|
||||
if (expression.type !== "Identifier") return false;
|
||||
if (
|
||||
(expression.name === "vi" || expression.name === "jest") &&
|
||||
sourceCode.isGlobalReference(expression)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const variable = resolveVariable(sourceCode, expression);
|
||||
if (variable === null || variable.defs.length === 0) {
|
||||
return expression.name === "vi" || expression.name === "jest";
|
||||
}
|
||||
return variable.defs.some((definition) => {
|
||||
if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") {
|
||||
return false;
|
||||
}
|
||||
const source = definition.parent.source.value;
|
||||
const name = importedName(definition.node);
|
||||
return (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest");
|
||||
});
|
||||
}
|
||||
|
||||
function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean {
|
||||
if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false;
|
||||
if (!isTestFrameworkObject(sourceCode, callee.object)) return false;
|
||||
const property = callee.property;
|
||||
const method = callee.computed
|
||||
? property.type === "Literal" &&
|
||||
(property.value === "doMock" ||
|
||||
property.value === "mock" ||
|
||||
property.value === "unstable_mockModule")
|
||||
? property.value
|
||||
: null
|
||||
: property.type === "Identifier"
|
||||
? property.name
|
||||
: null;
|
||||
return method !== null && moduleMockMethods.has(method);
|
||||
}
|
||||
|
||||
/** Ban test framework module mocking in favor of real dependency seams. */
|
||||
export const noModuleMockingRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.",
|
||||
},
|
||||
messages: {
|
||||
moduleMock:
|
||||
"Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return;
|
||||
if (moduleMockCall(context.sourceCode, node.callee)) {
|
||||
context.report({ node, messageId: "moduleMock" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
import {
|
||||
functionParameterBindingName,
|
||||
functionParameterTypeAnnotation,
|
||||
} from "../shared/function-parameters.ts";
|
||||
import {
|
||||
createTypeAliasEnvironment,
|
||||
resolvedTypeMatches,
|
||||
type TypeAliasEnvironment,
|
||||
} from "../shared/type-alias-resolution.ts";
|
||||
type ParameterOwner =
|
||||
| ESTree.ArrowFunctionExpression
|
||||
| ESTree.Function
|
||||
| ESTree.TSCallSignatureDeclaration
|
||||
| ESTree.TSConstructSignatureDeclaration
|
||||
| ESTree.TSConstructorType
|
||||
| ESTree.TSFunctionType
|
||||
| ESTree.TSMethodSignature;
|
||||
|
||||
/** Ban the broad object type on function inputs, including local aliases to object. */
|
||||
export const noObjectParametersRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.",
|
||||
},
|
||||
messages: {
|
||||
objectParameter:
|
||||
"Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
let environment: TypeAliasEnvironment | null = null;
|
||||
|
||||
const resolvesToObject = (type: ESTree.TSType): boolean =>
|
||||
environment !== null &&
|
||||
resolvedTypeMatches(type, environment, (resolved, matches) => {
|
||||
if (resolved.type === "TSObjectKeyword") return true;
|
||||
if (resolved.type === "TSParenthesizedType") {
|
||||
return matches(resolved.typeAnnotation);
|
||||
}
|
||||
return (
|
||||
resolved.type === "TSUnionType" && resolved.types.some(matches)
|
||||
);
|
||||
});
|
||||
|
||||
const checkParameters = (node: ParameterOwner) => {
|
||||
for (const parameter of node.params) {
|
||||
const annotation = functionParameterTypeAnnotation(parameter);
|
||||
if (annotation === null || annotation === undefined) continue;
|
||||
if (!resolvesToObject(annotation.typeAnnotation)) continue;
|
||||
context.report({
|
||||
node: annotation.typeAnnotation,
|
||||
messageId: "objectParameter",
|
||||
data: { parameter: functionParameterBindingName(parameter, context.sourceCode) },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
environment = createTypeAliasEnvironment(
|
||||
node,
|
||||
context.sourceCode.visitorKeys,
|
||||
);
|
||||
},
|
||||
ArrowFunctionExpression: checkParameters,
|
||||
FunctionDeclaration: checkParameters,
|
||||
FunctionExpression: checkParameters,
|
||||
TSCallSignatureDeclaration: checkParameters,
|
||||
TSConstructSignatureDeclaration: checkParameters,
|
||||
TSConstructorType: checkParameters,
|
||||
TSDeclareFunction: checkParameters,
|
||||
TSEmptyBodyFunctionExpression: checkParameters,
|
||||
TSFunctionType: checkParameters,
|
||||
TSMethodSignature: checkParameters,
|
||||
};
|
||||
},
|
||||
});
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
import type { ESTree, SourceCode, Variable } from "@oxlint/plugins";
|
||||
|
||||
import {
|
||||
arrayMethodTarget,
|
||||
isKnownArrayExpression,
|
||||
resolveArrayBinding,
|
||||
unwrapArrayExpression,
|
||||
} from "../shared/array-method.ts";
|
||||
|
||||
function enclosingReducer(node: ESTree.Node) {
|
||||
let parent = node.parent;
|
||||
while (parent !== null) {
|
||||
if (parent.type === "FunctionDeclaration") return null;
|
||||
if (parent.type === "ArrowFunctionExpression" || parent.type === "FunctionExpression") {
|
||||
const callback = parent;
|
||||
let owner: ESTree.Node | null = callback.parent;
|
||||
while (owner !== null && unwrapArrayExpression(owner) === callback) owner = owner.parent;
|
||||
if (owner?.type !== "CallExpression") return null;
|
||||
const method = arrayMethodTarget(owner.callee);
|
||||
const firstArgument = owner.arguments[0];
|
||||
if (
|
||||
method === null || (method.name !== "reduce" && method.name !== "reduceRight") ||
|
||||
owner.arguments.length > 2 || firstArgument === undefined ||
|
||||
unwrapArrayExpression(firstArgument) !== callback
|
||||
) return null;
|
||||
const firstParameter = callback.params[0];
|
||||
const accumulator = firstParameter?.type === "AssignmentPattern" ? firstParameter.left : firstParameter;
|
||||
if (accumulator?.type !== "Identifier") return null;
|
||||
return { callback, accumulator, initialValue: owner.arguments[1] };
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function referencesAccumulator(
|
||||
sourceCode: SourceCode,
|
||||
node: ESTree.Node,
|
||||
accumulator: Variable,
|
||||
visited = new Set<Variable>(),
|
||||
): boolean {
|
||||
const variable = resolveArrayBinding(sourceCode, node);
|
||||
if (variable === null || visited.has(variable)) return false;
|
||||
if (variable === accumulator) return true;
|
||||
visited.add(variable);
|
||||
if (variable.references.some(reference => reference.isWrite() && !reference.init)) return false;
|
||||
for (const definition of variable.defs) {
|
||||
if (
|
||||
definition.type === "Variable" && definition.node.type === "VariableDeclarator" &&
|
||||
definition.node.id.type === "Identifier" && definition.node.init !== null &&
|
||||
definition.node.parent.type === "VariableDeclaration" && definition.node.parent.kind === "const"
|
||||
) {
|
||||
return referencesAccumulator(sourceCode, definition.node.init, accumulator, visited);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isGlobalCopyOwner(sourceCode: SourceCode, node: ESTree.Node, name: string): boolean {
|
||||
node = unwrapArrayExpression(node);
|
||||
if (node.type !== "Identifier" || node.name !== name) return false;
|
||||
const variable = resolveArrayBinding(sourceCode, node);
|
||||
return variable === null || variable.defs.length === 0;
|
||||
}
|
||||
|
||||
/** Reject non-spread copies of reducer accumulators; pair with oxc/no-accumulating-spread. */
|
||||
export const noReduceAccumulatorCopyRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: { description: "Disallow copying growing reducer accumulators with Object.assign, Array.from, or array copy methods." },
|
||||
messages: {
|
||||
accumulatorCopy: "Do not copy the reducer accumulator on every iteration; growing copies can cause quadratic work. Mutate a fresh, locally owned accumulator and return it, or use an iterator pipeline/flatMap.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
const method = arrayMethodTarget(node.callee);
|
||||
if (method === null) return;
|
||||
const reducer = enclosingReducer(node);
|
||||
if (reducer === null) return;
|
||||
const accumulator = context.sourceCode.getDeclaredVariables(reducer.callback).find(variable =>
|
||||
variable.identifiers.some(identifier => identifier.start === reducer.accumulator.start),
|
||||
);
|
||||
if (accumulator === undefined) return;
|
||||
const isAccumulator = (expression: ESTree.Node) =>
|
||||
referencesAccumulator(context.sourceCode, expression, accumulator);
|
||||
let copiesAccumulator = false;
|
||||
if (method.name === "assign" && isGlobalCopyOwner(context.sourceCode, method.object, "Object")) {
|
||||
const target = node.arguments[0];
|
||||
copiesAccumulator = (
|
||||
target !== undefined && unwrapArrayExpression(target).type === "ObjectExpression" &&
|
||||
node.arguments.slice(1).some(isAccumulator)
|
||||
);
|
||||
} else if (method.name === "from" && isGlobalCopyOwner(context.sourceCode, method.object, "Array")) {
|
||||
const source = node.arguments[0];
|
||||
copiesAccumulator = source !== undefined && isAccumulator(source);
|
||||
} else if (["concat", "slice", "toSpliced", "toSorted", "toReversed", "with"].includes(method.name)) {
|
||||
const initialValue = reducer.initialValue;
|
||||
const arrayAccumulator = initialValue !== undefined &&
|
||||
isKnownArrayExpression(context.sourceCode, initialValue);
|
||||
copiesAccumulator = arrayAccumulator && isAccumulator(method.object);
|
||||
}
|
||||
if (copiesAccumulator) context.report({ node, messageId: "accumulatorCopy" });
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts";
|
||||
|
||||
/** Ban Reflect.apply, which bypasses ordinary typed function calls. */
|
||||
export const noReflectApplyRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.",
|
||||
},
|
||||
messages: {
|
||||
reflectApply:
|
||||
"Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return;
|
||||
if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) {
|
||||
context.report({ node, messageId: "reflectApply" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts";
|
||||
|
||||
/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */
|
||||
export const noReflectGetRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.",
|
||||
},
|
||||
messages: {
|
||||
reflectGet:
|
||||
"Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return;
|
||||
if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) {
|
||||
context.report({ node, messageId: "reflectGet" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
type RuntimeFunction = ESTree.ArrowFunctionExpression | ESTree.Function;
|
||||
|
||||
function isRuntimeFunction(node: ESTree.Node): node is RuntimeFunction {
|
||||
return (
|
||||
node.type === "ArrowFunctionExpression" ||
|
||||
node.type === "FunctionDeclaration" ||
|
||||
node.type === "FunctionExpression"
|
||||
);
|
||||
}
|
||||
|
||||
function isInsideTypeGuard(node: ESTree.Node): boolean {
|
||||
let current: ESTree.Node | null = node.parent;
|
||||
while (current !== null && current.type !== "Program") {
|
||||
if (isRuntimeFunction(current)) {
|
||||
return current.returnType?.typeAnnotation.type === "TSTypePredicate";
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Return whether typeof safely probes for the existence of a possibly absent binding. */
|
||||
function isExistenceProbe(node: ESTree.UnaryExpression): boolean {
|
||||
const parent = node.parent;
|
||||
if (parent.type !== "BinaryExpression") return false;
|
||||
if (!["===", "!==", "==", "!="].includes(parent.operator)) return false;
|
||||
const other = parent.left === node ? parent.right : parent.left;
|
||||
return other.type === "Literal" && other.value === "undefined";
|
||||
}
|
||||
|
||||
/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */
|
||||
export const noRuntimeTypeofRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.",
|
||||
},
|
||||
messages: {
|
||||
runtimeTypeof:
|
||||
"A `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value.",
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
allowInTypeGuards: { type: "boolean" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
defaultOptions: [{ allowInTypeGuards: false }],
|
||||
},
|
||||
createOnce(context) {
|
||||
return {
|
||||
UnaryExpression(node) {
|
||||
const option = context.options?.[0];
|
||||
const allowInTypeGuards =
|
||||
typeof option === "object" &&
|
||||
option !== null &&
|
||||
!Array.isArray(option) &&
|
||||
option.allowInTypeGuards === true;
|
||||
if (
|
||||
node.operator === "typeof" &&
|
||||
!isExistenceProbe(node) &&
|
||||
(!allowInTypeGuards || !isInsideTypeGuard(node))
|
||||
) {
|
||||
context.report({ node, messageId: "runtimeTypeof" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
const FORBIDDEN_SYMBOL_NAME = "shape";
|
||||
|
||||
function containsForbiddenSymbolName(name: string): boolean {
|
||||
return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME);
|
||||
}
|
||||
|
||||
/** Return whether an identifier names a statically accessed member owned by another value. */
|
||||
function isBorrowedMemberName(node: ESTree.Node): boolean {
|
||||
const parent = node.parent;
|
||||
if (parent === null || parent.type !== "MemberExpression") return false;
|
||||
return parent.property === node && parent.computed === false;
|
||||
}
|
||||
|
||||
/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */
|
||||
export const noForbiddenTermInSymbolNamesRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.',
|
||||
},
|
||||
messages: {
|
||||
forbiddenSymbolName:
|
||||
'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.',
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => {
|
||||
if (!containsForbiddenSymbolName(node.name) || isBorrowedMemberName(node)) return;
|
||||
context.report({
|
||||
node,
|
||||
messageId: "forbiddenSymbolName",
|
||||
data: { name: node.name },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
Identifier: reportForbiddenSymbolName,
|
||||
PrivateIdentifier: reportForbiddenSymbolName,
|
||||
JSXIdentifier: reportForbiddenSymbolName,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
import {
|
||||
containsUnknownType,
|
||||
functionParameterBindingName,
|
||||
functionParameterTypeAnnotation,
|
||||
} from "../shared/function-parameters.ts";
|
||||
type ParameterOwner =
|
||||
| ESTree.ArrowFunctionExpression
|
||||
| ESTree.Function
|
||||
| ESTree.TSCallSignatureDeclaration
|
||||
| ESTree.TSConstructSignatureDeclaration
|
||||
| ESTree.TSConstructorType
|
||||
| ESTree.TSFunctionType
|
||||
| ESTree.TSMethodSignature;
|
||||
|
||||
function isTypePredicateSubject(owner: ParameterOwner, parameterName: string): boolean {
|
||||
const predicate = owner.returnType?.typeAnnotation;
|
||||
return (
|
||||
predicate?.type === "TSTypePredicate" &&
|
||||
predicate.parameterName.type === "Identifier" &&
|
||||
predicate.parameterName.name === parameterName
|
||||
);
|
||||
}
|
||||
|
||||
/** Disallow unknown inputs except explicitly named error-cause enrichment. */
|
||||
export const noUnknownParametersRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow explicitly unknown function parameters except `cause` and type-predicate subjects; decode unknown input at its I/O boundary instead.",
|
||||
},
|
||||
messages: {
|
||||
unknownParameter:
|
||||
"Parameter `{{parameter}}` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
const checkParameters = (node: ParameterOwner) => {
|
||||
for (const parameter of node.params) {
|
||||
const annotation = functionParameterTypeAnnotation(parameter);
|
||||
if (annotation === null || annotation === undefined) continue;
|
||||
if (!containsUnknownType(annotation.typeAnnotation)) continue;
|
||||
const name = functionParameterBindingName(parameter, context.sourceCode);
|
||||
if (name === "cause" || isTypePredicateSubject(node, name)) continue;
|
||||
context.report({
|
||||
node: annotation.typeAnnotation,
|
||||
messageId: "unknownParameter",
|
||||
data: { parameter: name },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ArrowFunctionExpression: checkParameters,
|
||||
FunctionDeclaration: checkParameters,
|
||||
FunctionExpression: checkParameters,
|
||||
TSCallSignatureDeclaration: checkParameters,
|
||||
TSConstructSignatureDeclaration: checkParameters,
|
||||
TSConstructorType: checkParameters,
|
||||
TSDeclareFunction: checkParameters,
|
||||
TSEmptyBodyFunctionExpression: checkParameters,
|
||||
TSFunctionType: checkParameters,
|
||||
TSMethodSignature: checkParameters,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
import {
|
||||
createTypeAliasEnvironment,
|
||||
resolvedTypeMatches,
|
||||
type TypeAliasEnvironment,
|
||||
} from "../shared/type-alias-resolution.ts";
|
||||
|
||||
type FunctionWithReturnType =
|
||||
| ESTree.ArrowFunctionExpression
|
||||
| ESTree.Function
|
||||
| ESTree.TSCallSignatureDeclaration
|
||||
| ESTree.TSConstructSignatureDeclaration
|
||||
| ESTree.TSConstructorType
|
||||
| ESTree.TSFunctionType
|
||||
| ESTree.TSMethodSignature;
|
||||
|
||||
/** Ban function contracts that return unknown instead of a parsed domain type. */
|
||||
export const noUnknownReturnsRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow functions whose explicit return contract is unknown or Promise<unknown>.",
|
||||
},
|
||||
messages: {
|
||||
unknownReturn:
|
||||
"This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
let environment: TypeAliasEnvironment | null = null;
|
||||
|
||||
const resolvesToUnknown = (type: ESTree.TSType): boolean =>
|
||||
environment !== null &&
|
||||
resolvedTypeMatches(type, environment, (resolved, matches) => {
|
||||
if (resolved.type === "TSUnknownKeyword") return true;
|
||||
if (resolved.type === "TSParenthesizedType") {
|
||||
return matches(resolved.typeAnnotation);
|
||||
}
|
||||
if (resolved.type === "TSUnionType") return resolved.types.some(matches);
|
||||
if (
|
||||
resolved.type !== "TSTypeReference" ||
|
||||
resolved.typeName.type !== "Identifier" ||
|
||||
(resolved.typeName.name !== "Promise" &&
|
||||
resolved.typeName.name !== "PromiseLike")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const value = resolved.typeArguments?.params[0];
|
||||
return value !== undefined && matches(value);
|
||||
});
|
||||
|
||||
const checkReturnType = (node: FunctionWithReturnType) => {
|
||||
const annotation = node.returnType;
|
||||
if (annotation === null || annotation === undefined) return;
|
||||
if (!resolvesToUnknown(annotation.typeAnnotation)) return;
|
||||
context.report({ node: annotation.typeAnnotation, messageId: "unknownReturn" });
|
||||
};
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
environment = createTypeAliasEnvironment(
|
||||
node,
|
||||
context.sourceCode.visitorKeys,
|
||||
);
|
||||
},
|
||||
ArrowFunctionExpression: checkReturnType,
|
||||
FunctionDeclaration: checkReturnType,
|
||||
FunctionExpression: checkReturnType,
|
||||
TSCallSignatureDeclaration: checkReturnType,
|
||||
TSConstructSignatureDeclaration: checkReturnType,
|
||||
TSConstructorType: checkReturnType,
|
||||
TSDeclareFunction: checkReturnType,
|
||||
TSEmptyBodyFunctionExpression: checkReturnType,
|
||||
TSFunctionType: checkReturnType,
|
||||
TSMethodSignature: checkReturnType,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
import {
|
||||
createTypeAliasEnvironment,
|
||||
resolvedTypeMatches,
|
||||
type TypeAliasEnvironment,
|
||||
} from "../shared/type-alias-resolution.ts";
|
||||
|
||||
/** Ban named aliases that merely conceal TypeScript's unknown top type. */
|
||||
export const noUnknownTypeAliasesRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.",
|
||||
},
|
||||
messages: {
|
||||
unknownAlias:
|
||||
"Type alias `{{alias}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
let environment: TypeAliasEnvironment | null = null;
|
||||
|
||||
const resolvesToUnknown = (type: ESTree.TSType): boolean =>
|
||||
environment !== null &&
|
||||
resolvedTypeMatches(type, environment, (resolved, matches) => {
|
||||
if (resolved.type === "TSUnknownKeyword") return true;
|
||||
if (resolved.type === "TSParenthesizedType") {
|
||||
return matches(resolved.typeAnnotation);
|
||||
}
|
||||
return resolved.type === "TSUnionType" && resolved.types.some(matches);
|
||||
});
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
environment = createTypeAliasEnvironment(
|
||||
node,
|
||||
context.sourceCode.visitorKeys,
|
||||
);
|
||||
},
|
||||
TSTypeAliasDeclaration(node) {
|
||||
if (!resolvesToUnknown(node.typeAnnotation)) return;
|
||||
context.report({
|
||||
node: node.id,
|
||||
messageId: "unknownAlias",
|
||||
data: { alias: node.id.name },
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import {
|
||||
classifyUnsafeDictionary,
|
||||
classifyUnsafeDictionaryValue,
|
||||
createTypeEnvironment,
|
||||
type TypeEnvironment,
|
||||
} from "../shared/dictionary-types.ts";
|
||||
import { visibleTypeAlias } from "../shared/type-alias-resolution.ts";
|
||||
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
const typeNodeKinds: ReadonlySet<string> = new Set([
|
||||
"JSDocNonNullableType",
|
||||
"JSDocNullableType",
|
||||
"JSDocUnknownType",
|
||||
"TSAnyKeyword",
|
||||
"TSArrayType",
|
||||
"TSBigIntKeyword",
|
||||
"TSBooleanKeyword",
|
||||
"TSConditionalType",
|
||||
"TSConstructorType",
|
||||
"TSFunctionType",
|
||||
"TSImportType",
|
||||
"TSIndexedAccessType",
|
||||
"TSInferType",
|
||||
"TSIntersectionType",
|
||||
"TSIntrinsicKeyword",
|
||||
"TSLiteralType",
|
||||
"TSMappedType",
|
||||
"TSNamedTupleMember",
|
||||
"TSNeverKeyword",
|
||||
"TSNullKeyword",
|
||||
"TSNumberKeyword",
|
||||
"TSObjectKeyword",
|
||||
"TSParenthesizedType",
|
||||
"TSStringKeyword",
|
||||
"TSSymbolKeyword",
|
||||
"TSTemplateLiteralType",
|
||||
"TSThisType",
|
||||
"TSTupleType",
|
||||
"TSTypeLiteral",
|
||||
"TSTypeOperator",
|
||||
"TSTypePredicate",
|
||||
"TSTypeQuery",
|
||||
"TSTypeReference",
|
||||
"TSUndefinedKeyword",
|
||||
"TSUnionType",
|
||||
"TSUnknownKeyword",
|
||||
"TSVoidKeyword",
|
||||
]);
|
||||
|
||||
function isTypeNode(node: ESTree.Node): node is ESTree.TSType {
|
||||
return typeNodeKinds.has(node.type);
|
||||
}
|
||||
|
||||
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
||||
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
||||
}
|
||||
|
||||
function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean {
|
||||
let current: ESTree.Node | null = node.parent;
|
||||
while (current !== null && current.type !== "Program") {
|
||||
if (current.type === "TSTypeAliasDeclaration") return true;
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean {
|
||||
if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false;
|
||||
const name = typeReferenceName(node);
|
||||
return (
|
||||
name !== null &&
|
||||
visibleTypeAlias(name, node, environment.typeAliases) !== null &&
|
||||
!isInsideTypeAliasDeclaration(node)
|
||||
);
|
||||
}
|
||||
|
||||
function isInsideTypeParameterConstraint(node: ESTree.TSType): boolean {
|
||||
let child: ESTree.Node = node;
|
||||
let parent: ESTree.Node | null = child.parent;
|
||||
while (parent !== null && parent.type !== "Program") {
|
||||
if (parent.type === "TSTypeParameter" && parent.constraint === child) return true;
|
||||
child = parent;
|
||||
parent = child.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean {
|
||||
if (isInsideTypeParameterConstraint(node)) return false;
|
||||
if (isPlainAliasConsumerUse(node, environment)) return false;
|
||||
if (classifyUnsafeDictionary(node, environment) === null) return false;
|
||||
let current: ESTree.Node | null = node.parent;
|
||||
while (current !== null && current.type !== "Program") {
|
||||
if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null)
|
||||
return false;
|
||||
current = current.parent;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */
|
||||
export const noUnsafeDictionaryTypeRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.",
|
||||
},
|
||||
messages: {
|
||||
unsafeDictionary:
|
||||
"This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
let environment: TypeEnvironment | null = null;
|
||||
const report = (node: ESTree.Node, value: string) => {
|
||||
context.report({ node, messageId: "unsafeDictionary", data: { value } });
|
||||
};
|
||||
const reportIfUnsafe = (node: ESTree.TSType) => {
|
||||
if (environment === null || !shouldReportType(node, environment)) return;
|
||||
const unsafe = classifyUnsafeDictionary(node, environment);
|
||||
if (unsafe === null) return;
|
||||
report(node, unsafe.unsafeValue);
|
||||
};
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
environment = createTypeEnvironment(
|
||||
node,
|
||||
context.sourceCode.visitorKeys,
|
||||
);
|
||||
},
|
||||
TSTypeReference: reportIfUnsafe,
|
||||
TSTypeLiteral: reportIfUnsafe,
|
||||
TSMappedType: reportIfUnsafe,
|
||||
TSIndexSignature(node) {
|
||||
if (
|
||||
environment === null ||
|
||||
node.typeAnnotation === null ||
|
||||
node.parent.type === "TSTypeLiteral"
|
||||
)
|
||||
return;
|
||||
const unsafe = classifyUnsafeDictionaryValue(
|
||||
node.typeAnnotation.typeAnnotation,
|
||||
environment,
|
||||
);
|
||||
if (unsafe !== null) report(node, unsafe.unsafeValue);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
import type { ESTree, Variable } from "@oxlint/plugins";
|
||||
|
||||
type BroadTypeKind = "top" | "object" | "record";
|
||||
|
||||
type KnownValueEvidence = {
|
||||
readonly type: ESTree.TSType | null;
|
||||
};
|
||||
|
||||
const functionBoundaryTypes = new Set([
|
||||
"ArrowFunctionExpression",
|
||||
"FunctionDeclaration",
|
||||
"FunctionExpression",
|
||||
"TSDeclareFunction",
|
||||
"TSEmptyBodyFunctionExpression",
|
||||
]);
|
||||
|
||||
function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression {
|
||||
let current = expression;
|
||||
while (current.type === "ParenthesizedExpression") current = current.expression;
|
||||
return current;
|
||||
}
|
||||
|
||||
function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType {
|
||||
let current = type;
|
||||
while (current.type === "TSParenthesizedType") current = current.typeAnnotation;
|
||||
return current;
|
||||
}
|
||||
|
||||
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
||||
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
||||
}
|
||||
|
||||
function isUnknownOrAnyType(type: ESTree.TSType): boolean {
|
||||
const unwrapped = unwrapTypeParentheses(type);
|
||||
return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword";
|
||||
}
|
||||
|
||||
function isBroadRecordKeyType(type: ESTree.TSType): boolean {
|
||||
const unwrapped = unwrapTypeParentheses(type);
|
||||
if (
|
||||
unwrapped.type === "TSStringKeyword" ||
|
||||
unwrapped.type === "TSNumberKeyword" ||
|
||||
unwrapped.type === "TSSymbolKeyword"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType);
|
||||
return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey";
|
||||
}
|
||||
|
||||
function isBroadRecordType(type: ESTree.TSType): boolean {
|
||||
const unwrapped = unwrapTypeParentheses(type);
|
||||
|
||||
if (unwrapped.type === "TSTypeReference") {
|
||||
if (typeReferenceName(unwrapped) === "Readonly") {
|
||||
const [inner] = unwrapped.typeArguments?.params ?? [];
|
||||
return inner !== undefined && isBroadRecordType(inner);
|
||||
}
|
||||
|
||||
if (typeReferenceName(unwrapped) !== "Record") return false;
|
||||
const parameters = unwrapped.typeArguments?.params ?? [];
|
||||
return (
|
||||
parameters.length === 2 &&
|
||||
parameters[0] !== undefined &&
|
||||
parameters[1] !== undefined &&
|
||||
isBroadRecordKeyType(parameters[0]) &&
|
||||
isUnknownOrAnyType(parameters[1])
|
||||
);
|
||||
}
|
||||
|
||||
if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false;
|
||||
const [member] = unwrapped.members;
|
||||
const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : [];
|
||||
return (
|
||||
member?.type === "TSIndexSignature" &&
|
||||
member.parameters.length === 1 &&
|
||||
parameter !== undefined &&
|
||||
isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) &&
|
||||
isUnknownOrAnyType(member.typeAnnotation.typeAnnotation)
|
||||
);
|
||||
}
|
||||
|
||||
function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null {
|
||||
const unwrapped = unwrapTypeParentheses(type);
|
||||
if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top";
|
||||
if (unwrapped.type === "TSObjectKeyword") return "object";
|
||||
return isBroadRecordType(unwrapped) ? "record" : null;
|
||||
}
|
||||
|
||||
function assertedExpression(
|
||||
node: ESTree.TSAsExpression | ESTree.TSTypeAssertion,
|
||||
): ESTree.Expression {
|
||||
return unwrapExpressionParentheses(node.expression);
|
||||
}
|
||||
|
||||
function assertionFromExpression(
|
||||
expression: ESTree.Expression,
|
||||
): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null {
|
||||
const unwrapped = unwrapExpressionParentheses(expression);
|
||||
return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion"
|
||||
? unwrapped
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizedTypeText(sourceText: string, type: ESTree.TSType): string {
|
||||
return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, "");
|
||||
}
|
||||
|
||||
function typesHaveSameSyntax(
|
||||
sourceText: string,
|
||||
left: ESTree.TSType | null,
|
||||
right: ESTree.TSType,
|
||||
): boolean {
|
||||
return (
|
||||
left !== null &&
|
||||
normalizedTypeText(sourceText, unwrapTypeParentheses(left)) ===
|
||||
normalizedTypeText(sourceText, unwrapTypeParentheses(right))
|
||||
);
|
||||
}
|
||||
|
||||
function isDefinitelyObjectType(type: ESTree.TSType): boolean {
|
||||
const unwrapped = unwrapTypeParentheses(type);
|
||||
switch (unwrapped.type) {
|
||||
case "TSArrayType":
|
||||
case "TSConstructorType":
|
||||
case "TSFunctionType":
|
||||
case "TSMappedType":
|
||||
case "TSObjectKeyword":
|
||||
case "TSTupleType":
|
||||
return true;
|
||||
case "TSTypeLiteral":
|
||||
return unwrapped.members.length > 0;
|
||||
case "TSIntersectionType":
|
||||
return unwrapped.types.every(isDefinitelyObjectType);
|
||||
case "TSTypeOperator":
|
||||
return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean {
|
||||
const unwrapped = unwrapTypeParentheses(type);
|
||||
if (unwrapped.type === "TSTypeLiteral") {
|
||||
return unwrapped.members.some((member) => member.type !== "TSIndexSignature");
|
||||
}
|
||||
|
||||
if (unwrapped.type !== "TSTypeReference") return false;
|
||||
if (typeReferenceName(unwrapped) === "Readonly") {
|
||||
const [inner] = unwrapped.typeArguments?.params ?? [];
|
||||
return inner !== undefined && isDefinitelyNarrowerRecordType(inner);
|
||||
}
|
||||
if (typeReferenceName(unwrapped) !== "Record") return false;
|
||||
|
||||
const parameters = unwrapped.typeArguments?.params ?? [];
|
||||
return (
|
||||
parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1])
|
||||
);
|
||||
}
|
||||
|
||||
function functionBoundary(node: ESTree.Node): ESTree.Node | null {
|
||||
let current = node.parent;
|
||||
while (current !== null && current.type !== "Program") {
|
||||
if (functionBoundaryTypes.has(current.type)) return current;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvedVariableForIdentifier(
|
||||
scopes: readonly {
|
||||
readonly references: readonly {
|
||||
readonly identifier: ESTree.Node;
|
||||
readonly resolved: Variable | null;
|
||||
}[];
|
||||
}[],
|
||||
identifier: ESTree.IdentifierReference,
|
||||
): Variable | null {
|
||||
for (const scope of scopes) {
|
||||
const reference = scope.references.find(
|
||||
(candidate) =>
|
||||
candidate.identifier.start === identifier.start &&
|
||||
candidate.identifier.end === identifier.end,
|
||||
);
|
||||
if (reference !== undefined) return reference.resolved;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null {
|
||||
for (const definition of variable.defs) {
|
||||
if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") {
|
||||
return definition.node;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function knownValueEvidence(
|
||||
expression: ESTree.Expression,
|
||||
scopes: Parameters<typeof resolvedVariableForIdentifier>[0],
|
||||
boundary: ESTree.Node | null,
|
||||
visitedVariables: ReadonlySet<Variable>,
|
||||
): KnownValueEvidence | null {
|
||||
const unwrapped = unwrapExpressionParentheses(expression);
|
||||
|
||||
if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") {
|
||||
if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null;
|
||||
return { type: unwrapped.typeAnnotation };
|
||||
}
|
||||
|
||||
if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") {
|
||||
return { type: null };
|
||||
}
|
||||
|
||||
if (
|
||||
unwrapped.type === "ArrayExpression" ||
|
||||
unwrapped.type === "ArrowFunctionExpression" ||
|
||||
unwrapped.type === "ClassExpression" ||
|
||||
unwrapped.type === "FunctionExpression" ||
|
||||
unwrapped.type === "NewExpression" ||
|
||||
unwrapped.type === "ObjectExpression"
|
||||
) {
|
||||
return { type: null };
|
||||
}
|
||||
|
||||
if (unwrapped.type !== "Identifier") return null;
|
||||
const variable = resolvedVariableForIdentifier(scopes, unwrapped);
|
||||
if (variable === null || visitedVariables.has(variable)) return null;
|
||||
|
||||
const annotatedIdentifier = variable.identifiers.find(
|
||||
(identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined,
|
||||
);
|
||||
const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation;
|
||||
if (annotation !== undefined && annotatedIdentifier !== undefined) {
|
||||
if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) {
|
||||
return null;
|
||||
}
|
||||
return { type: annotation };
|
||||
}
|
||||
|
||||
const declarator = variableDeclarator(variable);
|
||||
if (
|
||||
declarator === null ||
|
||||
declarator.parent.type !== "VariableDeclaration" ||
|
||||
declarator.parent.kind !== "const" ||
|
||||
declarator.init === null ||
|
||||
variable.references.some((reference) => reference.isWrite() && !reference.init) ||
|
||||
functionBoundary(declarator) !== boundary
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return knownValueEvidence(
|
||||
declarator.init,
|
||||
scopes,
|
||||
boundary,
|
||||
new Set([...visitedVariables, variable]),
|
||||
);
|
||||
}
|
||||
|
||||
function widenedBinding(
|
||||
variable: Variable,
|
||||
scopes: Parameters<typeof resolvedVariableForIdentifier>[0],
|
||||
): {
|
||||
readonly broadKind: BroadTypeKind;
|
||||
readonly evidence: KnownValueEvidence;
|
||||
readonly declaredAt: number;
|
||||
readonly boundary: ESTree.Node | null;
|
||||
} | null {
|
||||
const declarator = variableDeclarator(variable);
|
||||
if (
|
||||
declarator === null ||
|
||||
declarator.parent.type !== "VariableDeclaration" ||
|
||||
declarator.parent.kind !== "const" ||
|
||||
declarator.id.type !== "Identifier" ||
|
||||
declarator.init === null ||
|
||||
variable.references.some((reference) => reference.isWrite() && !reference.init)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const boundary = functionBoundary(declarator);
|
||||
const declaredType = declarator.id.typeAnnotation?.typeAnnotation;
|
||||
const initializerAssertion = assertionFromExpression(declarator.init);
|
||||
const initializerBroadKind =
|
||||
initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation);
|
||||
const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType);
|
||||
const broadKind = declaredBroadKind ?? initializerBroadKind;
|
||||
if (broadKind === null) return null;
|
||||
|
||||
const originalExpression =
|
||||
initializerAssertion !== null && initializerBroadKind !== null
|
||||
? assertedExpression(initializerAssertion)
|
||||
: declarator.init;
|
||||
const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable]));
|
||||
return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary };
|
||||
}
|
||||
|
||||
function assertionIsNarrower(
|
||||
sourceText: string,
|
||||
broadKind: BroadTypeKind,
|
||||
evidence: KnownValueEvidence,
|
||||
assertedType: ESTree.TSType,
|
||||
): boolean {
|
||||
if (broadTypeKind(assertedType) !== null) return false;
|
||||
if (broadKind === "top") return true;
|
||||
if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true;
|
||||
if (broadKind === "object") return isDefinitelyObjectType(assertedType);
|
||||
return isDefinitelyNarrowerRecordType(assertedType);
|
||||
}
|
||||
|
||||
/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */
|
||||
export const noWidenThenAssertRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.",
|
||||
},
|
||||
messages: {
|
||||
widenThenAssert:
|
||||
'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.',
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
let scopes: Parameters<typeof resolvedVariableForIdentifier>[0] = [];
|
||||
|
||||
const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => {
|
||||
const expression = assertedExpression(node);
|
||||
if (expression.type !== "Identifier") return;
|
||||
|
||||
const variable = resolvedVariableForIdentifier(scopes, expression);
|
||||
if (variable === null) return;
|
||||
const widened = widenedBinding(variable, scopes);
|
||||
if (
|
||||
widened === null ||
|
||||
node.start <= widened.declaredAt ||
|
||||
functionBoundary(node) !== widened.boundary ||
|
||||
!assertionIsNarrower(
|
||||
context.sourceCode.text,
|
||||
widened.broadKind,
|
||||
widened.evidence,
|
||||
node.typeAnnotation,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: "widenThenAssert",
|
||||
data: { name: expression.name },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
Program() {
|
||||
scopes = context.sourceCode.scopeManager.scopes;
|
||||
},
|
||||
TSAsExpression: checkAssertion,
|
||||
TSTypeAssertion: checkAssertion,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { CreateRule, ESTree, SourceCode } from "@oxlint/plugins";
|
||||
|
||||
import createPaddingLineRule from "../vendor/eslint-stylistic/padding-line-between-statements.ts";
|
||||
|
||||
import type { NodeTestObject } from "../vendor/eslint-stylistic/padding-line-between-statements.ts";
|
||||
|
||||
/** A `const`/`let`/`var`/`using` declaration, optionally exported, written on one line. */
|
||||
function isSinglelineBinding(node: ESTree.Node): boolean {
|
||||
const declaration = node.type === "ExportNamedDeclaration" ? node.declaration : node;
|
||||
return declaration?.type === "VariableDeclaration" && node.loc.start.line === node.loc.end.line;
|
||||
}
|
||||
|
||||
/** Comments above a statement belong to it; a comment trailing the previous line does not. */
|
||||
function hasLeadingComment(node: ESTree.Node, sourceCode: SourceCode): boolean {
|
||||
const previousLine = sourceCode.getTokenBefore(node)?.loc.end.line;
|
||||
return sourceCode
|
||||
.getCommentsBefore(node)
|
||||
.some((comment) => comment.loc.start.line !== previousLine);
|
||||
}
|
||||
|
||||
const singlelineBinding: NodeTestObject = {
|
||||
test: (node) => isSinglelineBinding(node),
|
||||
};
|
||||
|
||||
const undocumentedSinglelineBinding: NodeTestObject = {
|
||||
test: (node, sourceCode) => isSinglelineBinding(node) && !hasLeadingComment(node, sourceCode),
|
||||
};
|
||||
|
||||
const paddingRule = createPaddingLineRule(
|
||||
[
|
||||
{ blankLine: "always", prev: "import", next: "*" },
|
||||
{ blankLine: "always", prev: "*", next: { selector: "Program > :not(ImportDeclaration)" } },
|
||||
{ blankLine: "always", prev: { selector: "Program > :not(ImportDeclaration)" }, next: "*" },
|
||||
{ blankLine: "always", prev: "*", next: ["function", "class", "interface", "type"] },
|
||||
{ blankLine: "always", prev: ["function", "class", "interface", "type"], next: "*" },
|
||||
{
|
||||
blankLine: "always",
|
||||
prev: "*",
|
||||
next: ["multiline-const", "multiline-let", "multiline-var", "multiline-using"],
|
||||
},
|
||||
{
|
||||
blankLine: "always",
|
||||
prev: ["multiline-const", "multiline-let", "multiline-var", "multiline-using"],
|
||||
next: "*",
|
||||
},
|
||||
{ blankLine: "always", prev: "*", next: ["return", "if", "switch", "try", "for", "while", "do"] },
|
||||
{ blankLine: "always", prev: "block-like", next: "*" },
|
||||
{ blankLine: "any", prev: "import", next: "import" },
|
||||
// Consecutive single-line bindings form one group at any scope, including
|
||||
// module scope. A binding with its own leading comment starts a new group.
|
||||
{ blankLine: "any", prev: "singleline-binding", next: "undocumented-singleline-binding" },
|
||||
{
|
||||
blankLine: "any",
|
||||
prev: {
|
||||
selector:
|
||||
':matches(TSDeclareFunction, ExportNamedDeclaration[declaration.type="TSDeclareFunction"])',
|
||||
},
|
||||
next: {
|
||||
selector:
|
||||
':matches(TSDeclareFunction, FunctionDeclaration, ExportNamedDeclaration[declaration.type="TSDeclareFunction"], ExportNamedDeclaration[declaration.type="FunctionDeclaration"])',
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
"singleline-binding": singlelineBinding,
|
||||
"undocumented-singleline-binding": undocumentedSinglelineBinding,
|
||||
},
|
||||
);
|
||||
|
||||
/** Restore structural blank lines with whitespace-only fixes; keep single-line binding groups and overloads grouped. */
|
||||
export const requireReadableSpacingRule: CreateRule = {
|
||||
...paddingRule,
|
||||
meta: {
|
||||
...paddingRule.meta,
|
||||
docs: {
|
||||
description: "Require readable spacing between declarations and logical statement groups.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
};
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import type { ESTree, SourceCode } from "@oxlint/plugins";
|
||||
|
||||
type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion;
|
||||
|
||||
const DEFAULT_SAFETY_MARKERS = ["SAFETY"] as const;
|
||||
|
||||
const commentOwnerKinds = new Set([
|
||||
"ExpressionStatement",
|
||||
"PropertyDefinition",
|
||||
"ReturnStatement",
|
||||
"ThrowStatement",
|
||||
"VariableDeclaration",
|
||||
]);
|
||||
|
||||
function isConstAssertion(node: TypeAssertion): boolean {
|
||||
return (
|
||||
node.typeAnnotation.type === "TSTypeReference" &&
|
||||
node.typeAnnotation.typeName.type === "Identifier" &&
|
||||
node.typeAnnotation.typeName.name === "const"
|
||||
);
|
||||
}
|
||||
|
||||
function configuredSafetyMarkers(option: unknown): readonly string[] {
|
||||
if (typeof option !== "object" || option === null || !("markers" in option)) {
|
||||
return DEFAULT_SAFETY_MARKERS;
|
||||
}
|
||||
const configured = option.markers;
|
||||
if (!Array.isArray(configured)) return DEFAULT_SAFETY_MARKERS;
|
||||
const markers = configured.flatMap((marker) =>
|
||||
typeof marker === "string" && marker.trim().length > 0 ? [marker.trim()] : [],
|
||||
);
|
||||
return markers.length > 0 ? markers : DEFAULT_SAFETY_MARKERS;
|
||||
}
|
||||
|
||||
function markerPattern(markers: readonly string[]): RegExp {
|
||||
const alternation = markers
|
||||
.map((marker) => marker.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`))
|
||||
.join("|");
|
||||
return new RegExp(
|
||||
String.raw`(?:^|[^\p{L}\p{N}_])(?:${alternation})\s*:\s*\S`,
|
||||
"u",
|
||||
);
|
||||
}
|
||||
|
||||
function hasSafetyJustificationBefore(
|
||||
sourceCode: SourceCode,
|
||||
owner: ESTree.Node,
|
||||
assertion: TypeAssertion,
|
||||
pattern: RegExp,
|
||||
): boolean {
|
||||
return sourceCode
|
||||
.getCommentsBefore(owner)
|
||||
.some(
|
||||
(comment) => comment.end <= assertion.start && pattern.test(comment.value),
|
||||
);
|
||||
}
|
||||
|
||||
function hasSafetyComment(
|
||||
sourceCode: SourceCode,
|
||||
node: TypeAssertion,
|
||||
pattern: RegExp,
|
||||
): boolean {
|
||||
let current: ESTree.Node = node;
|
||||
while (true) {
|
||||
if (hasSafetyJustificationBefore(sourceCode, current, node, pattern)) return true;
|
||||
if (commentOwnerKinds.has(current.type)) {
|
||||
const exportDeclaration = current.parent;
|
||||
return (
|
||||
exportDeclaration.type === "ExportNamedDeclaration" &&
|
||||
exportDeclaration.declaration === current &&
|
||||
hasSafetyJustificationBefore(sourceCode, exportDeclaration, node, pattern)
|
||||
);
|
||||
}
|
||||
if (current.parent.type === "Program") return false;
|
||||
current = current.parent;
|
||||
}
|
||||
}
|
||||
|
||||
/** Require every non-const type assertion to state the invariant TypeScript cannot express. */
|
||||
export const requireSafetyCommentForTypeAssertionRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.",
|
||||
},
|
||||
messages: {
|
||||
missingSafetyComment:
|
||||
"This type assertion has no `{{marker}}:` justification. State the checked invariant immediately before the assertion or its containing statement.",
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
markers: {
|
||||
type: "array",
|
||||
items: { type: "string", minLength: 1 },
|
||||
minItems: 1,
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
defaultOptions: [{ markers: ["SAFETY"] }],
|
||||
},
|
||||
createOnce(context) {
|
||||
const patterns = new Map<string, RegExp>();
|
||||
|
||||
const checkAssertion = (node: TypeAssertion) => {
|
||||
if (isConstAssertion(node)) return;
|
||||
const markers = configuredSafetyMarkers(context.options?.[0]);
|
||||
const patternKey = markers.join("\u0000");
|
||||
const pattern = patterns.get(patternKey) ?? markerPattern(markers);
|
||||
patterns.set(patternKey, pattern);
|
||||
if (hasSafetyComment(context.sourceCode, node, pattern)) return;
|
||||
context.report({
|
||||
node,
|
||||
messageId: "missingSafetyComment",
|
||||
data: { marker: markers[0] ?? DEFAULT_SAFETY_MARKERS[0] },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
TSAsExpression: checkAssertion,
|
||||
TSTypeAssertion: checkAssertion,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
|
||||
|
||||
/** Unwrap syntax-only wrappers when inspecting array methods and accumulator references. */
|
||||
export function unwrapArrayExpression(node: ESTree.Node): ESTree.Node {
|
||||
while (
|
||||
node.type === "ParenthesizedExpression" ||
|
||||
node.type === "ChainExpression" ||
|
||||
node.type === "TSAsExpression" ||
|
||||
node.type === "TSTypeAssertion" ||
|
||||
node.type === "TSNonNullExpression" ||
|
||||
node.type === "TSSatisfiesExpression"
|
||||
) {
|
||||
node = node.expression;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Resolve a local binding by scope, not by identifier spelling. */
|
||||
export function resolveArrayBinding(sourceCode: SourceCode, node: ESTree.Node): Variable | null {
|
||||
node = unwrapArrayExpression(node);
|
||||
if (node.type !== "Identifier") return null;
|
||||
let scope: Scope | null = sourceCode.getScope(node);
|
||||
while (scope !== null) {
|
||||
const variable = scope.set.get(node.name);
|
||||
if (variable !== undefined) return variable;
|
||||
scope = scope.upper;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Read static method names, including computed string literals, without evaluating expressions. */
|
||||
export function arrayMethodTarget(
|
||||
node: ESTree.Node,
|
||||
): { readonly name: string; readonly object: ESTree.Node } | null {
|
||||
node = unwrapArrayExpression(node);
|
||||
if (node.type !== "MemberExpression") return null;
|
||||
const property = node.property;
|
||||
if (!node.computed && property.type === "Identifier") {
|
||||
return { name: property.name, object: node.object };
|
||||
}
|
||||
if (node.computed && property.type === "Literal" && typeof property.value === "string") {
|
||||
return { name: property.value, object: node.object };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isArrayAnnotation(type: ESTree.TSType): boolean {
|
||||
if (type.type === "TSArrayType" || type.type === "TSTupleType") return true;
|
||||
if (type.type === "TSParenthesizedType") return isArrayAnnotation(type.typeAnnotation);
|
||||
if (type.type === "TSTypeOperator" && type.operator === "readonly") {
|
||||
return isArrayAnnotation(type.typeAnnotation);
|
||||
}
|
||||
return (
|
||||
type.type === "TSTypeReference" && type.typeName.type === "Identifier" &&
|
||||
(type.typeName.name === "Array" || type.typeName.name === "ReadonlyArray")
|
||||
);
|
||||
}
|
||||
|
||||
/** Recognize local array evidence; unknown receivers and iterator pipelines are deliberately excluded. */
|
||||
export function isKnownArrayExpression(
|
||||
sourceCode: SourceCode,
|
||||
node: ESTree.Node,
|
||||
visited = new Set<Variable>(),
|
||||
): boolean {
|
||||
node = unwrapArrayExpression(node);
|
||||
if (node.type === "ArrayExpression") return true;
|
||||
if (node.type === "CallExpression") {
|
||||
const method = arrayMethodTarget(node.callee);
|
||||
return (
|
||||
method !== null &&
|
||||
["map", "filter", "flatMap", "slice", "concat", "toSorted", "toReversed", "toSpliced"].includes(method.name) &&
|
||||
isKnownArrayExpression(sourceCode, method.object, visited)
|
||||
);
|
||||
}
|
||||
if (node.type !== "Identifier") return false;
|
||||
const variable = resolveArrayBinding(sourceCode, node);
|
||||
if (variable === null || visited.has(variable)) return false;
|
||||
visited.add(variable);
|
||||
if (variable.references.some(reference => reference.isWrite() && !reference.init)) return false;
|
||||
for (const identifier of variable.identifiers) {
|
||||
const annotation = identifier.typeAnnotation?.typeAnnotation;
|
||||
if (annotation !== undefined) return isArrayAnnotation(annotation);
|
||||
}
|
||||
for (const definition of variable.defs) {
|
||||
if (
|
||||
definition.type === "Variable" && definition.node.type === "VariableDeclarator" &&
|
||||
definition.node.id.type === "Identifier" && definition.node.init !== null &&
|
||||
definition.node.parent.type === "VariableDeclaration" && definition.node.parent.kind === "const"
|
||||
) {
|
||||
return isKnownArrayExpression(sourceCode, definition.node.init, visited);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
import {
|
||||
createTypeAliasEnvironment,
|
||||
hasVisibleTypeBinding,
|
||||
visibleTypeAlias,
|
||||
type TypeAliasEnvironment as LexicalTypeAliasEnvironment,
|
||||
} from "./type-alias-resolution.ts";
|
||||
|
||||
const BUILT_INS = new Set([
|
||||
"Record",
|
||||
"Readonly",
|
||||
"Partial",
|
||||
"Required",
|
||||
"Pick",
|
||||
"Omit",
|
||||
"PropertyKey",
|
||||
"NonNullable",
|
||||
]);
|
||||
const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]);
|
||||
|
||||
type TypeAliasEnvironment = ReadonlyMap<string, ESTree.TSType>;
|
||||
|
||||
type ResolvedType = {
|
||||
readonly type: ESTree.TSType;
|
||||
readonly substitutions: TypeAliasEnvironment;
|
||||
};
|
||||
|
||||
export type UnsafeDictionary = {
|
||||
readonly kind: "unsafe-dictionary";
|
||||
readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown";
|
||||
};
|
||||
|
||||
export type WideningTargetKind =
|
||||
| "anonymous object"
|
||||
| "generic container"
|
||||
| "object"
|
||||
| "open dictionary"
|
||||
| "unknown";
|
||||
|
||||
export type WideningTarget = {
|
||||
readonly kind: WideningTargetKind;
|
||||
};
|
||||
|
||||
export type TypeEnvironment = {
|
||||
readonly interfaces: ReadonlyMap<string, readonly ESTree.TSInterfaceDeclaration[]>;
|
||||
readonly typeAliases: LexicalTypeAliasEnvironment;
|
||||
};
|
||||
|
||||
function declaredStatement(statement: ESTree.Statement): ESTree.Node | null {
|
||||
return statement.type === "ExportNamedDeclaration" ||
|
||||
statement.type === "ExportDefaultDeclaration"
|
||||
? (statement.declaration ?? null)
|
||||
: statement;
|
||||
}
|
||||
|
||||
export function createTypeEnvironment(
|
||||
program: ESTree.Program,
|
||||
visitorKeys: Readonly<Record<string, readonly string[]>>,
|
||||
): TypeEnvironment {
|
||||
const interfaces = new Map<string, ESTree.TSInterfaceDeclaration[]>();
|
||||
|
||||
for (const statement of program.body) {
|
||||
const declaration = declaredStatement(statement);
|
||||
if (declaration?.type !== "TSInterfaceDeclaration") continue;
|
||||
const declarations = interfaces.get(declaration.id.name) ?? [];
|
||||
declarations.push(declaration);
|
||||
interfaces.set(declaration.id.name, declarations);
|
||||
}
|
||||
|
||||
return {
|
||||
interfaces,
|
||||
typeAliases: createTypeAliasEnvironment(program, visitorKeys),
|
||||
};
|
||||
}
|
||||
|
||||
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
||||
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
||||
}
|
||||
|
||||
function isBuiltIn(
|
||||
name: string,
|
||||
use: ESTree.Node,
|
||||
environment: TypeEnvironment,
|
||||
): boolean {
|
||||
return (
|
||||
BUILT_INS.has(name) &&
|
||||
!hasVisibleTypeBinding(name, use, environment.typeAliases)
|
||||
);
|
||||
}
|
||||
|
||||
function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean {
|
||||
const unwrapped = unwrapTransparentType(type);
|
||||
return (
|
||||
unwrapped.type === "TSTypeReference" &&
|
||||
typeReferenceName(unwrapped) === name &&
|
||||
(unwrapped.typeArguments === null ||
|
||||
unwrapped.typeArguments === undefined ||
|
||||
unwrapped.typeArguments.params.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType {
|
||||
let current = type;
|
||||
while (
|
||||
current.type === "TSParenthesizedType" ||
|
||||
(current.type === "TSTypeOperator" && current.operator === "readonly")
|
||||
) {
|
||||
current = current.typeAnnotation;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function isNeverType(type: ESTree.TSType): boolean {
|
||||
return unwrapTransparentType(type).type === "TSNeverKeyword";
|
||||
}
|
||||
|
||||
function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean {
|
||||
return (
|
||||
member.type === "TSPropertySignature" &&
|
||||
member.optional === true &&
|
||||
member.typeAnnotation !== null &&
|
||||
member.typeAnnotation !== undefined &&
|
||||
isNeverType(member.typeAnnotation.typeAnnotation)
|
||||
);
|
||||
}
|
||||
|
||||
function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean {
|
||||
return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember);
|
||||
}
|
||||
|
||||
function isEffectivelyEmptyInterface(
|
||||
declarations: readonly ESTree.TSInterfaceDeclaration[],
|
||||
): boolean {
|
||||
if (declarations.length !== 1) return false;
|
||||
const [type] = declarations;
|
||||
return (
|
||||
type !== undefined &&
|
||||
type.extends.length === 0 &&
|
||||
(type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember))
|
||||
);
|
||||
}
|
||||
|
||||
function resolvedSubstitutionArgument(
|
||||
type: ESTree.TSType,
|
||||
base: TypeAliasEnvironment,
|
||||
resolving: ReadonlySet<string> = new Set(),
|
||||
): ESTree.TSType {
|
||||
const unwrapped = unwrapTransparentType(type);
|
||||
if (unwrapped.type !== "TSTypeReference") return type;
|
||||
const name = typeReferenceName(unwrapped);
|
||||
if (name === null || resolving.has(name)) return type;
|
||||
const substitution = base.get(name);
|
||||
if (substitution === undefined) return type;
|
||||
const nextResolving = new Set(resolving);
|
||||
nextResolving.add(name);
|
||||
return resolvedSubstitutionArgument(substitution, base, nextResolving);
|
||||
}
|
||||
|
||||
function aliasSubstitution(
|
||||
alias: ESTree.TSTypeAliasDeclaration,
|
||||
type: ESTree.TSTypeReference,
|
||||
base: TypeAliasEnvironment,
|
||||
): TypeAliasEnvironment | null {
|
||||
const parameters = alias.typeParameters?.params ?? [];
|
||||
const arguments_ = type.typeArguments?.params ?? [];
|
||||
const next = new Map(base);
|
||||
for (const [index, parameter] of parameters.entries()) {
|
||||
const argument = arguments_[index] ?? parameter.default;
|
||||
if (argument === null || argument === undefined) return null;
|
||||
next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function unsafeDirectValue(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
substitutions: TypeAliasEnvironment,
|
||||
resolvingAliases: ReadonlySet<string>,
|
||||
): UnsafeDictionary["unsafeValue"] | null {
|
||||
const unwrapped = unwrapTransparentType(type);
|
||||
if (unwrapped.type === "TSUnknownKeyword") return "unknown";
|
||||
if (unwrapped.type === "TSAnyKeyword") return "any";
|
||||
if (unwrapped.type === "TSObjectKeyword") return "object";
|
||||
if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped))
|
||||
return "empty-object";
|
||||
if (unwrapped.type === "TSUnionType") {
|
||||
return unwrapped.types.some(
|
||||
(member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null,
|
||||
)
|
||||
? "union"
|
||||
: null;
|
||||
}
|
||||
if (unwrapped.type === "TSIntersectionType") {
|
||||
const unsafeMembers = unwrapped.types.map((member) =>
|
||||
unsafeDirectValue(member, environment, substitutions, resolvingAliases),
|
||||
);
|
||||
if (unsafeMembers.includes("any")) return "any";
|
||||
return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null)
|
||||
? unsafeMembers[0]
|
||||
: null;
|
||||
}
|
||||
if (unwrapped.type !== "TSTypeReference") return null;
|
||||
const name = typeReferenceName(unwrapped);
|
||||
if (name === null) return null;
|
||||
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
|
||||
const wrapped = unwrapped.typeArguments?.params[0];
|
||||
return wrapped === undefined
|
||||
? null
|
||||
: unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases);
|
||||
}
|
||||
const substitution = substitutions.get(name);
|
||||
if (substitution !== undefined) {
|
||||
return isUnappliedReferenceTo(substitution, name)
|
||||
? null
|
||||
: unsafeDirectValue(substitution, environment, substitutions, resolvingAliases);
|
||||
}
|
||||
const interfaceDeclarations = environment.interfaces.get(name);
|
||||
if (interfaceDeclarations !== undefined) {
|
||||
return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null;
|
||||
}
|
||||
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||
if (alias === null || resolvingAliases.has(name)) return null;
|
||||
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
||||
if (nextSubstitutions === null) return null;
|
||||
const nextResolving = new Set(resolvingAliases);
|
||||
nextResolving.add(name);
|
||||
return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
|
||||
}
|
||||
|
||||
function dictionaryValueTypes(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
substitutions: TypeAliasEnvironment,
|
||||
resolvingAliases: ReadonlySet<string>,
|
||||
): readonly ResolvedType[] {
|
||||
const unwrapped = unwrapTransparentType(type);
|
||||
|
||||
if (unwrapped.type === "TSTypeLiteral") {
|
||||
return unwrapped.members.flatMap((member): readonly ResolvedType[] =>
|
||||
member.type === "TSIndexSignature" && member.typeAnnotation !== null
|
||||
? [{ type: member.typeAnnotation.typeAnnotation, substitutions }]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
if (unwrapped.type === "TSMappedType") {
|
||||
return unwrapped.typeAnnotation === null
|
||||
? []
|
||||
: [{ type: unwrapped.typeAnnotation, substitutions }];
|
||||
}
|
||||
|
||||
if (unwrapped.type !== "TSTypeReference") return [];
|
||||
const name = typeReferenceName(unwrapped);
|
||||
if (name === null) return [];
|
||||
|
||||
const substitution = substitutions.get(name);
|
||||
if (substitution !== undefined) {
|
||||
return isUnappliedReferenceTo(substitution, name)
|
||||
? []
|
||||
: dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases);
|
||||
}
|
||||
|
||||
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
|
||||
const wrapped = unwrapped.typeArguments?.params[0];
|
||||
return wrapped === undefined
|
||||
? []
|
||||
: dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases);
|
||||
}
|
||||
|
||||
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
|
||||
const value = unwrapped.typeArguments?.params[1] ?? null;
|
||||
return value === null ? [] : [{ type: value, substitutions }];
|
||||
}
|
||||
|
||||
if (
|
||||
(name === "Pick" || name === "Omit") &&
|
||||
isBuiltIn(name, unwrapped, environment)
|
||||
) {
|
||||
const source = unwrapped.typeArguments?.params[0];
|
||||
return source === undefined
|
||||
? []
|
||||
: dictionaryValueTypes(source, environment, substitutions, resolvingAliases);
|
||||
}
|
||||
|
||||
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||
if (alias === null || resolvingAliases.has(name)) return [];
|
||||
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
||||
if (nextSubstitutions === null) return [];
|
||||
const nextResolving = new Set(resolvingAliases);
|
||||
nextResolving.add(name);
|
||||
return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
|
||||
}
|
||||
|
||||
export function classifyUnsafeDictionaryValue(
|
||||
valueType: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
): UnsafeDictionary | null {
|
||||
const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set());
|
||||
return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue };
|
||||
}
|
||||
|
||||
export function classifyUnsafeDictionary(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
): UnsafeDictionary | null {
|
||||
for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) {
|
||||
const unsafeValue = unsafeDirectValue(
|
||||
valueType.type,
|
||||
environment,
|
||||
valueType.substitutions,
|
||||
new Set(),
|
||||
);
|
||||
if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function classifyWideningTarget(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
): WideningTarget | null {
|
||||
const unwrapped = unwrapTransparentType(type);
|
||||
if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" };
|
||||
if (unwrapped.type === "TSObjectKeyword") return { kind: "object" };
|
||||
if (unwrapped.type === "TSTypeLiteral") {
|
||||
return unwrapped.members.some((member) => member.type === "TSIndexSignature")
|
||||
? { kind: "open dictionary" }
|
||||
: unwrapped.members.length > 0
|
||||
? { kind: "anonymous object" }
|
||||
: null;
|
||||
}
|
||||
if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" };
|
||||
if (unwrapped.type !== "TSTypeReference") return null;
|
||||
const name = typeReferenceName(unwrapped);
|
||||
if (name === null) return null;
|
||||
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
|
||||
const wrapped = unwrapped.typeArguments?.params[0];
|
||||
return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment);
|
||||
}
|
||||
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
|
||||
return hasBroadRecordKey(unwrapped, environment, new Map())
|
||||
? { kind: "open dictionary" }
|
||||
: null;
|
||||
}
|
||||
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||
if (alias === null) return null;
|
||||
if ((alias.typeParameters?.params.length ?? 0) > 0) {
|
||||
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
|
||||
const resolved =
|
||||
substitutions === null
|
||||
? null
|
||||
: classifyAliasBroadTarget(
|
||||
alias.typeAnnotation,
|
||||
environment,
|
||||
substitutions,
|
||||
new Set([name]),
|
||||
);
|
||||
return resolved?.kind === "open dictionary" ? { kind: "generic container" } : null;
|
||||
}
|
||||
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
|
||||
if (substitutions === null) return null;
|
||||
const resolved = classifyAliasBroadTarget(
|
||||
alias.typeAnnotation,
|
||||
environment,
|
||||
substitutions,
|
||||
new Set([name]),
|
||||
);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function hasBroadRecordKey(
|
||||
type: ESTree.TSTypeReference,
|
||||
environment: TypeEnvironment,
|
||||
substitutions: TypeAliasEnvironment,
|
||||
): boolean {
|
||||
const key = type.typeArguments?.params[0];
|
||||
return key === undefined || isBroadMappedKey(key, environment, substitutions);
|
||||
}
|
||||
|
||||
function isBroadMappedKey(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
substitutions: TypeAliasEnvironment,
|
||||
visitedAliases: ReadonlySet<string> = new Set(),
|
||||
): boolean {
|
||||
const unwrapped = unwrapTransparentType(type);
|
||||
if (
|
||||
unwrapped.type === "TSStringKeyword" ||
|
||||
unwrapped.type === "TSNumberKeyword" ||
|
||||
unwrapped.type === "TSSymbolKeyword"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (unwrapped.type === "TSUnionType") {
|
||||
return unwrapped.types.some((member) =>
|
||||
isBroadMappedKey(member, environment, substitutions, visitedAliases),
|
||||
);
|
||||
}
|
||||
if (unwrapped.type !== "TSTypeReference") return false;
|
||||
const name = typeReferenceName(unwrapped);
|
||||
if (name === null) return false;
|
||||
const substitution = substitutions.get(name);
|
||||
if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) {
|
||||
return isBroadMappedKey(substitution, environment, substitutions, visitedAliases);
|
||||
}
|
||||
if (name === "PropertyKey" && isBuiltIn(name, unwrapped, environment)) return true;
|
||||
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||
if (
|
||||
alias === null ||
|
||||
(alias.typeParameters?.params.length ?? 0) > 0 ||
|
||||
visitedAliases.has(name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const nextVisited = new Set(visitedAliases);
|
||||
nextVisited.add(name);
|
||||
return isBroadMappedKey(alias.typeAnnotation, environment, substitutions, nextVisited);
|
||||
}
|
||||
|
||||
function classifyAliasBroadTarget(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
substitutions: TypeAliasEnvironment,
|
||||
resolvingAliases: ReadonlySet<string>,
|
||||
): WideningTarget | null {
|
||||
const unwrapped = unwrapTransparentType(type);
|
||||
if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" };
|
||||
if (unwrapped.type === "TSObjectKeyword") return { kind: "object" };
|
||||
if (unwrapped.type === "TSTypeLiteral") {
|
||||
return unwrapped.members.some((member) => member.type === "TSIndexSignature")
|
||||
? { kind: "open dictionary" }
|
||||
: null;
|
||||
}
|
||||
if (unwrapped.type === "TSMappedType") {
|
||||
return isBroadMappedKey(unwrapped.constraint, environment, substitutions)
|
||||
? { kind: "open dictionary" }
|
||||
: null;
|
||||
}
|
||||
if (unwrapped.type !== "TSTypeReference") return null;
|
||||
const name = typeReferenceName(unwrapped);
|
||||
if (name === null) return null;
|
||||
const substitution = substitutions.get(name);
|
||||
if (substitution !== undefined) {
|
||||
return isUnappliedReferenceTo(substitution, name)
|
||||
? null
|
||||
: classifyAliasBroadTarget(
|
||||
substitution,
|
||||
environment,
|
||||
substitutions,
|
||||
resolvingAliases,
|
||||
);
|
||||
}
|
||||
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
|
||||
const wrapped = unwrapped.typeArguments?.params[0];
|
||||
return wrapped === undefined
|
||||
? null
|
||||
: classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases);
|
||||
}
|
||||
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
|
||||
return hasBroadRecordKey(unwrapped, environment, substitutions)
|
||||
? { kind: "open dictionary" }
|
||||
: null;
|
||||
}
|
||||
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||
if (alias === null || resolvingAliases.has(name)) return null;
|
||||
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
||||
if (nextSubstitutions === null) return null;
|
||||
const nextResolving = new Set(resolvingAliases);
|
||||
nextResolving.add(name);
|
||||
return classifyAliasBroadTarget(
|
||||
alias.typeAnnotation,
|
||||
environment,
|
||||
nextSubstitutions,
|
||||
nextResolving,
|
||||
);
|
||||
}
|
||||
|
||||
export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean {
|
||||
let current = expression;
|
||||
while (
|
||||
current.type === "ParenthesizedExpression" ||
|
||||
current.type === "TSAsExpression" ||
|
||||
current.type === "TSTypeAssertion" ||
|
||||
current.type === "TSNonNullExpression"
|
||||
) {
|
||||
current = current.expression;
|
||||
}
|
||||
return current.type === "ObjectExpression" && current.properties.length > 0;
|
||||
}
|
||||
|
||||
export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean {
|
||||
let current = expression;
|
||||
while (
|
||||
current.type === "ParenthesizedExpression" ||
|
||||
current.type === "TSAsExpression" ||
|
||||
current.type === "TSTypeAssertion" ||
|
||||
current.type === "TSNonNullExpression" ||
|
||||
current.type === "TSSatisfiesExpression"
|
||||
) {
|
||||
current = current.expression;
|
||||
}
|
||||
if (current.type === "ObjectExpression") return true;
|
||||
return (
|
||||
current.type === "ArrayExpression" ||
|
||||
current.type === "ArrowFunctionExpression" ||
|
||||
current.type === "ClassExpression" ||
|
||||
current.type === "FunctionExpression" ||
|
||||
current.type === "NewExpression" ||
|
||||
current.type === "Literal" ||
|
||||
current.type === "TemplateLiteral" ||
|
||||
current.type === "UnaryExpression"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ESTree, SourceCode } from "@oxlint/plugins";
|
||||
|
||||
export type FunctionParameter = ESTree.ParamPattern;
|
||||
|
||||
/** Return whether a type is or contains TypeScript's absorbing unknown top type. */
|
||||
export function containsUnknownType(type: ESTree.TSType): boolean {
|
||||
if (type.type === "TSUnknownKeyword") return true;
|
||||
if (type.type === "TSParenthesizedType") return containsUnknownType(type.typeAnnotation);
|
||||
return type.type === "TSUnionType" && type.types.some(containsUnknownType);
|
||||
}
|
||||
|
||||
/** Return the TypeScript annotation attached to a function parameter or its wrapped binding. */
|
||||
export function functionParameterTypeAnnotation(
|
||||
parameter: FunctionParameter,
|
||||
): ESTree.TSTypeAnnotation | null | undefined {
|
||||
if (parameter.type === "TSParameterProperty") {
|
||||
return functionParameterTypeAnnotation(parameter.parameter);
|
||||
}
|
||||
if (parameter.type === "RestElement") {
|
||||
return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.argument);
|
||||
}
|
||||
if (parameter.type === "AssignmentPattern") {
|
||||
return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.left);
|
||||
}
|
||||
return parameter.typeAnnotation;
|
||||
}
|
||||
|
||||
/** Return only a function parameter's local binding, excluding its annotation and default value. */
|
||||
export function functionParameterBindingName(
|
||||
parameter: FunctionParameter,
|
||||
sourceCode: SourceCode,
|
||||
): string {
|
||||
if (parameter.type === "TSParameterProperty") {
|
||||
return functionParameterBindingName(parameter.parameter, sourceCode);
|
||||
}
|
||||
if (parameter.type === "AssignmentPattern") {
|
||||
return functionParameterBindingName(parameter.left, sourceCode);
|
||||
}
|
||||
if (parameter.type === "RestElement") {
|
||||
return functionParameterBindingName(parameter.argument, sourceCode);
|
||||
}
|
||||
if (parameter.type === "Identifier") return parameter.name;
|
||||
|
||||
const sourceText = sourceCode.getText(parameter);
|
||||
const annotationStart = parameter.typeAnnotation?.start;
|
||||
return annotationStart === undefined
|
||||
? sourceText
|
||||
: sourceText.slice(0, annotationStart - parameter.start).trimEnd();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
type VisitorKeys = Readonly<Record<string, readonly string[]>>;
|
||||
|
||||
function isNode(value: unknown): value is ESTree.Node {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"type" in value &&
|
||||
typeof value.type === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function collectInferTypeParameterNames(
|
||||
node: ESTree.Node,
|
||||
visitorKeys: VisitorKeys,
|
||||
names: Set<string>,
|
||||
): void {
|
||||
if (node.type === "TSInferType") names.add(node.typeParameter.name.name);
|
||||
const record = node as unknown as Readonly<Record<string, unknown>>;
|
||||
for (const key of visitorKeys[node.type] ?? []) {
|
||||
const value = record[key];
|
||||
if (isNode(value)) {
|
||||
collectInferTypeParameterNames(value, visitorKeys, names);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(value)) continue;
|
||||
for (const child of value) {
|
||||
if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect type binders that are in scope at a node and can shadow module aliases. */
|
||||
export function lexicalTypeParameterNames(
|
||||
node: ESTree.Node,
|
||||
visitorKeys: VisitorKeys,
|
||||
): ReadonlySet<string> {
|
||||
const names = new Set<string>();
|
||||
let descendant: ESTree.Node = node;
|
||||
let current: ESTree.Node | null = node;
|
||||
while (current !== null && current.type !== "Program") {
|
||||
if ("typeParameters" in current) {
|
||||
for (const parameter of current.typeParameters?.params ?? []) {
|
||||
names.add(parameter.name.name);
|
||||
}
|
||||
}
|
||||
if (
|
||||
current.type === "TSMappedType" &&
|
||||
(descendant === current.nameType || descendant === current.typeAnnotation)
|
||||
) {
|
||||
names.add(current.key.name);
|
||||
}
|
||||
if (current.type === "TSConditionalType" && descendant === current.trueType) {
|
||||
collectInferTypeParameterNames(current.extendsType, visitorKeys, names);
|
||||
}
|
||||
descendant = current;
|
||||
current = current.parent;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { resolveVariable } from "./scope.ts";
|
||||
|
||||
import type { ESTree, SourceCode } from "@oxlint/plugins";
|
||||
|
||||
function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean {
|
||||
if (expression.type !== "Identifier" || expression.name !== "Reflect") return false;
|
||||
if (sourceCode.isGlobalReference(expression)) return true;
|
||||
const variable = resolveVariable(sourceCode, expression);
|
||||
return variable === null || variable.defs.length === 0;
|
||||
}
|
||||
|
||||
/** Reports whether a call target names one method on the global Reflect object. */
|
||||
export function isGlobalReflectMethodCall(
|
||||
sourceCode: SourceCode,
|
||||
callee: ESTree.Expression,
|
||||
methodName: string,
|
||||
): boolean {
|
||||
if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false;
|
||||
if (!isGlobalReflect(sourceCode, callee.object)) return false;
|
||||
const property = callee.property;
|
||||
return callee.computed
|
||||
? property.type === "Literal" && property.value === methodName
|
||||
: property.type === "Identifier" && property.name === methodName;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
|
||||
|
||||
/** Resolve an identifier to its binding by walking lexical scopes upward. */
|
||||
export function resolveVariable(
|
||||
sourceCode: SourceCode,
|
||||
identifier: ESTree.IdentifierReference,
|
||||
): Variable | null {
|
||||
let scope: Scope | null = sourceCode.getScope(identifier);
|
||||
while (scope !== null) {
|
||||
const variable = scope.set.get(identifier.name);
|
||||
if (variable !== undefined) return variable;
|
||||
scope = scope.upper;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
import { lexicalTypeParameterNames } from "./lexical-type-parameters.ts";
|
||||
|
||||
type VisitorKeys = Readonly<Record<string, readonly string[]>>;
|
||||
type TypeScope = ESTree.Node;
|
||||
|
||||
type TypeBinding = {
|
||||
readonly alias: ESTree.TSTypeAliasDeclaration | null;
|
||||
readonly name: string;
|
||||
readonly scope: TypeScope;
|
||||
};
|
||||
|
||||
type Substitution = {
|
||||
readonly substitutions: Substitutions;
|
||||
readonly type: ESTree.TSType;
|
||||
};
|
||||
|
||||
type Substitutions = ReadonlyMap<string, Substitution>;
|
||||
|
||||
export type TypeAliasEnvironment = {
|
||||
readonly aliases: readonly ESTree.TSTypeAliasDeclaration[];
|
||||
readonly bindingsByName: ReadonlyMap<string, readonly TypeBinding[]>;
|
||||
readonly visitorKeys: VisitorKeys;
|
||||
};
|
||||
|
||||
export type ResolvedTypeMatcher = (
|
||||
type: ESTree.TSType,
|
||||
matches: (child: ESTree.TSType) => boolean,
|
||||
) => boolean;
|
||||
|
||||
const environmentsByProgram = new WeakMap<ESTree.Program, TypeAliasEnvironment>();
|
||||
|
||||
function isNode(value: unknown): value is ESTree.Node {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"type" in value &&
|
||||
typeof value.type === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function enclosingTypeScope(node: ESTree.Node): TypeScope {
|
||||
let current: ESTree.Node | null = node.parent;
|
||||
while (current !== null) {
|
||||
if (
|
||||
current.type === "Program" ||
|
||||
current.type === "BlockStatement" ||
|
||||
current.type === "TSModuleBlock" ||
|
||||
current.type === "StaticBlock" ||
|
||||
current.type === "SwitchStatement"
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function declaredTypeBinding(node: ESTree.Node): {
|
||||
readonly alias: ESTree.TSTypeAliasDeclaration | null;
|
||||
readonly name: string;
|
||||
} | null {
|
||||
if (node.type === "TSTypeAliasDeclaration") {
|
||||
return { alias: node, name: node.id.name };
|
||||
}
|
||||
if (
|
||||
node.type === "TSInterfaceDeclaration" ||
|
||||
node.type === "TSEnumDeclaration" ||
|
||||
node.type === "ClassDeclaration" ||
|
||||
node.type === "ClassExpression"
|
||||
) {
|
||||
return node.id === null ? null : { alias: null, name: node.id.name };
|
||||
}
|
||||
if (
|
||||
node.type === "ImportSpecifier" ||
|
||||
node.type === "ImportDefaultSpecifier" ||
|
||||
node.type === "ImportNamespaceSpecifier"
|
||||
) {
|
||||
return { alias: null, name: node.local.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectTypeBindings(
|
||||
node: ESTree.Node,
|
||||
visitorKeys: VisitorKeys,
|
||||
bindingsByName: Map<string, TypeBinding[]>,
|
||||
aliases: ESTree.TSTypeAliasDeclaration[],
|
||||
): void {
|
||||
const declared = declaredTypeBinding(node);
|
||||
if (declared !== null) {
|
||||
const bindings = bindingsByName.get(declared.name) ?? [];
|
||||
bindings.push({ ...declared, scope: enclosingTypeScope(node) });
|
||||
bindingsByName.set(declared.name, bindings);
|
||||
if (declared.alias !== null) aliases.push(declared.alias);
|
||||
}
|
||||
|
||||
// SAFETY: Oxlint's visitor keys identify only ESTree child-node properties.
|
||||
const fields = node as unknown as Readonly<Record<string, unknown>>;
|
||||
for (const key of visitorKeys[node.type] ?? []) {
|
||||
const value = fields[key];
|
||||
if (isNode(value)) {
|
||||
collectTypeBindings(value, visitorKeys, bindingsByName, aliases);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(value)) continue;
|
||||
for (const child of value) {
|
||||
if (isNode(child)) {
|
||||
collectTypeBindings(child, visitorKeys, bindingsByName, aliases);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect every lexical type alias and competing type binding in a program. */
|
||||
export function createTypeAliasEnvironment(
|
||||
program: ESTree.Program,
|
||||
visitorKeys: VisitorKeys,
|
||||
): TypeAliasEnvironment {
|
||||
const cached = environmentsByProgram.get(program);
|
||||
if (cached !== undefined) return cached;
|
||||
const bindingsByName = new Map<string, TypeBinding[]>();
|
||||
const aliases: ESTree.TSTypeAliasDeclaration[] = [];
|
||||
collectTypeBindings(program, visitorKeys, bindingsByName, aliases);
|
||||
const environment = { aliases, bindingsByName, visitorKeys };
|
||||
environmentsByProgram.set(program, environment);
|
||||
return environment;
|
||||
}
|
||||
|
||||
function ancestorDistance(ancestor: ESTree.Node, node: ESTree.Node): number | null {
|
||||
let current: ESTree.Node | null = node;
|
||||
let distance = 0;
|
||||
while (current !== null) {
|
||||
if (current === ancestor) return distance;
|
||||
current = current.parent;
|
||||
distance += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestTypeBindings(
|
||||
name: string,
|
||||
use: ESTree.Node,
|
||||
environment: TypeAliasEnvironment,
|
||||
): readonly TypeBinding[] {
|
||||
const candidates = environment.bindingsByName.get(name) ?? [];
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
let nearest: TypeBinding[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const distance = ancestorDistance(candidate.scope, use);
|
||||
if (distance === null || distance > nearestDistance) continue;
|
||||
if (distance === nearestDistance) {
|
||||
nearest.push(candidate);
|
||||
continue;
|
||||
}
|
||||
nearestDistance = distance;
|
||||
nearest = [candidate];
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/** Resolve the nearest visible alias with this name, respecting lexical shadowing. */
|
||||
export function visibleTypeAlias(
|
||||
name: string,
|
||||
use: ESTree.Node,
|
||||
environment: TypeAliasEnvironment,
|
||||
): ESTree.TSTypeAliasDeclaration | null {
|
||||
if (lexicalTypeParameterNames(use, environment.visitorKeys).has(name)) return null;
|
||||
const bindings = nearestTypeBindings(name, use, environment);
|
||||
return bindings.length === 1 ? (bindings[0]?.alias ?? null) : null;
|
||||
}
|
||||
|
||||
/** Return whether a local declaration shadows a built-in type at this use. */
|
||||
export function hasVisibleTypeBinding(
|
||||
name: string,
|
||||
use: ESTree.Node,
|
||||
environment: TypeAliasEnvironment,
|
||||
): boolean {
|
||||
return (
|
||||
lexicalTypeParameterNames(use, environment.visitorKeys).has(name) ||
|
||||
nearestTypeBindings(name, use, environment).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
||||
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
||||
}
|
||||
|
||||
function aliasSubstitutions(
|
||||
alias: ESTree.TSTypeAliasDeclaration,
|
||||
reference: ESTree.TSTypeReference,
|
||||
base: Substitutions,
|
||||
): Substitutions | null {
|
||||
const parameters = alias.typeParameters?.params ?? [];
|
||||
const arguments_ = reference.typeArguments?.params ?? [];
|
||||
const next = new Map(base);
|
||||
for (const [index, parameter] of parameters.entries()) {
|
||||
const explicitArgument = arguments_[index];
|
||||
const argument = explicitArgument ?? parameter.default;
|
||||
if (argument === null || argument === undefined) return null;
|
||||
const argumentSubstitutions = explicitArgument === undefined ? next : base;
|
||||
next.set(parameter.name.name, {
|
||||
type: argument,
|
||||
substitutions: new Map(argumentSubstitutions),
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Match a type after resolving visible aliases and substituting their type parameters. */
|
||||
export function resolvedTypeMatches(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeAliasEnvironment,
|
||||
matcher: ResolvedTypeMatcher,
|
||||
): boolean {
|
||||
const evaluate = (
|
||||
current: ESTree.TSType,
|
||||
substitutions: Substitutions,
|
||||
resolvingAliases: ReadonlySet<ESTree.TSTypeAliasDeclaration>,
|
||||
): boolean => {
|
||||
if (current.type === "TSTypeReference") {
|
||||
const name = typeReferenceName(current);
|
||||
if (name !== null) {
|
||||
const substitution = substitutions.get(name);
|
||||
if (substitution !== undefined && !current.typeArguments?.params.length) {
|
||||
return evaluate(
|
||||
substitution.type,
|
||||
substitution.substitutions,
|
||||
resolvingAliases,
|
||||
);
|
||||
}
|
||||
const alias = visibleTypeAlias(name, current, environment);
|
||||
if (alias !== null && !resolvingAliases.has(alias)) {
|
||||
const nextSubstitutions = aliasSubstitutions(alias, current, substitutions);
|
||||
if (nextSubstitutions !== null) {
|
||||
const nextResolving = new Set(resolvingAliases);
|
||||
nextResolving.add(alias);
|
||||
return evaluate(alias.typeAnnotation, nextSubstitutions, nextResolving);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matcher(current, (child) =>
|
||||
evaluate(child, substitutions, resolvingAliases),
|
||||
);
|
||||
};
|
||||
|
||||
return evaluate(type, new Map(), new Set());
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright OpenJS Foundation and other contributors, <www.openjsf.org>
|
||||
Copyright (c) 2023-PRESENT ESLint Stylistic contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Vendored padding-line-between-statements
|
||||
|
||||
Source: [ESLint Stylistic](https://github.com/eslint-stylistic/eslint-stylistic), commit `435c3ea0fd26a5fef9042c4b36b6e165fbbf8d08`.
|
||||
|
||||
Copied files:
|
||||
|
||||
- `packages/eslint-plugin/rules/padding-line-between-statements/padding-line-between-statements.ts`
|
||||
- `packages/eslint-plugin/rules/padding-line-between-statements/types.d.ts` → `padding-line-options.d.ts`
|
||||
- Root `LICENSE`, retained verbatim. Both OpenJS Foundation and ESLint Stylistic notices apply.
|
||||
|
||||
The rule is MIT-licensed. Keep `LICENSE` with every redistributed copy, including skill assets. No Stylistic, ESLint, TypeScript-ESLint, or additional parser runtime dependency is required.
|
||||
|
||||
## Local adaptations
|
||||
|
||||
- Replace upstream type aliases with Oxlint's ESTree, context, token/comment, and rule types. Upstream's token type includes comments; Oxlint exposes those separately.
|
||||
- Replace `AST_NODE_TYPES` enum members with identical string literals.
|
||||
- Guard indexed reads for consuming repositories with `noUncheckedIndexedAccess`. Impossible missing AST/configuration entries raise explicit invariant errors rather than introducing new non-null assertions.
|
||||
- Replace the repository-specific `createRule` factory with `createPaddingLineRule(options, statementTypes?)`. The anti-slop wrapper supplies typed options directly; it exposes no user configuration options. The optional second argument registers caller-defined statement types, consulted before the upstream table, so a policy can match syntax the upstream matchers do not distinguish (exported bindings, comment ownership) without editing the vendored tables.
|
||||
- Implement the small required AST helper surface in `padding-line-ast.ts` using Oxlint's public source-code/token API. `isParenthesized` only needs the one-pair check used to exclude parenthesized directive strings, not the upstream general-purpose overloads.
|
||||
- Retain the upstream statement matchers, scope tracking, comment-aware insertion/removal, selector support, and diagnostic text. Upstream naming and non-null assumptions remain localized here to keep future diffs reviewable; the file is not a model for new application code.
|
||||
|
||||
The opinionated policy lives outside this directory in `../../rules/require-readable-spacing.ts`. It adds spacing without collapsing existing blank lines. Short local bindings, consecutive imports, and adjacent overload signatures/implementation remain grouped. Spacing is syntactic, not an inference of business-logic boundaries.
|
||||
|
||||
## Updating and verification
|
||||
|
||||
Fetch an explicit upstream revision, compare the original rule and types against this revision, and port relevant fixes while retaining the adapters above. Update this record and preserve the license. Run `pnpm check` and `pnpm sync:skill-assets` as required by repository guidance.
|
||||
|
||||
Focused Oxlint RuleTester cases live in `../../rules/require-readable-spacing.test.ts`; they test exact fixes, JSDoc/trailing comments, same-line statements, semicolon-free code, TypeScript exports/overloads, Effect-style generators, and upstream removal behavior. `../../rules/require-readable-spacing-cli.test.ts` verifies the exported plugin through the native Oxlint CLI on multiple files, including rejection, autofix, and repeated-fix stability. The complete upstream JS/TS test suites have not been ported; this is focused compatibility evidence, not a claim of full upstream conformance.
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
// Local replacements for the upstream helper imports. See UPSTREAM.md.
|
||||
import type { ESTree, SourceCode, Token as SyntaxToken, Comment, Location } from "@oxlint/plugins";
|
||||
|
||||
type Token = SyntaxToken | Comment;
|
||||
|
||||
/** Line terminators recognized by the upstream padding matcher. */
|
||||
export const LINEBREAKS = new Set(["\r\n", "\r", "\n", "\u2028", "\u2029"]);
|
||||
|
||||
/** Test a closing brace without treating comment text as punctuation. */
|
||||
export const isClosingBraceToken = (token: Token): boolean =>
|
||||
token.type === "Punctuator" && token.value === "}";
|
||||
|
||||
/** Test a semicolon without treating comment text as punctuation. */
|
||||
export const isSemicolonToken = (token: Token): boolean =>
|
||||
token.type === "Punctuator" && token.value === ";";
|
||||
|
||||
/** Filter the optional final semicolon when identifying block-like statements. */
|
||||
export const isNotSemicolonToken = (token: Token): boolean => !isSemicolonToken(token);
|
||||
|
||||
/** Compare token/node boundaries, including attached comments. */
|
||||
export const isTokenOnSameLine = (left: { loc: Location }, right: { loc: Location }): boolean =>
|
||||
left.loc.end.line === right.loc.start.line;
|
||||
|
||||
/** Recognize declarations and expressions used by the upstream IIFE matcher. */
|
||||
export const isFunction = (node: ESTree.Node): boolean =>
|
||||
node.type === "FunctionDeclaration" ||
|
||||
node.type === "FunctionExpression" ||
|
||||
node.type === "ArrowFunctionExpression";
|
||||
|
||||
/** Preserve the upstream multiline statement heuristic. */
|
||||
export const isSingleLine = (node: ESTree.Node): boolean =>
|
||||
node.loc.start.line === node.loc.end.line;
|
||||
|
||||
/** Unwrap optional chaining before checking IIFE syntax. */
|
||||
export const skipChainExpression = (node: ESTree.Node): ESTree.Node =>
|
||||
node.type === "ChainExpression" ? node.expression : node;
|
||||
|
||||
/** Only a program or function-body expression can begin a directive prologue. */
|
||||
export const isTopLevelExpressionStatement = (
|
||||
node: ESTree.Node,
|
||||
): node is ESTree.ExpressionStatement =>
|
||||
node.type === "ExpressionStatement" &&
|
||||
(node.parent.type === "Program" ||
|
||||
(node.parent.type === "BlockStatement" && isFunction(node.parent.parent)));
|
||||
|
||||
/** A single wrapping pair suffices to exclude a string from directive syntax. */
|
||||
export function isParenthesized(node: ESTree.Node, sourceCode: SourceCode): boolean {
|
||||
const before = sourceCode.getTokenBefore(node);
|
||||
const after = sourceCode.getTokenAfter(node);
|
||||
return before?.value === "(" && after?.value === ")";
|
||||
}
|
||||
+929
@@ -0,0 +1,929 @@
|
||||
// Vendored from ESLint Stylistic; see UPSTREAM.md and LICENSE in this directory.
|
||||
import type { ESTree, Context as RuleContext, SourceCode, Token as SyntaxToken, Comment, CreateRule, Location } from '@oxlint/plugins'
|
||||
type ASTNode = ESTree.Node
|
||||
type Token = SyntaxToken | Comment
|
||||
import type {
|
||||
PaddingType,
|
||||
SelectorOption,
|
||||
StatementMatcher,
|
||||
} from './padding-line-options.d.ts'
|
||||
import {
|
||||
isClosingBraceToken,
|
||||
isFunction,
|
||||
isNotSemicolonToken,
|
||||
isParenthesized,
|
||||
isSemicolonToken,
|
||||
isSingleLine,
|
||||
isTokenOnSameLine,
|
||||
isTopLevelExpressionStatement,
|
||||
LINEBREAKS,
|
||||
skipChainExpression,
|
||||
} from './padding-line-ast.ts'
|
||||
|
||||
const CJS_EXPORT = /^(?:module\s*\.\s*)?exports(?:\s*\.|\s*\[|$)/u
|
||||
const CJS_IMPORT = /^require\(/u
|
||||
|
||||
/**
|
||||
* This rule is a replica of padding-line-between-statements.
|
||||
*
|
||||
* Ideally we would want to extend the rule support typescript specific support.
|
||||
* But since not all the state is exposed by the eslint and eslint has frozen stylistic rules,
|
||||
* (see - https://eslint.org/blog/2020/05/changes-to-rules-policies for details.)
|
||||
* we are forced to re-implement the rule here.
|
||||
*
|
||||
* We have tried to keep the implementation as close as possible to the eslint implementation, to make
|
||||
* patching easier for future contributors.
|
||||
*
|
||||
* Reference rule - https://github.com/eslint/eslint/blob/main/lib/rules/padding-line-between-statements.js
|
||||
*/
|
||||
|
||||
type NodeTest = (
|
||||
node: ASTNode,
|
||||
sourceCode: SourceCode,
|
||||
) => boolean
|
||||
|
||||
export interface NodeTestObject {
|
||||
test: NodeTest
|
||||
}
|
||||
|
||||
/**
|
||||
* A policy entry may name a caller-defined statement type (registered through
|
||||
* `createPaddingLineRule`) anywhere the upstream statement types are accepted.
|
||||
*/
|
||||
export type PolicyStatementOption<Extra extends string = never> =
|
||||
| StatementMatcher
|
||||
| Extra
|
||||
| [StatementMatcher | Extra, ...(StatementMatcher | Extra)[]]
|
||||
|
||||
export type PolicyOptions<Extra extends string = never> = {
|
||||
blankLine: PaddingType
|
||||
prev: PolicyStatementOption<Extra>
|
||||
next: PolicyStatementOption<Extra>
|
||||
}[]
|
||||
|
||||
const LT = `[${Array.from(LINEBREAKS).join('')}]`
|
||||
const PADDING_LINE_SEQUENCE = new RegExp(
|
||||
String.raw`^(\s*?${LT})\s*${LT}(\s*;?)$`,
|
||||
'u',
|
||||
)
|
||||
|
||||
function isSelectorOption(option: PolicyStatementOption<string>): option is SelectorOption {
|
||||
return typeof option === 'object' && !Array.isArray(option)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates tester which check if a node starts with specific keyword with the
|
||||
* appropriate AST_NODE_TYPES.
|
||||
* @param keyword The keyword to test.
|
||||
* @returns the created tester.
|
||||
* @private
|
||||
*/
|
||||
function newKeywordTester(
|
||||
type: string | string[],
|
||||
keyword: string,
|
||||
): NodeTestObject {
|
||||
return {
|
||||
test(node, sourceCode): boolean {
|
||||
const isSameKeyword = sourceCode.getFirstToken(node)?.value === keyword
|
||||
const isSameType = Array.isArray(type)
|
||||
? type.includes(node.type)
|
||||
: type === node.type
|
||||
|
||||
return isSameKeyword && isSameType
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates tester which check if a node is specific type.
|
||||
* @param type The node type to test.
|
||||
* @returns the created tester.
|
||||
* @private
|
||||
*/
|
||||
function newNodeTypeTester(type: string): NodeTestObject {
|
||||
return {
|
||||
test: (node): boolean => node.type === type,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the given node is an expression statement of IIFE.
|
||||
* @param node The node to check.
|
||||
* @returns `true` if the node is an expression statement of IIFE.
|
||||
* @private
|
||||
*/
|
||||
function isIIFEStatement(node: ASTNode): boolean {
|
||||
if (node.type === 'ExpressionStatement') {
|
||||
let expression = skipChainExpression(node.expression)
|
||||
if (expression.type === 'UnaryExpression')
|
||||
expression = skipChainExpression(expression.argument)
|
||||
|
||||
if (expression.type === 'CallExpression') {
|
||||
let node: ASTNode = expression.callee
|
||||
while (node.type === 'SequenceExpression') {
|
||||
const lastExpression = node.expressions.at(-1)
|
||||
if (lastExpression === undefined)
|
||||
throw new Error('Padding rule invariant: sequence expression is empty')
|
||||
node = lastExpression
|
||||
}
|
||||
|
||||
return isFunction(node)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the given node is a CommonJS require statement
|
||||
* @param node The node to check.
|
||||
* @returns `true` if the node is a CommonJS require statement.
|
||||
* @private
|
||||
*/
|
||||
function isCJSRequire(node: ASTNode): boolean {
|
||||
if (node.type === 'VariableDeclaration') {
|
||||
const declaration = node.declarations[0]
|
||||
if (declaration?.init) {
|
||||
let call = declaration?.init
|
||||
while (call.type === 'MemberExpression')
|
||||
call = call.object
|
||||
|
||||
if (
|
||||
call.type === 'CallExpression'
|
||||
&& call.callee.type === 'Identifier'
|
||||
) {
|
||||
return call.callee.name === 'require'
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given node is a block-like statement.
|
||||
* This checks the last token of the node is the closing brace of a block.
|
||||
* @param sourceCode The source code to get tokens.
|
||||
* @param node The node to check.
|
||||
* @returns `true` if the node is a block-like statement.
|
||||
* @private
|
||||
*/
|
||||
function isBlockLikeStatement(
|
||||
node: ASTNode,
|
||||
sourceCode: SourceCode,
|
||||
): boolean {
|
||||
// do-while with a block is a block-like statement.
|
||||
if (
|
||||
node.type === 'DoWhileStatement'
|
||||
&& node.body.type === 'BlockStatement'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* IIFE is a block-like statement specially from
|
||||
* JSCS#disallowPaddingNewLinesAfterBlocks.
|
||||
*/
|
||||
if (isIIFEStatement(node))
|
||||
return true
|
||||
|
||||
// Checks the last token is a closing brace of blocks.
|
||||
const lastToken = sourceCode.getLastToken(node, isNotSemicolonToken)
|
||||
const belongingNode
|
||||
= lastToken && isClosingBraceToken(lastToken)
|
||||
? sourceCode.getNodeByRangeIndex(lastToken.range[0])
|
||||
: null
|
||||
|
||||
return (
|
||||
!!belongingNode
|
||||
&& (belongingNode.type === 'BlockStatement'
|
||||
|| belongingNode.type === 'SwitchStatement')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given node is a directive or not.
|
||||
* @param node The node to check.
|
||||
* @param sourceCode The source code object to get tokens.
|
||||
* @returns `true` if the node is a directive.
|
||||
*/
|
||||
function isDirective(
|
||||
node: ASTNode,
|
||||
sourceCode: SourceCode,
|
||||
): boolean {
|
||||
return (
|
||||
isTopLevelExpressionStatement(node)
|
||||
&& node.expression.type === 'Literal'
|
||||
&& typeof node.expression.value === 'string'
|
||||
&& !isParenthesized(node.expression, sourceCode)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given node is a part of directive prologue or not.
|
||||
* @param node The node to check.
|
||||
* @param sourceCode The source code object to get tokens.
|
||||
* @returns `true` if the node is a part of directive prologue.
|
||||
*/
|
||||
function isDirectivePrologue(
|
||||
node: ASTNode,
|
||||
sourceCode: SourceCode,
|
||||
): boolean {
|
||||
if (
|
||||
isDirective(node, sourceCode)
|
||||
&& node.parent
|
||||
&& 'body' in node.parent
|
||||
&& Array.isArray(node.parent.body)
|
||||
) {
|
||||
for (const sibling of node.parent.body) {
|
||||
if (sibling === node)
|
||||
break
|
||||
|
||||
if (!isDirective(sibling, sourceCode))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the given node is a CommonJS export statement
|
||||
* @param node The node to check.
|
||||
* @returns `true` if the node is a CommonJS export statement.
|
||||
* @private
|
||||
*/
|
||||
function isCJSExport(node: ASTNode): boolean {
|
||||
if (node.type === 'ExpressionStatement') {
|
||||
const expression = node.expression
|
||||
if (expression.type === 'AssignmentExpression') {
|
||||
let left = expression.left
|
||||
if (left.type === 'MemberExpression') {
|
||||
while (left.object.type === 'MemberExpression')
|
||||
left = left.object
|
||||
|
||||
return (
|
||||
left.object.type === 'Identifier'
|
||||
&& (left.object.name === 'exports'
|
||||
|| (left.object.name === 'module'
|
||||
&& left.property.type === 'Identifier'
|
||||
&& left.property.name === 'exports'))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given node is an expression
|
||||
* @param node The node to check.
|
||||
* @param sourceCode The source code object to get tokens.
|
||||
* @returns `true` if the node is an expression
|
||||
*/
|
||||
function isExpression(
|
||||
node: ASTNode,
|
||||
sourceCode: SourceCode,
|
||||
): boolean {
|
||||
return (
|
||||
node.type === 'ExpressionStatement'
|
||||
&& !isDirectivePrologue(node, sourceCode)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the actual last token.
|
||||
*
|
||||
* If a semicolon is semicolon-less style's semicolon, this ignores it.
|
||||
* For example:
|
||||
*
|
||||
* foo()
|
||||
* ;[1, 2, 3].forEach(bar)
|
||||
* @param sourceCode The source code to get tokens.
|
||||
* @param node The node to get.
|
||||
* @returns The actual last token.
|
||||
* @private
|
||||
*/
|
||||
function getActualLastToken(
|
||||
node: ASTNode,
|
||||
sourceCode: SourceCode,
|
||||
): Token | null {
|
||||
const semiToken = sourceCode.getLastToken(node)!
|
||||
const prevToken = sourceCode.getTokenBefore(semiToken)
|
||||
const nextToken = sourceCode.getTokenAfter(semiToken)
|
||||
const isSemicolonLessStyle
|
||||
= prevToken
|
||||
&& nextToken
|
||||
&& prevToken.range[0] >= node.range[0]
|
||||
&& isSemicolonToken(semiToken)
|
||||
&& !isTokenOnSameLine(prevToken, semiToken)
|
||||
&& isTokenOnSameLine(semiToken, nextToken)
|
||||
|
||||
return isSemicolonLessStyle ? prevToken : semiToken
|
||||
}
|
||||
|
||||
/**
|
||||
* This returns the concatenation of the first 2 captured strings.
|
||||
* @param _ Unused. Whole matched string.
|
||||
* @param trailingSpaces The trailing spaces of the first line.
|
||||
* @param indentSpaces The indentation spaces of the last line.
|
||||
* @returns The concatenation of trailingSpaces and indentSpaces.
|
||||
* @private
|
||||
*/
|
||||
function replacerToRemovePaddingLines(
|
||||
_: string,
|
||||
trailingSpaces: string,
|
||||
indentSpaces: string,
|
||||
): string {
|
||||
return trailingSpaces + indentSpaces
|
||||
}
|
||||
|
||||
function getReportLoc(node: ASTNode, sourceCode: SourceCode): Location {
|
||||
if (isSingleLine(node))
|
||||
return node.loc
|
||||
|
||||
const line = node.loc.start.line
|
||||
const sourceLine = sourceCode.lines[line - 1]
|
||||
if (sourceLine === undefined)
|
||||
throw new Error('Padding rule invariant: statement source line is missing')
|
||||
|
||||
return {
|
||||
start: node.loc.start,
|
||||
end: {
|
||||
line,
|
||||
column: sourceLine.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and report statements for `any` configuration.
|
||||
* It does nothing.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function verifyForAny(): void {
|
||||
// Empty
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and report statements for `never` configuration.
|
||||
* This autofix removes blank lines between the given 2 statements.
|
||||
* However, if comments exist between 2 blank lines, it does not remove those
|
||||
* blank lines automatically.
|
||||
* @param context The rule context to report.
|
||||
* @param _ Unused. The previous node to check.
|
||||
* @param nextNode The next node to check.
|
||||
* @param paddingLines The array of token pairs that blank
|
||||
* lines exist between the pair.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function verifyForNever(
|
||||
context: RuleContext,
|
||||
_: ASTNode,
|
||||
nextNode: ASTNode,
|
||||
paddingLines: [Token, Token][],
|
||||
): void {
|
||||
if (paddingLines.length === 0)
|
||||
return
|
||||
|
||||
context.report({
|
||||
node: nextNode,
|
||||
messageId: 'unexpectedBlankLine',
|
||||
loc: getReportLoc(nextNode, context.sourceCode),
|
||||
fix(fixer) {
|
||||
if (paddingLines.length >= 2)
|
||||
return null
|
||||
|
||||
const paddingPair = paddingLines[0]
|
||||
if (paddingPair === undefined)
|
||||
throw new Error('Padding rule invariant: reported padding pair is missing')
|
||||
const [prevToken, nextToken] = paddingPair
|
||||
const start = prevToken.range[1]
|
||||
const end = nextToken.range[0]
|
||||
const text = context
|
||||
.sourceCode
|
||||
.text
|
||||
.slice(start, end)
|
||||
.replace(PADDING_LINE_SEQUENCE, replacerToRemovePaddingLines)
|
||||
|
||||
return fixer.replaceTextRange([start, end], text)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and report statements for `always` configuration.
|
||||
* This autofix inserts a blank line between the given 2 statements.
|
||||
* If the `prevNode` has trailing comments, it inserts a blank line after the
|
||||
* trailing comments.
|
||||
* @param context The rule context to report.
|
||||
* @param prevNode The previous node to check.
|
||||
* @param nextNode The next node to check.
|
||||
* @param paddingLines The array of token pairs that blank
|
||||
* lines exist between the pair.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function verifyForAlways(
|
||||
context: RuleContext,
|
||||
prevNode: ASTNode,
|
||||
nextNode: ASTNode,
|
||||
paddingLines: [Token, Token][],
|
||||
): void {
|
||||
if (paddingLines.length > 0)
|
||||
return
|
||||
|
||||
context.report({
|
||||
node: nextNode,
|
||||
messageId: 'expectedBlankLine',
|
||||
loc: getReportLoc(nextNode, context.sourceCode),
|
||||
fix(fixer) {
|
||||
const sourceCode = context.sourceCode
|
||||
let prevToken = getActualLastToken(prevNode, sourceCode)!
|
||||
const nextToken
|
||||
= sourceCode.getFirstTokenBetween(prevToken, nextNode, {
|
||||
includeComments: true,
|
||||
|
||||
/**
|
||||
* Skip the trailing comments of the previous node.
|
||||
* This inserts a blank line after the last trailing comment.
|
||||
*
|
||||
* For example:
|
||||
*
|
||||
* foo(); // trailing comment.
|
||||
* // comment.
|
||||
* bar();
|
||||
*
|
||||
* Get fixed to:
|
||||
*
|
||||
* foo(); // trailing comment.
|
||||
*
|
||||
* // comment.
|
||||
* bar();
|
||||
* @param token The token to check.
|
||||
* @returns `true` if the token is not a trailing comment.
|
||||
* @private
|
||||
*/
|
||||
filter(token) {
|
||||
if (isTokenOnSameLine(prevToken, token)) {
|
||||
prevToken = token
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
})! || nextNode
|
||||
const insertText = isTokenOnSameLine(prevToken, nextToken)
|
||||
? '\n\n'
|
||||
: '\n'
|
||||
|
||||
return fixer.insertTextAfter(prevToken, insertText)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Types of blank lines.
|
||||
* `any`, `never`, and `always` are defined.
|
||||
* Those have `verify` method to check and report statements.
|
||||
* @private
|
||||
*/
|
||||
const PaddingTypes = {
|
||||
any: { verify: verifyForAny },
|
||||
never: { verify: verifyForNever },
|
||||
always: { verify: verifyForAlways },
|
||||
}
|
||||
|
||||
const MaybeMultilineStatementType: Record<string, NodeTestObject> = {
|
||||
'block-like': { test: isBlockLikeStatement },
|
||||
'expression': { test: isExpression },
|
||||
'return': newKeywordTester('ReturnStatement', 'return'),
|
||||
'export': newKeywordTester(
|
||||
[
|
||||
'ExportAllDeclaration',
|
||||
'ExportDefaultDeclaration',
|
||||
'ExportNamedDeclaration',
|
||||
],
|
||||
'export',
|
||||
),
|
||||
'var': newKeywordTester('VariableDeclaration', 'var'),
|
||||
'let': newKeywordTester('VariableDeclaration', 'let'),
|
||||
'const': newKeywordTester('VariableDeclaration', 'const'),
|
||||
'using': {
|
||||
test: node => node.type === 'VariableDeclaration'
|
||||
&& (node.kind === 'using' || node.kind === 'await using'),
|
||||
},
|
||||
'type': newKeywordTester('TSTypeAliasDeclaration', 'type'),
|
||||
}
|
||||
|
||||
/**
|
||||
* Types of statements.
|
||||
* Those have `test` method to check it matches to the given statement.
|
||||
* @private
|
||||
*/
|
||||
const StatementTypes: Record<string, NodeTestObject> = {
|
||||
'*': { test: (): boolean => true },
|
||||
'exports': { test: isCJSExport },
|
||||
'require': { test: isCJSRequire },
|
||||
'directive': { test: isDirectivePrologue },
|
||||
'iife': { test: isIIFEStatement },
|
||||
|
||||
'block': newNodeTypeTester('BlockStatement'),
|
||||
'empty': newNodeTypeTester('EmptyStatement'),
|
||||
'function': newNodeTypeTester('FunctionDeclaration'),
|
||||
'ts-method': newNodeTypeTester('TSMethodSignature'),
|
||||
|
||||
'break': newKeywordTester('BreakStatement', 'break'),
|
||||
'case': newKeywordTester('SwitchCase', 'case'),
|
||||
'class': newKeywordTester('ClassDeclaration', 'class'),
|
||||
'continue': newKeywordTester('ContinueStatement', 'continue'),
|
||||
'debugger': newKeywordTester('DebuggerStatement', 'debugger'),
|
||||
'default': newKeywordTester(
|
||||
['SwitchCase', 'ExportDefaultDeclaration'],
|
||||
'default',
|
||||
),
|
||||
'do': newKeywordTester('DoWhileStatement', 'do'),
|
||||
'for': newKeywordTester(
|
||||
[
|
||||
'ForStatement',
|
||||
'ForInStatement',
|
||||
'ForOfStatement',
|
||||
],
|
||||
'for',
|
||||
),
|
||||
'if': newKeywordTester('IfStatement', 'if'),
|
||||
'import': newKeywordTester('ImportDeclaration', 'import'),
|
||||
'switch': newKeywordTester('SwitchStatement', 'switch'),
|
||||
'throw': newKeywordTester('ThrowStatement', 'throw'),
|
||||
'try': newKeywordTester('TryStatement', 'try'),
|
||||
'while': newKeywordTester(
|
||||
['WhileStatement', 'DoWhileStatement'],
|
||||
'while',
|
||||
),
|
||||
'with': newKeywordTester('WithStatement', 'with'),
|
||||
|
||||
'cjs-export': {
|
||||
test: (node, sourceCode) => node.type === 'ExpressionStatement'
|
||||
&& node.expression.type === 'AssignmentExpression'
|
||||
&& CJS_EXPORT.test(sourceCode.getText(node.expression.left)),
|
||||
},
|
||||
'cjs-import': {
|
||||
test: (node, sourceCode) => node.type === 'VariableDeclaration'
|
||||
&& node.declarations.length > 0
|
||||
&& node.declarations[0]?.init != null
|
||||
&& CJS_IMPORT.test(sourceCode.getText(node.declarations[0].init)),
|
||||
},
|
||||
|
||||
'enum': newKeywordTester(
|
||||
'TSEnumDeclaration',
|
||||
'enum',
|
||||
),
|
||||
'interface': newKeywordTester(
|
||||
'TSInterfaceDeclaration',
|
||||
'interface',
|
||||
),
|
||||
'function-overload': newNodeTypeTester('TSDeclareFunction'),
|
||||
...Object.fromEntries(
|
||||
Object.entries(MaybeMultilineStatementType)
|
||||
.flatMap(([key, value]) => [
|
||||
[key, value],
|
||||
[
|
||||
`singleline-${key}`,
|
||||
{
|
||||
...value,
|
||||
test: (node, sourceCode) => value.test(node, sourceCode) && isSingleLine(node),
|
||||
},
|
||||
],
|
||||
[
|
||||
`multiline-${key}`,
|
||||
{
|
||||
...value,
|
||||
test: (node, sourceCode) => value.test(node, sourceCode) && !isSingleLine(node),
|
||||
},
|
||||
],
|
||||
]),
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the vendored padding rule with caller-owned, typed policy options.
|
||||
* `statementTypes` registers caller-defined statement types, looked up before
|
||||
* the upstream table, for syntax the upstream matchers do not distinguish.
|
||||
*/
|
||||
export default function createPaddingLineRule<Extra extends string = never>(
|
||||
options: PolicyOptions<NoInfer<Extra>>,
|
||||
statementTypes?: Record<Extra, NodeTestObject>,
|
||||
): CreateRule {
|
||||
const customStatementTypes: Partial<Record<string, NodeTestObject>> = statementTypes ?? {}
|
||||
return {
|
||||
meta: {
|
||||
type: 'layout',
|
||||
docs: {
|
||||
description: 'Require or disallow padding lines between statements',
|
||||
},
|
||||
fixable: 'whitespace',
|
||||
hasSuggestions: false,
|
||||
// This is intentionally an array schema as you can pass 0..n config objects
|
||||
schema: {
|
||||
$defs: {
|
||||
paddingType: {
|
||||
type: 'string',
|
||||
enum: Object.keys(PaddingTypes),
|
||||
},
|
||||
statementType: {
|
||||
type: 'string',
|
||||
enum: Object.keys(StatementTypes),
|
||||
},
|
||||
selectorOption: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
selector: {
|
||||
type: 'string',
|
||||
},
|
||||
lineMode: {
|
||||
type: 'string',
|
||||
enum: ['any', 'singleline', 'multiline'],
|
||||
},
|
||||
},
|
||||
required: ['selector'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
statementMatcher: {
|
||||
anyOf: [
|
||||
{ $ref: '#/$defs/statementType' },
|
||||
{ $ref: '#/$defs/selectorOption' },
|
||||
],
|
||||
},
|
||||
statementOption: {
|
||||
anyOf: [
|
||||
{ $ref: '#/$defs/statementMatcher' },
|
||||
{
|
||||
type: 'array',
|
||||
items: { $ref: '#/$defs/statementMatcher' },
|
||||
minItems: 1,
|
||||
uniqueItems: true,
|
||||
additionalItems: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
type: 'array',
|
||||
additionalItems: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
blankLine: { $ref: '#/$defs/paddingType' },
|
||||
prev: { $ref: '#/$defs/statementOption' },
|
||||
next: { $ref: '#/$defs/statementOption' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
required: ['blankLine', 'prev', 'next'],
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
unexpectedBlankLine: 'Unexpected blank line before this statement.',
|
||||
expectedBlankLine: 'Expected blank line before this statement.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode
|
||||
|
||||
const selectorMatchedNodes = new Map<string, Set<ASTNode>>()
|
||||
const pendingPairs: { prevNode: ASTNode, nextNode: ASTNode }[] = []
|
||||
|
||||
function collectSelectorOption(option: PolicyStatementOption<Extra>): void {
|
||||
if (Array.isArray(option)) {
|
||||
for (const item of option)
|
||||
collectSelectorOption(item)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSelectorOption(option))
|
||||
return
|
||||
|
||||
selectorMatchedNodes.set(option.selector, new Set())
|
||||
}
|
||||
|
||||
for (const configure of options) {
|
||||
collectSelectorOption(configure.prev)
|
||||
collectSelectorOption(configure.next)
|
||||
}
|
||||
|
||||
type Scope = {
|
||||
upper: Scope
|
||||
prevNode: ASTNode | null
|
||||
} | null
|
||||
|
||||
let scopeInfo: Scope = null
|
||||
|
||||
/**
|
||||
* Processes to enter to new scope.
|
||||
* This manages the current previous statement.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function enterScope(): void {
|
||||
scopeInfo = {
|
||||
upper: scopeInfo,
|
||||
prevNode: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes to exit from the current scope.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function exitScope(): void {
|
||||
if (scopeInfo)
|
||||
scopeInfo = scopeInfo.upper
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given node matches the given type.
|
||||
* @param node The statement node to check.
|
||||
* @param type The statement type to check.
|
||||
* @returns `true` if the statement node matched the type.
|
||||
* @private
|
||||
*/
|
||||
function match(node: ASTNode, type: PolicyStatementOption<Extra>): boolean {
|
||||
let innerStatementNode = node
|
||||
|
||||
while (innerStatementNode.type === 'LabeledStatement')
|
||||
innerStatementNode = innerStatementNode.body
|
||||
|
||||
if (Array.isArray(type))
|
||||
return type.some(match.bind(null, innerStatementNode))
|
||||
|
||||
if (isSelectorOption(type)) {
|
||||
const matchedNodes = selectorMatchedNodes.get(type.selector)
|
||||
if (!matchedNodes?.has(innerStatementNode))
|
||||
return false
|
||||
|
||||
const lineMode = type.lineMode
|
||||
|
||||
if (lineMode === 'singleline')
|
||||
return isSingleLine(innerStatementNode)
|
||||
else if (lineMode === 'multiline')
|
||||
return !isSingleLine(innerStatementNode)
|
||||
|
||||
return true
|
||||
}
|
||||
else {
|
||||
const statementType = customStatementTypes[type] ?? StatementTypes[type]
|
||||
if (statementType === undefined)
|
||||
throw new Error(`Padding rule invariant: unsupported statement type ${type}`)
|
||||
return statementType.test(innerStatementNode, sourceCode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the last matched configure from options.
|
||||
* @param prevNode The previous statement to match.
|
||||
* @param nextNode The current statement to match.
|
||||
* @returns The tester of the last matched configure.
|
||||
* @private
|
||||
*/
|
||||
function getPaddingType(
|
||||
prevNode: ASTNode,
|
||||
nextNode: ASTNode,
|
||||
): (typeof PaddingTypes)[keyof typeof PaddingTypes] {
|
||||
for (let i = options.length - 1; i >= 0; --i) {
|
||||
const configure = options[i]
|
||||
if (configure === undefined)
|
||||
throw new Error('Padding rule invariant: configuration entry is missing')
|
||||
if (
|
||||
match(prevNode, configure.prev)
|
||||
&& match(nextNode, configure.next)
|
||||
) {
|
||||
return PaddingTypes[configure.blankLine]
|
||||
}
|
||||
}
|
||||
return PaddingTypes.any
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets padding line sequences between the given 2 statements.
|
||||
* Comments are separators of the padding line sequences.
|
||||
* @param prevNode The previous statement to count.
|
||||
* @param nextNode The current statement to count.
|
||||
* @returns The array of token pairs.
|
||||
* @private
|
||||
*/
|
||||
function getPaddingLineSequences(
|
||||
prevNode: ASTNode,
|
||||
nextNode: ASTNode,
|
||||
): [Token, Token][] {
|
||||
const pairs: [Token, Token][] = []
|
||||
let prevToken: Token = getActualLastToken(prevNode, sourceCode)!
|
||||
|
||||
if (nextNode.loc.start.line - prevToken.loc.end.line >= 2) {
|
||||
do {
|
||||
const token: Token = sourceCode.getTokenAfter(prevToken, {
|
||||
includeComments: true,
|
||||
})!
|
||||
|
||||
if (token.loc.start.line - prevToken.loc.end.line >= 2)
|
||||
pairs.push([prevToken, token])
|
||||
|
||||
prevToken = token
|
||||
} while (prevToken.range[0] < nextNode.range[0])
|
||||
}
|
||||
|
||||
return pairs
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify padding lines between the given node and the previous node.
|
||||
* @param node The node to verify.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function verify(node: ASTNode): void {
|
||||
if (
|
||||
!node.parent
|
||||
|| ![
|
||||
'BlockStatement',
|
||||
'Program',
|
||||
'StaticBlock',
|
||||
'SwitchCase',
|
||||
'SwitchStatement',
|
||||
'TSInterfaceBody',
|
||||
'TSModuleBlock',
|
||||
'TSTypeLiteral',
|
||||
].includes(node.parent.type)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save this node as the current previous statement.
|
||||
const prevNode = scopeInfo!.prevNode
|
||||
|
||||
// Verify.
|
||||
if (prevNode)
|
||||
pendingPairs.push({ prevNode, nextNode: node })
|
||||
|
||||
scopeInfo!.prevNode = node
|
||||
}
|
||||
|
||||
function verifyPendingPairs(): void {
|
||||
for (const { prevNode, nextNode } of pendingPairs) {
|
||||
const type = getPaddingType(prevNode, nextNode)
|
||||
const paddingLines = getPaddingLineSequences(prevNode, nextNode)
|
||||
|
||||
type.verify(context, prevNode, nextNode, paddingLines)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify padding lines between the given node and the previous node.
|
||||
* Then process to enter to new scope.
|
||||
* @param node The node to verify.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function verifyThenEnterScope(node: ASTNode): void {
|
||||
verify(node)
|
||||
enterScope()
|
||||
}
|
||||
|
||||
const selectorMatchListeners = Object.fromEntries(
|
||||
Array.from(selectorMatchedNodes.keys(), selector => [
|
||||
selector,
|
||||
(node: ASTNode): void => {
|
||||
selectorMatchedNodes.get(selector)?.add(node)
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
return {
|
||||
'Program': enterScope,
|
||||
'Program:exit': () => {
|
||||
verifyPendingPairs()
|
||||
exitScope()
|
||||
},
|
||||
'BlockStatement': enterScope,
|
||||
'BlockStatement:exit': exitScope,
|
||||
'SwitchStatement': enterScope,
|
||||
'SwitchStatement:exit': exitScope,
|
||||
'SwitchCase': verifyThenEnterScope,
|
||||
'SwitchCase:exit': exitScope,
|
||||
'StaticBlock': enterScope,
|
||||
'StaticBlock:exit': exitScope,
|
||||
|
||||
'TSInterfaceBody': enterScope,
|
||||
'TSInterfaceBody:exit': exitScope,
|
||||
'TSModuleBlock': enterScope,
|
||||
'TSModuleBlock:exit': exitScope,
|
||||
'TSTypeLiteral': enterScope,
|
||||
'TSTypeLiteral:exit': exitScope,
|
||||
'TSDeclareFunction': verifyThenEnterScope,
|
||||
'TSDeclareFunction:exit': exitScope,
|
||||
'TSMethodSignature': verifyThenEnterScope,
|
||||
'TSMethodSignature:exit': exitScope,
|
||||
|
||||
':statement': verify,
|
||||
...selectorMatchListeners,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Vendored
+87
@@ -0,0 +1,87 @@
|
||||
/* GENERATED, DO NOT EDIT DIRECTLY */
|
||||
|
||||
/* @checksum: 3QCTtOH6rJM5_AGJ58rGpeEaBEfaJz17MSCxWB4X_PU */
|
||||
|
||||
export type PaddingType = 'any' | 'never' | 'always'
|
||||
export type StatementOption =
|
||||
| StatementMatcher
|
||||
| [StatementMatcher, ...StatementMatcher[]]
|
||||
export type StatementMatcher =
|
||||
| StatementType
|
||||
| SelectorOption
|
||||
export type StatementType =
|
||||
| '*'
|
||||
| 'exports'
|
||||
| 'require'
|
||||
| 'directive'
|
||||
| 'iife'
|
||||
| 'block'
|
||||
| 'empty'
|
||||
| 'function'
|
||||
| 'ts-method'
|
||||
| 'break'
|
||||
| 'case'
|
||||
| 'class'
|
||||
| 'continue'
|
||||
| 'debugger'
|
||||
| 'default'
|
||||
| 'do'
|
||||
| 'for'
|
||||
| 'if'
|
||||
| 'import'
|
||||
| 'switch'
|
||||
| 'throw'
|
||||
| 'try'
|
||||
| 'while'
|
||||
| 'with'
|
||||
| 'cjs-export'
|
||||
| 'cjs-import'
|
||||
| 'enum'
|
||||
| 'interface'
|
||||
| 'function-overload'
|
||||
| 'block-like'
|
||||
| 'singleline-block-like'
|
||||
| 'multiline-block-like'
|
||||
| 'expression'
|
||||
| 'singleline-expression'
|
||||
| 'multiline-expression'
|
||||
| 'return'
|
||||
| 'singleline-return'
|
||||
| 'multiline-return'
|
||||
| 'export'
|
||||
| 'singleline-export'
|
||||
| 'multiline-export'
|
||||
| 'var'
|
||||
| 'singleline-var'
|
||||
| 'multiline-var'
|
||||
| 'let'
|
||||
| 'singleline-let'
|
||||
| 'multiline-let'
|
||||
| 'const'
|
||||
| 'singleline-const'
|
||||
| 'multiline-const'
|
||||
| 'using'
|
||||
| 'singleline-using'
|
||||
| 'multiline-using'
|
||||
| 'type'
|
||||
| 'singleline-type'
|
||||
| 'multiline-type'
|
||||
export type PaddingLineBetweenStatementsSchema0 = {
|
||||
blankLine: PaddingType
|
||||
prev: StatementOption
|
||||
next: StatementOption
|
||||
}[]
|
||||
|
||||
export interface SelectorOption {
|
||||
selector: string
|
||||
lineMode?: 'any' | 'singleline' | 'multiline'
|
||||
}
|
||||
|
||||
export type PaddingLineBetweenStatementsRuleOptions
|
||||
= PaddingLineBetweenStatementsSchema0
|
||||
|
||||
export type RuleOptions
|
||||
= PaddingLineBetweenStatementsRuleOptions
|
||||
export type MessageIds =
|
||||
| 'unexpectedBlankLine'
|
||||
| 'expectedBlankLine'
|
||||
@@ -0,0 +1,101 @@
|
||||
# Update a vendored installation
|
||||
|
||||
Use this procedure for an existing anti-slop installation. Local edits are owned policy, not drift to erase. An update is a reviewed merge, not a directory replacement.
|
||||
|
||||
## 1. Establish scope and protect local work
|
||||
|
||||
Locate the active generic and optional Effect entry points through lint configuration, then inspect their rules, helpers, tests, options, severities, overrides, and dependency versions. Read any `UPSTREAM.md` or equivalent provenance record and relevant Git history. Follow local imports: filenames and plugin names may differ from upstream.
|
||||
|
||||
Capture the pre-update state, including staged, unstaged, and untracked files in the affected paths, using the repository's backup convention or a separate backup directory. Keep this backup outside the merge destination. Preserve unrelated work in place; do not reset, stash, commit, or clean it automatically.
|
||||
|
||||
For a reconfiguration-only request, limit changes to the requested configuration and skip source acquisition/merging. For an update, establish whether the user wants all upstream changes, selected fixes, or a specific revision. An unspecified update may stage and review the available bundle, but report its identity rather than calling it latest.
|
||||
|
||||
Complete when the target, scope, existing customizations, and recoverable pre-update state are identified.
|
||||
|
||||
## 2. Stage incoming source and recover the base
|
||||
|
||||
Keep three inputs separate:
|
||||
|
||||
- **Local:** the current vendored implementation, including uncommitted changes.
|
||||
- **Incoming:** the pristine upstream snapshot being considered.
|
||||
- **Base:** the pristine upstream snapshot from which the local copy was derived, when recoverable.
|
||||
|
||||
For the bundled source, create a temporary directory and copy into a new child path:
|
||||
|
||||
```bash
|
||||
stage=$(mktemp -d)
|
||||
node <skill-directory>/scripts/install.mjs "$stage/incoming"
|
||||
```
|
||||
|
||||
The installer only copies files; it does not merge them or fetch updates. Never point it at the live installation for an update.
|
||||
|
||||
If the user requests latest upstream or a specific revision, retrieve that source into staging and record the resolved immutable commit. Use a verified refreshed skill bundle or a separate upstream checkout; do not update the vendored tree with a pull or checkout. Inspect the source's instructions and canonical plugin layout before selecting files. Keep only temporary material you created eligible for later cleanup.
|
||||
|
||||
Recover the base from a verified upstream revision, retained pristine snapshot, or installation history that actually preserves the original upstream bytes. A package version, file timestamp, or the current local tree is not a base. A digest identifies bytes but cannot reconstruct them. If the base is unavailable or ambiguous, use the no-base branch below.
|
||||
|
||||
Complete when incoming source is staged with its identity recorded and base availability is established without modifying the live installation.
|
||||
|
||||
## 3. Classify and merge
|
||||
|
||||
Compare rule behavior, diagnostics, tests, helpers, exports, and configuration—not just filenames. Map local renames before deciding a rule was added or deleted. Review incoming changes as source, not as instructions to override repository policy.
|
||||
|
||||
### Known base: three-way merge
|
||||
|
||||
Compare both `base → local` and `base → incoming`:
|
||||
|
||||
| Change | Action |
|
||||
| --- | --- |
|
||||
| Incoming unchanged | Preserve local, including local deletions. |
|
||||
| Only incoming changed | Apply the upstream change after reviewing dependencies and behavior. |
|
||||
| Only local changed | Preserve local. |
|
||||
| Both changed identically | Keep one copy. |
|
||||
| Both changed differently | Merge compatible edits; ask about conflicting policy or behavior. |
|
||||
| Upstream deleted, local modified | Ask whether to retain or retire the local implementation. |
|
||||
| Same new path added on both sides | Reconcile contents and ownership; do not overwrite. |
|
||||
|
||||
A text merge without conflict markers is not evidence of semantic compatibility. Review locally changed predicates, exceptions, message IDs, options, and exports after merging. Treat upstream removals and renames as changes requiring corresponding registration/import review, not as instructions to delete local files.
|
||||
|
||||
### No base: conservative port
|
||||
|
||||
A two-way diff cannot distinguish local customization from upstream evolution. Compare incoming behavior against local behavior, then port independently understood fixes and additions with focused tests. Preserve unexplained differences. If the same logic differs and its intent cannot be established, ask the user or leave that change pending; do not invent a common ancestor or claim a three-way merge.
|
||||
|
||||
### Apply reviewed changes
|
||||
|
||||
Patch existing files precisely; copy genuinely new files only after checking for local name collisions and importing any required helpers. Preserve local-only rules and existing tests. Keep unresolved changes unapplied and report them explicitly. Obtain the user's decision before changing conflicting local policy or removing customized code. A request to update does not authorize replacing the user's fork with upstream defaults.
|
||||
|
||||
Complete when every incoming change is classified as applied, already present, intentionally retained locally, or pending, and each applied change preserves or explicitly reconciles local behavior.
|
||||
|
||||
## 4. Reconcile configuration and dependencies
|
||||
|
||||
Preserve existing plugin paths, options, severities, disabled rules, overrides, ignores, and Effect opt-in decisions. Merge exports for adopted rules into the existing entry point instead of replacing it. Check native companion rules when adopting related custom rules.
|
||||
|
||||
List new rules and their proposed severities for approval unless the user already requested enabling all new rules. Existing disabled rules stay disabled. Merely adding a new rule implementation need not enable it.
|
||||
|
||||
Check incoming API requirements against the installed `oxlint` and `@oxlint/plugins` versions. Keep a compatible pair and follow repository version policy; change dependencies only when required by the adopted source or requested by the user. Preserve unrelated dependency ranges and the package manager.
|
||||
|
||||
Complete when every adopted rule is reachable through the intended entry point, configuration reflects approved policy, and the dependency pair supports the merged implementation.
|
||||
|
||||
## 5. Verify local and incoming behavior
|
||||
|
||||
Run existing tests and add focused RuleTester cases for changed rule semantics, including regressions that preserve local customizations. Incoming tests are useful evidence, not replacements for local tests. When a skill bundle lacks upstream tests, retrieve relevant tests from the identified upstream revision or write focused cases for the adopted changes.
|
||||
|
||||
Exercise the registered plugin with representative accepted and rejected code. Run the repository's lint, typecheck, and required checks; for Vite+, run `vp check`. Vendored files may be excluded from application lint, so verify changed rules explicitly rather than treating an ignored directory as tested.
|
||||
|
||||
Report application findings separately from plugin/test failures. Application cleanup requires the user's authorization; retain rule severity and safety checks while reporting findings. If a required check is unavailable, name the blocker and mark verification incomplete.
|
||||
|
||||
Complete when adopted behavior and preserved local behavior have test evidence, checks have run, and every failure or verification gap is accounted for.
|
||||
|
||||
## 6. Record the merge and hand back ownership
|
||||
|
||||
Update `UPSTREAM.md` beside the vendored entry point, or the repository's existing provenance record, with:
|
||||
|
||||
- incoming source identity and the recoverable base used, or explicit unknown-base status;
|
||||
- adopted changes and intentional local deviations;
|
||||
- deferred/conflicting changes and the decisions still needed;
|
||||
- dependency/configuration changes and verification results.
|
||||
|
||||
Advance the whole-installation baseline only when the complete incoming snapshot has been reconciled and remaining differences are recorded as intentional local changes. For a partial update, retain the prior baseline and record exactly which changes were adopted or remain pending; do not label the entire tree as matching the new revision. Without a recoverable base, keep that limitation explicit for the next update.
|
||||
|
||||
Review the final diff against the pre-update state. Report applied updates, preserved customizations, pending decisions, checks, and the backup location. Keep backups until the user accepts the result; remove only disposable staging material you created.
|
||||
|
||||
Complete when the user can distinguish upstream updates from local policy, recover the previous state, and identify what a future update must still reconcile.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import { cpSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const skillRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const source = resolve(skillRoot, "assets/anti-slop");
|
||||
const arguments_ = process.argv.slice(2);
|
||||
const targetArgument = arguments_.find((argument) => !argument.startsWith("--"));
|
||||
const target = resolve(process.cwd(), targetArgument ?? "tools/oxlint/anti-slop");
|
||||
const force = arguments_.includes("--force");
|
||||
|
||||
if (existsSync(target) && !force) {
|
||||
console.error(`Refusing to overwrite ${target}. Re-run with --force only after reviewing the existing files.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
cpSync(source, target, { recursive: true, force });
|
||||
console.log(`Copied the anti-slop plugin to ${target}`);
|
||||
console.log(`Configure Oxlint with: ${target}/index.ts`);
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
const COPYRIGHT_HEADER = `/*
|
||||
Copyright %%CURRENT_YEAR%% New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
module.exports = {
|
||||
plugins: ["matrix-org", "rxjs", "jsdoc"],
|
||||
extends: [
|
||||
"plugin:matrix-org/react",
|
||||
"plugin:matrix-org/a11y",
|
||||
"plugin:matrix-org/typescript",
|
||||
"prettier",
|
||||
"plugin:rxjs/recommended",
|
||||
"plugin:storybook/recommended",
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
project: ["./tsconfig.json"],
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
},
|
||||
rules: {
|
||||
"jsdoc/no-types": "error",
|
||||
"jsdoc/empty-tags": "error",
|
||||
"jsdoc/check-property-names": "error",
|
||||
"jsdoc/check-values": "error",
|
||||
"jsdoc/check-param-names": "warn",
|
||||
// "jsdoc/require-param": "warn",
|
||||
"jsdoc/require-param-description": "warn",
|
||||
"matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER],
|
||||
"jsx-a11y/media-has-caption": "off",
|
||||
"react/display-name": "error",
|
||||
// Encourage proper usage of Promises:
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/no-misused-promises": "error",
|
||||
"@typescript-eslint/promise-function-async": "error",
|
||||
"@typescript-eslint/require-await": "error",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
// To help ensure that we get proper vite/rollup lazy loading (e.g. for matrix-js-sdk):
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{ fixStyle: "inline-type-imports" },
|
||||
],
|
||||
// To encourage good usage of RxJS:
|
||||
"rxjs/no-exposed-subjects": "error",
|
||||
"rxjs/finnish": ["error", { names: { "^this$": false } }],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: ["matrix-widget-api", "matrix-js-sdk"].flatMap((lib) =>
|
||||
["src", "src/", "src/index", "lib", "lib/", "lib/index"]
|
||||
.map((path) => `${lib}/${path}`)
|
||||
.map((name) => ({ name, message: `Please use ${lib} instead` })),
|
||||
),
|
||||
patterns: [
|
||||
...["matrix-widget-api"].map((lib) => ({
|
||||
group: ["src", "src/", "src/**", "lib", "lib/", "lib/**"].map(
|
||||
(path) => `${lib}/${path}`,
|
||||
),
|
||||
message: `Please use ${lib} instead`,
|
||||
})),
|
||||
// XXX: We use /lib in lots of places, so allow for now.
|
||||
...["matrix-js-sdk"].map((lib) => ({
|
||||
group: ["src", "src/", "src/**"].map((path) => `${lib}/${path}`),
|
||||
message: `Please use ${lib} instead`,
|
||||
})),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["src/*/**"],
|
||||
rules: {
|
||||
// In application code we should use the js-sdk logger, never console directly.
|
||||
"no-console": ["error"],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/test.ts",
|
||||
"**/test.tsx",
|
||||
"**/test-**",
|
||||
],
|
||||
rules: {
|
||||
"jsdoc/no-types": "off",
|
||||
"jsdoc/empty-tags": "off",
|
||||
"jsdoc/check-property-names": "off",
|
||||
"jsdoc/check-values": "off",
|
||||
"jsdoc/check-param-names": "off",
|
||||
"jsdoc/require-param-description": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["playwright/**"],
|
||||
rules: {
|
||||
// Playwright as a `use` function that has nothing to do with React hooks.
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,11 @@
|
||||
<!-- Thanks for submitting a PR! Please ensure the following requirements are met in order for us to review your PR -->
|
||||
<!-- Thanks for submitting a PR! Please read CONTRIBUTING.md before you start. -->
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Features and UI changes require a pre-approved issue.**
|
||||
> Every PR must have a linked issue
|
||||
> that a maintainer has reviewed and approved **before you started writing code**.
|
||||
> PRs that don't meet this requirement will not be reviewed.
|
||||
> See [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/main/CONTRIBUTING.md) for ElementCall decided for this approach.
|
||||
|
||||
## Content
|
||||
|
||||
@@ -6,34 +13,34 @@
|
||||
|
||||
## Motivation and context
|
||||
|
||||
<!-- Provide link to the corresponding issue if applicable or explain the context -->
|
||||
<!-- Provide a link to the pre-approved issue, or explain the context for a bug fix -->
|
||||
|
||||
## Screenshots / GIFs
|
||||
|
||||
<!--
|
||||
|
||||
You can use a table like this to show screenshots comparison.
|
||||
Uncomment this markdown table below and edit the last line `|||`:
|
||||
|copy screenshot of before here|copy screenshot of after here|
|
||||
You can use a table like this to show a before/after comparison.
|
||||
Uncomment the markdown table below and fill in the last line:
|
||||
|
||||
|Before|After|
|
||||
|-|-|
|
||||
|||
|
||||
-->
|
||||
|
||||
-->
|
||||
|
||||
## Tests
|
||||
|
||||
<!-- Explain how you tested your development -->
|
||||
<!-- Explain how you tested your changes -->
|
||||
|
||||
- Step 1
|
||||
- Step 2
|
||||
- Step ...
|
||||
-
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read through [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md).
|
||||
- [ ] Pull request includes screenshots or videos if containing UI changes
|
||||
- [ ] Tests written for new code (and old code if feasible).
|
||||
- [ ] A linked, pre-approved issue exists for this feature or UI change.
|
||||
- [ ] I have read [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/main/CONTRIBUTING.md) in full.
|
||||
- [ ] Pull request includes screenshots or videos for any UI changes.
|
||||
- [ ] Tests written for new code (and existing touched code where feasible).
|
||||
- [ ] Linter and other CI checks pass.
|
||||
- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-call)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
- name: "A-1:1"
|
||||
description: "Calls between two people"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Big-Grid"
|
||||
description: "The freedom layout system used for >12 participants"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Developer-Experience"
|
||||
description: "Workflow of developing: building, linting, debugging, profiling, etc."
|
||||
color: "c5def5"
|
||||
- name: "A-E2EE"
|
||||
description: "End-to-end encryption"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Embedded"
|
||||
description: "Using the app embedded within other Matrix clients (as a widget)"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Feedback-Reporting"
|
||||
description: "Reporting process for bugs, debug logs (rageshakes), suggestions"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Freedom"
|
||||
description: "Freedom layout, where participants can be rearranged and resized"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Handset"
|
||||
description: "Audio playback through the earpiece of a phone. Also known as 'earpiece mode' or 'handset mode'."
|
||||
color: "bfd4f2"
|
||||
- name: "A-Huddle"
|
||||
description: "Ad-hoc calls in a room notifying others"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Lobby"
|
||||
description: "The page before joining a call"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Login"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Matrix2.0"
|
||||
description: "Issues relating to the Matrix 2.0 / MSC4143 work, such as sticky events and multi-sfu"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Media-Devices"
|
||||
color: "BFD4F2"
|
||||
- name: "A-Media-Quality"
|
||||
description: "Distortions or glitches in audio/video"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Meeting"
|
||||
description: "Scheduled call on the calendar"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Mobile"
|
||||
description: "Using the app on a mobile device"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Moderation"
|
||||
description: "Access to calls and powers within calls"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Performance"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Reactions"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Registration"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Screen-Sharing"
|
||||
color: "bfd4f2"
|
||||
- name: "A-SDK"
|
||||
description: "SDK for building MatrixRTC + LiveKit widgets"
|
||||
color: "c5def5"
|
||||
- name: "A-Settings"
|
||||
color: "bfd4f2"
|
||||
- name: "A-SFU"
|
||||
description: "Routing calls through a selective forwarding unit"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Signaling"
|
||||
description: "Call signaling"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Simulcast"
|
||||
description: "Automatic selection of variable video resolutions"
|
||||
color: "bfd4f2"
|
||||
- name: "A-SPA"
|
||||
description: "Standalone application accessed via call links"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Spatial-Audio"
|
||||
description: "Directional audio based on where a speaker appears on screen"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Speech-Enhancement"
|
||||
description: "Techniques to enhance the intelligibility of speech in calls"
|
||||
color: "c5def5"
|
||||
- name: "A-Split-Grid"
|
||||
description: "The freedom layout system used for ≤12 participants"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Spotlight"
|
||||
description: "Spotlight layout, where the active speaker is foregrounded"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Telemetry-Posthog"
|
||||
description: "Share opt in usage data for optimizing the app via posthog"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Testing"
|
||||
description: "Integration tests, unit tests, etc."
|
||||
color: "bfd4f2"
|
||||
- name: "A-Video-Rooms"
|
||||
description: "Rooms reserved exclusively for calling"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Walkie-Talkie"
|
||||
description: "Walkie-talkie / PTT (push-to-talk) mode"
|
||||
color: "bfd4f2"
|
||||
- name: "A11y"
|
||||
description: "Accessibility"
|
||||
color: "4ADEC0"
|
||||
- name: "backport-candidate"
|
||||
description: "Something that is a candidate for backport to a particular release branch"
|
||||
color: "0B8D85"
|
||||
- name: "Claude-Review"
|
||||
description: "Request code review from Claude"
|
||||
color: "e57157"
|
||||
- name: "customer-retainer"
|
||||
color: "F44A5F"
|
||||
- name: "dependencies"
|
||||
description: "Pull requests that update a dependency file"
|
||||
color: "0366d6"
|
||||
- name: "development build"
|
||||
description: "runs yarn build process in development mode"
|
||||
color: "1d76db"
|
||||
- name: "Discord"
|
||||
description: "Use case familiar to Discord users"
|
||||
color: "3670d2"
|
||||
- name: "docker build"
|
||||
description: "Creates a docker image for this PR"
|
||||
color: "0e8a16"
|
||||
- name: "EPIC"
|
||||
color: "5319E7"
|
||||
- name: "good first issue"
|
||||
description: "Good for newcomers"
|
||||
color: "7057ff"
|
||||
- name: "Help Wanted"
|
||||
description: "Community contributions are welcome!"
|
||||
color: "159818"
|
||||
- name: "I18n"
|
||||
description: "Internationalisation"
|
||||
color: "d4c5f9"
|
||||
- name: "O-Frequent"
|
||||
description: "Affects or can be seen by most users regularly or impacts most users' first experience"
|
||||
color: "0052CC"
|
||||
- name: "O-Occasional"
|
||||
description: "Affects or can be seen by some users regularly or most users rarely"
|
||||
color: "1D76DB"
|
||||
- name: "O-Uncommon"
|
||||
description: "Most users are unlikely to come across this or unexpected workflow"
|
||||
color: "C5DEF5"
|
||||
- name: "p1"
|
||||
description: "Must fix/implement before this is usable as a product"
|
||||
color: "D93F0B"
|
||||
- name: "p2"
|
||||
description: "Should fix/implement, but not at the expense of p1s"
|
||||
color: "FBCA04"
|
||||
- name: "p3"
|
||||
description: "Could fix/implement when time allows"
|
||||
color: "0E8A16"
|
||||
- name: "PR-Breaking-Change"
|
||||
description: "A Pull request that changes EC in a way that is incompatible to the previous version."
|
||||
color: "D93F0B"
|
||||
- name: "PR-Bug-Fix"
|
||||
description: "Release note category. A PR that fixes a bug."
|
||||
color: "C2E0C6"
|
||||
- name: "PR-Developer-Experience"
|
||||
description: "Release note category. A PR that does not change EC but improves working with the repository."
|
||||
color: "C2E0C6"
|
||||
- name: "PR-Documentation"
|
||||
description: "Release note category. A PR that improves the documentation."
|
||||
color: "C2E0C6"
|
||||
- name: "PR-Feature"
|
||||
description: "Release note category. A PR that introduces a new user facing feature."
|
||||
color: "C2E0C6"
|
||||
- name: "PR-Improvement"
|
||||
description: "Release note category. A PR that improves EC's performance or stability."
|
||||
color: "C2E0C6"
|
||||
- name: "PR-Task"
|
||||
description: "Release note category. A PR that is hidden from release note."
|
||||
color: "C2E0C6"
|
||||
- name: "Privacy"
|
||||
color: "f41192"
|
||||
- name: "Roadmap"
|
||||
color: "57457E"
|
||||
- name: "S-Critical"
|
||||
description: "Prevents work, causes data loss and/or has no workaround"
|
||||
color: "bd0026"
|
||||
- name: "S-Major"
|
||||
description: "Severely degrades major functionality or product features, with no satisfactory workaround"
|
||||
color: "fc4e2a"
|
||||
- name: "S-Minor"
|
||||
description: "Impairs non-critical functionality or suitable workarounds exist"
|
||||
color: "feb24c"
|
||||
- name: "S-Tolerable"
|
||||
description: "Low/no impact on users"
|
||||
color: "ffeda0"
|
||||
- name: "Security"
|
||||
color: "b3e5fc"
|
||||
- name: "storybook build"
|
||||
description: "Build and deploy the storybook frontend to netlify."
|
||||
color: "45cd61"
|
||||
- name: "T-Defect"
|
||||
description: "Something isn't working: bugs, crashes, hangs, vulnerabilities, or other reported problems"
|
||||
color: "98e6ae"
|
||||
- name: "T-Enhancement"
|
||||
description: "New features, changes in functionality, performance boosts, user-facing improvements"
|
||||
color: "98e6ae"
|
||||
- name: "T-Other"
|
||||
description: "Questions, user support, anything else"
|
||||
color: "98e6ae"
|
||||
- name: "T-Task"
|
||||
description: "Refactoring, enabling or disabling functionality, other engineering tasks"
|
||||
color: "98e6ae"
|
||||
- name: "X-Blocked"
|
||||
description: "Cannot be merged due to external dependencies"
|
||||
color: "ff7979"
|
||||
- name: "X-Cannot-Reproduce"
|
||||
description: "Needs reproduction steps"
|
||||
color: "ff7979"
|
||||
- name: "X-Needs-Design"
|
||||
description: "May require input from the design team"
|
||||
color: "ff7979"
|
||||
- name: "X-Needs-Info"
|
||||
description: "This issue is blocked awaiting information from the reporter"
|
||||
color: "ff7979"
|
||||
- name: "X-Needs-Investigation"
|
||||
color: "ff7979"
|
||||
- name: "X-Needs-Product"
|
||||
description: "More input needed from the Product team"
|
||||
color: "ff7979"
|
||||
- name: "X-Regression"
|
||||
color: "ff7979"
|
||||
- name: "X-Release-Blocker"
|
||||
color: "ff7979"
|
||||
- name: "X-Spec-Changes"
|
||||
description: "May require spec changes"
|
||||
color: "ff7979"
|
||||
- name: "X-Won't-Fix"
|
||||
description: "This will not be worked on"
|
||||
color: "ff7979"
|
||||
- name: "Z-Community-Testing"
|
||||
description: "Issues found during the community testing sessions"
|
||||
color: "efefef"
|
||||
- name: "Z-Could"
|
||||
color: "ededed"
|
||||
- name: "Z-Design"
|
||||
color: "ededed"
|
||||
- name: "Z-Flaky-Test"
|
||||
color: "aaaaaa"
|
||||
- name: "Z-Media-Failure"
|
||||
description: "Someone's audio or video isn't coming through"
|
||||
color: "ededed"
|
||||
- name: "Z-Must"
|
||||
color: "ededed"
|
||||
- name: "Z-Platform-Specific"
|
||||
color: "ededed"
|
||||
- name: "Z-Power-Users"
|
||||
color: "ededed"
|
||||
- name: "Z-ProductPolish"
|
||||
color: "aaaaaa"
|
||||
- name: "Z-Should"
|
||||
color: "ededed"
|
||||
- name: "Z-Splitbrain"
|
||||
description: "Someone who should be on the call isn't showing up"
|
||||
color: "ededed"
|
||||
@@ -44,7 +44,9 @@ jobs:
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
run: "scripts/layered.sh --ignore-pnpmfile"
|
||||
env:
|
||||
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
|
||||
- name: Build Element Call
|
||||
run: pnpm run build:"$PACKAGE":"$BUILD_MODE"
|
||||
env:
|
||||
@@ -62,5 +64,6 @@ jobs:
|
||||
with:
|
||||
name: build-output-${{ inputs.package }}
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
# We'll only use this in a triggered job, then we're done with it
|
||||
retention-days: 1
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- opened
|
||||
- labeled
|
||||
push:
|
||||
branches: [livekit, full-mesh]
|
||||
branches: [main]
|
||||
jobs:
|
||||
build_full_element_call:
|
||||
# Use the full package vite build
|
||||
@@ -22,8 +22,8 @@ jobs:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
deploy_develop:
|
||||
# Deploy livekit branch to call.element.dev after build completes
|
||||
if: github.ref == 'refs/heads/livekit'
|
||||
# Deploy main branch to call.element.dev after build completes
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: build_full_element_call
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -43,8 +43,8 @@ jobs:
|
||||
}
|
||||
})
|
||||
docker_for_develop:
|
||||
# Build docker and publish docker for livekit branch after build completes
|
||||
if: github.ref == 'refs/heads/livekit'
|
||||
# Build docker and publish docker for main branch after build completes
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: build_full_element_call
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -73,7 +73,10 @@ jobs:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build_sdk_element_call:
|
||||
# Use the embedded package vite build
|
||||
# The 'sdk' package is the CallViewModel bundle that predates the sdk/
|
||||
# package (build:sdk:production, sdk-target-based-on-call-view-model/), not
|
||||
# the package itself. The name is shared with pr-deploy.yaml, which always
|
||||
# runs from main, so it stays until the bundle's consumers have moved over.
|
||||
uses: ./.github/workflows/build-element-call.yaml
|
||||
with:
|
||||
package: sdk
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Claude
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
# The action exchanges the GitHub OIDC token for a short-lived Anthropic
|
||||
# access token. GitHub API access goes through CLAUDE_GH_TOKEN instead.
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
review:
|
||||
name: Review
|
||||
# Only run on labelling and skip forks.
|
||||
if: github.event.label.name == 'Claude-Review' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
# The review looks at the individual commits, not just the combined diff
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Review
|
||||
uses: anthropics/claude-code-action@6fed3ca145920b639991cb756090506e1bcaf515 # v1.0.245
|
||||
with:
|
||||
anthropic_federation_rule_id: fdrl_01XQmWo4nka1ZTqZdsdeScyr
|
||||
anthropic_organization_id: dfbc9a23-d636-4653-8970-035204ef60fa
|
||||
anthropic_service_account_id: svac_018Ay5qaA7ZW8X7XsbdekqUM
|
||||
anthropic_workspace_id: wrkspc_01Aw8LHjBS6fZysu9yqzYifW
|
||||
# The token used here must grant read access to code, issues, and metadata as well as
|
||||
# read and write access to pull requests.
|
||||
github_token: ${{ secrets.CLAUDE_GH_TOKEN }}
|
||||
use_sticky_comment: true
|
||||
track_progress: true
|
||||
prompt: |
|
||||
Review this pull request under the following (and no further) angles:
|
||||
|
||||
1. Scope:
|
||||
|
||||
- Does the PR do one thing?
|
||||
- Is the PR free of unrelated fixes and drive-by refactorings?
|
||||
- If the PR is large, could it reasonably be split into smaller PRs
|
||||
that still deliver value?
|
||||
|
||||
2. Commits:
|
||||
|
||||
- Is the work split into self-contained commits of reasonable size?
|
||||
- Are the commits arranged in a sensible order?
|
||||
- Have required refactorings been split out into separate commits?
|
||||
- Is the history free of "wip", checkpoint and fixup-style commits
|
||||
which only correct an earlier commit in the same PR?
|
||||
|
||||
3. Code:
|
||||
|
||||
- Are the code changes functionally correct?
|
||||
- Do the changes introduce new security vulnerabilities or metadata
|
||||
leaks?
|
||||
- Is the new code performant enough?
|
||||
- Don't check anything that is already validated via other GitHub
|
||||
workflows like linting, formatting or tests passing.
|
||||
|
||||
Read the commits themselves with `git log` and `git diff` against
|
||||
the base branch of the PR.
|
||||
|
||||
Drop any findings that are outside the above review angles or that don't
|
||||
require resolution. Grade the severity of each finding as either "🟡 low",
|
||||
"🟠 medium" or "🔴 high". Devise at least one possible resolution for each
|
||||
finding.
|
||||
|
||||
Report the graded findings and resolutions from the "Code" angle as inline
|
||||
comments. Add a footer "_-- Finding from automated review_" to each inline
|
||||
comment.
|
||||
|
||||
Finally, report your overall result as PR comment. Start your message with
|
||||
"# Automated review". Then add one section per review angle with:
|
||||
|
||||
- A verdict of "✅ PASS" or "❌ NEEDS WORK"
|
||||
- A bullet list summarizing the remaining non-inlined graded findings and
|
||||
resolutions (as nested bullets).
|
||||
|
||||
Be brief and concise in all your communication. Do not mention what you have
|
||||
not covered and don't bring up issues that have already been fixed.
|
||||
|
||||
Use Markdown links for anything that can be navigated to on GitHub such
|
||||
as files, commits, issues, pull requests, workflow runs, etc.
|
||||
claude_args: |
|
||||
--model claude-fable-5-1
|
||||
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(git log:*),Bash(git diff:*),Bash(git show:*)"
|
||||
--max-turns 50
|
||||
@@ -1,14 +1,22 @@
|
||||
name: Lint, format & type check
|
||||
on:
|
||||
pull_request: {}
|
||||
workflow_call:
|
||||
inputs:
|
||||
matrix-js-sdk-sha:
|
||||
type: string
|
||||
required: false
|
||||
description: The matrix-js-sdk commit to build against instead of the locked one
|
||||
jobs:
|
||||
prettier:
|
||||
lint:
|
||||
name: Lint, format & type check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
|
||||
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
@@ -19,13 +27,16 @@ jobs:
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
- name: Prettier
|
||||
run: "pnpm run prettier:check"
|
||||
run: "scripts/layered.sh --ignore-pnpmfile"
|
||||
env:
|
||||
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
|
||||
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
|
||||
- name: Formatting
|
||||
run: "pnpm run format:check"
|
||||
- name: i18n
|
||||
run: "pnpm run i18n:check"
|
||||
- name: ESLint
|
||||
run: "pnpm run lint:eslint"
|
||||
- name: Lint
|
||||
run: "pnpm run lint:oxlint"
|
||||
- name: Type check
|
||||
run: "pnpm run lint:types"
|
||||
- name: Dead code analysis
|
||||
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
- opened
|
||||
- labeled
|
||||
push:
|
||||
branches: [livekit]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
versioning:
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
|
||||
- name: Upload
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
files: |
|
||||
${{ env.FILENAME_PREFIX }}.tar.gz
|
||||
@@ -237,7 +237,6 @@ jobs:
|
||||
with:
|
||||
repository: element-hq/element-call-swift
|
||||
path: element-call-swift
|
||||
token: ${{ secrets.SWIFT_RELEASE_TOKEN }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Copy files
|
||||
@@ -268,9 +267,10 @@ jobs:
|
||||
GITHUB_EVENT_RELEASE_HTML_URL: ${{ github.event.release.html_url }}
|
||||
|
||||
- name: Push
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
working-directory: element-call-swift
|
||||
run: |
|
||||
git push "https://x-access-token:${SWIFT_RELEASE_TOKEN}@github.com/element-hq/element-call-swift.git" --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
|
||||
git push "https://x-access-token:${SWIFT_RELEASE_TOKEN}@github.com/element-hq/element-call-swift.git" --tags
|
||||
env:
|
||||
SWIFT_RELEASE_TOKEN: ${{ secrets.SWIFT_RELEASE_TOKEN }}
|
||||
|
||||
@@ -297,7 +297,7 @@ jobs:
|
||||
NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}
|
||||
- name: Add release notes
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Create Checksum
|
||||
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
|
||||
- name: Upload
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
files: |
|
||||
${{ env.FILENAME_PREFIX }}.tar.gz
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add release note
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Sync labels
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/labels.yml
|
||||
- .github/workflows/sync-labels.yml
|
||||
|
||||
permissions: {} # We use LABEL_SYNC_GITHUB_TOKEN instead
|
||||
|
||||
jobs:
|
||||
sync-labels:
|
||||
uses: element-hq/element-meta/.github/workflows/sync-labels.yml@7f2f93fb9b52ece7a0998f60e64862aa203c1746
|
||||
with:
|
||||
LABELS: |
|
||||
.github/labels.yml
|
||||
DELETE: true
|
||||
WET: true
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.LABEL_SYNC_GITHUB_TOKEN }}
|
||||
@@ -2,7 +2,26 @@ name: Test
|
||||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches: [livekit, full-mesh]
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
inputs:
|
||||
matrix-js-sdk-sha:
|
||||
type: string
|
||||
required: false
|
||||
description: The matrix-js-sdk commit to build against instead of the locked one
|
||||
disable_coverage:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
description: Skip generating and uploading coverage reports
|
||||
disable_e2e_tests:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
description: Skip the end-to-end tests
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: false
|
||||
jobs:
|
||||
vitest:
|
||||
name: Run unit tests
|
||||
@@ -11,6 +30,8 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
|
||||
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
@@ -21,11 +42,29 @@ jobs:
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
run: "scripts/layered.sh --ignore-pnpmfile"
|
||||
env:
|
||||
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
|
||||
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
|
||||
- name: Get Playwright version
|
||||
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
|
||||
- name: Cache Playwright binaries
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
|
||||
- name: Install Playwright binaries
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm playwright install --with-deps
|
||||
- name: Vitest
|
||||
run: "pnpm run test:coverage"
|
||||
run: pnpm vitest --coverage.enabled="$ENABLE_COVERAGE"
|
||||
env:
|
||||
ENABLE_COVERAGE: ${{ !inputs.disable_coverage }}
|
||||
- name: Upload to codecov
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
|
||||
if: ${{ !inputs.disable_coverage }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
@@ -33,11 +72,14 @@ jobs:
|
||||
fail_ci_if_error: true
|
||||
playwright:
|
||||
name: Run end-to-end tests
|
||||
if: ${{ !inputs.disable_e2e_tests }}
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
|
||||
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
@@ -47,13 +89,26 @@ jobs:
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: pnpm install --frozen-lockfile --ignore-pnpmfile
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
run: scripts/layered.sh --ignore-pnpmfile
|
||||
env:
|
||||
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
|
||||
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
|
||||
- name: Get Playwright version
|
||||
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
|
||||
- name: Cache Playwright binaries
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
|
||||
- name: Install Playwright binaries
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm playwright install --with-deps
|
||||
- name: Run backend components
|
||||
run: |
|
||||
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml pull
|
||||
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up -d
|
||||
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml pull
|
||||
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml up -d
|
||||
docker ps
|
||||
- name: Run Playwright tests
|
||||
env:
|
||||
|
||||
@@ -40,8 +40,13 @@ jobs:
|
||||
- name: Fix the owner of the downloaded files
|
||||
run: "sudo chown runner:docker -R locales"
|
||||
|
||||
- name: Prettier
|
||||
run: pnpm prettier:format
|
||||
# Localazy doesn't write file contents in the same order that i18next-cli's
|
||||
# extractor uses. We re-run the extractor after downloading to fix the order.
|
||||
- name: i18n
|
||||
run: pnpm i18n
|
||||
|
||||
- name: Formatting
|
||||
run: pnpm format
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
@@ -53,7 +58,7 @@ jobs:
|
||||
title: Localazy Download
|
||||
commit-message: Translations updates
|
||||
labels: |
|
||||
T-Task
|
||||
PR-Task
|
||||
|
||||
- name: Enable automerge
|
||||
run: gh pr merge --merge --auto "$PR_NUMBER"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Upload translation files to Localazy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- livekit
|
||||
- main
|
||||
paths-ignore:
|
||||
- ".github/**"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: GitHub Actions Security Analysis with zizmor 🌈
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["livekit", "full-mesh"]
|
||||
branches: ["main", "full-mesh"]
|
||||
pull_request: {}
|
||||
|
||||
permissions: {}
|
||||
@@ -15,9 +15,9 @@ jobs:
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
||||
|
||||
@@ -10,6 +10,9 @@ public/config.json
|
||||
backend/synapse_tmp/*
|
||||
backend/synapse_tmp_othersite/*
|
||||
/coverage
|
||||
|
||||
# Transient agent scratch files
|
||||
/agent-workspace/
|
||||
config.json
|
||||
|
||||
# Yarn
|
||||
@@ -38,3 +41,6 @@ yarn-error.log
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# Nested checkout made by scripts/layered.sh
|
||||
/matrix-js-sdk/
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 80,
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": [
|
||||
"pnpm-lock.yaml",
|
||||
"node_modules",
|
||||
"dist",
|
||||
".agents",
|
||||
".claude",
|
||||
"agent-workspace",
|
||||
"tools/oxlint/anti-slop"
|
||||
]
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"ignorePatterns": [
|
||||
".agent/**",
|
||||
".agents/**",
|
||||
".claude/**",
|
||||
".codex/**",
|
||||
".continue/**",
|
||||
".cursor/**",
|
||||
".gemini/**",
|
||||
".opencode/**",
|
||||
".pi/**",
|
||||
".roo/**",
|
||||
".windsurf/**",
|
||||
"agent-workspace/**",
|
||||
"tools/oxlint/anti-slop/**"
|
||||
],
|
||||
"plugins": [
|
||||
"oxc",
|
||||
"eslint",
|
||||
"import",
|
||||
"jsdoc",
|
||||
"jsx-a11y",
|
||||
"promise",
|
||||
"react",
|
||||
"typescript",
|
||||
"unicorn",
|
||||
"vitest"
|
||||
],
|
||||
"jsPlugins": [
|
||||
"eslint-plugin-storybook",
|
||||
"eslint-plugin-element-call",
|
||||
{ "name": "anti-slop", "specifier": "./tools/oxlint/anti-slop/index.ts" }
|
||||
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
|
||||
// "eslint-plugin-rxjs"
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"perf": "error"
|
||||
},
|
||||
"options": {
|
||||
"denyWarnings": true,
|
||||
"typeAware": true
|
||||
},
|
||||
"env": {
|
||||
"builtin": true
|
||||
},
|
||||
"rules": {
|
||||
"element-call/copyright-header": [
|
||||
"error",
|
||||
"/*\nCopyright %%CURRENT_YEAR%% Element Creations Ltd.\n\nSPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial\nPlease see LICENSE in the repository root for full details.\n*/\n\n"
|
||||
],
|
||||
"element-call/no-observablescope-leak": "error",
|
||||
"element-call/no-top-level-logger-get-child": "error",
|
||||
"element-call/sdk-import-boundary": "error",
|
||||
"oxc/no-accumulating-spread": "error",
|
||||
"anti-slop/no-array-filter-map": "error",
|
||||
"anti-slop/no-reduce-accumulator-copy": "error",
|
||||
"anti-slop/no-chained-type-assertions": "error",
|
||||
"anti-slop/no-conditional-empty-object-spread": "error",
|
||||
"anti-slop/no-known-value-widening": "error",
|
||||
"anti-slop/no-module-mocking": "error",
|
||||
"anti-slop/no-object-parameters": "error",
|
||||
"anti-slop/no-reflect-apply": "error",
|
||||
"anti-slop/no-reflect-get": "error",
|
||||
"anti-slop/no-runtime-typeof": "error",
|
||||
"anti-slop/no-shape-in-symbol-names": "error",
|
||||
"anti-slop/no-unknown-parameters": "error",
|
||||
"anti-slop/no-unknown-returns": "error",
|
||||
"anti-slop/no-unknown-type-aliases": "error",
|
||||
"anti-slop/no-unsafe-dictionary-type": "error",
|
||||
"anti-slop/no-widen-then-assert": "error",
|
||||
"anti-slop/require-readable-spacing": "error",
|
||||
"anti-slop/require-safety-comment-for-type-assertion": "error",
|
||||
"jsdoc/empty-tags": "error",
|
||||
"jsdoc/check-property-names": "error",
|
||||
"jsdoc/require-param-description": "warn",
|
||||
"react/display-name": "error",
|
||||
// TODO: These React Compiler-based rules joined oxlint's correctness
|
||||
// category in v1.81 and flag many pre-existing patterns, including hooks
|
||||
// like useLatest/useInitial/useReactiveState that intentionally access
|
||||
// refs during render. Re-evaluate them when adopting the React Compiler.
|
||||
"react/immutability": "off",
|
||||
"react/no-deriving-state-in-effects": "off",
|
||||
"react/preserve-manual-memoization": "off",
|
||||
"react/refs": "off",
|
||||
"react/set-state-in-effect": "off",
|
||||
"react/static-components": "off",
|
||||
"react/use-memo": "off",
|
||||
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
|
||||
// "rxjs/no-exposed-subjects": "error",
|
||||
// "rxjs/finnish": [
|
||||
// "error",
|
||||
// {
|
||||
// "names": {
|
||||
// "^this$": false
|
||||
// }
|
||||
// }
|
||||
// ],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"matrix-widget-api/src",
|
||||
"matrix-widget-api/src/",
|
||||
"matrix-widget-api/src/**",
|
||||
"matrix-widget-api/lib",
|
||||
"matrix-widget-api/lib/",
|
||||
"matrix-widget-api/lib/**"
|
||||
],
|
||||
"message": "Please use matrix-widget-api instead"
|
||||
},
|
||||
{
|
||||
"group": [
|
||||
"matrix-js-sdk/src",
|
||||
"matrix-js-sdk/src/",
|
||||
"matrix-js-sdk/src/**",
|
||||
"matrix-js-sdk/lib",
|
||||
"matrix-js-sdk/lib/",
|
||||
"matrix-js-sdk/lib/index"
|
||||
],
|
||||
"message": "Please use matrix-js-sdk instead"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-misused-promises": "error",
|
||||
"typescript/promise-function-async": "error",
|
||||
"typescript/require-await": "error",
|
||||
"typescript/await-thenable": "error",
|
||||
// To help ensure that we get proper vite/rollup lazy loading (e.g. for matrix-js-sdk).
|
||||
"typescript/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
// TODO: These had to be disabled in the eslint -> oxlint migration. Would be nice to
|
||||
// enable them in future or at least document why we're disabling them.
|
||||
"eslint/no-await-in-loop": "off",
|
||||
"eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"import/default": "off",
|
||||
"jsdoc/check-tag-names": "off",
|
||||
"jsx-a11y/prefer-tag-over-role": "off",
|
||||
"promise/no-callback-in-promise": "off",
|
||||
"react/jsx-key": "off",
|
||||
"react/jsx-no-constructed-context-values": "off",
|
||||
"react/no-array-index-key": "off",
|
||||
"react/no-children-prop": "off",
|
||||
"react/no-object-type-as-default-prop": "off",
|
||||
"typescript/no-misused-spread": "off",
|
||||
"typescript/no-useless-default-assignment": "off",
|
||||
"typescript/restrict-template-expressions": "off",
|
||||
"typescript/unbound-method": "off",
|
||||
"vitest/expect-expect": "off",
|
||||
"vitest/no-conditional-expect": "off",
|
||||
"vitest/no-disabled-tests": "off",
|
||||
"vitest/require-mock-type-parameters": "off",
|
||||
"vitest/require-to-throw-message": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/*/**"],
|
||||
"rules": {
|
||||
// In application code we should use the js-sdk logger, never console directly.
|
||||
"no-console": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/test.ts",
|
||||
"**/test.tsx",
|
||||
"**/test-**"
|
||||
],
|
||||
"rules": {
|
||||
// Tests often initialize an ObservableScope in an outer scope in
|
||||
// beforeEach, which is not actually a problem
|
||||
"element-call/no-observablescope-leak": "off",
|
||||
"jsdoc/empty-tags": "off",
|
||||
"jsdoc/check-property-names": "off",
|
||||
"jsdoc/require-param-description": "off",
|
||||
"jsx-a11y/media-has-caption": "off"
|
||||
// TODO: Enable once oxlint supports them.
|
||||
// "jsdoc/check-values": "off",
|
||||
// "jsdoc/check-param-names": "off",
|
||||
// "jsdoc/no-types": "off",
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["playwright/**"],
|
||||
"rules": {
|
||||
// Playwright as a `use` function that has nothing to do with React hooks.
|
||||
"react-hooks/rules-of-hooks": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pnpm-lock.yaml
|
||||
node_modules
|
||||
dist
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
+22
-1
@@ -9,7 +9,28 @@ import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: ["@storybook/addon-docs"],
|
||||
addons: ["@storybook/addon-docs", "@storybook/addon-vitest"],
|
||||
framework: "@storybook/react-vite",
|
||||
// THIS IS IMPORTANT
|
||||
// vitest runs without Vite's normal dependency optimization, so we need to manually include the polyfills for the stories to work.
|
||||
// otherwise we will get: new dependencies optimized: ...
|
||||
// and
|
||||
// ```
|
||||
// [vitest] Vite unexpectedly reloaded a test. This may cause tests to fail, lead to flaky behaviour or duplicated test runs.
|
||||
// For a stable experience, please add mentioned dependencies to your config's `optimizeDeps.include` field manually.
|
||||
// ```
|
||||
// which breaks the storybook ci on the first and only run.
|
||||
viteFinal(config) {
|
||||
config.optimizeDeps = {
|
||||
...config.optimizeDeps,
|
||||
include: [
|
||||
...(config.optimizeDeps?.include ?? []),
|
||||
"vite-plugin-node-polyfills/shims/buffer",
|
||||
"vite-plugin-node-polyfills/shims/global",
|
||||
"vite-plugin-node-polyfills/shims/process",
|
||||
],
|
||||
};
|
||||
return config;
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -7,14 +7,16 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import i18n from "i18next";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import EN from "../locales/en/app.json";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { i18n } from "../src/utils/i18n";
|
||||
import "../src/index.css";
|
||||
|
||||
// Bare-minimum i18n config
|
||||
// Bare-minimum i18n config.
|
||||
// Unlike the app, stories register the instance as react-i18next's default
|
||||
// rather than wrapping every story in an <I18nextProvider>.
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# AGENTS.md — Element Call
|
||||
|
||||
MatrixRTC (MSC4143) + LiveKit video calling, shipped standalone, as a widget in
|
||||
Element Web and Element X, as embedded packages, and as a React component in a
|
||||
host's page. It is the MatrixRTC reference implementation.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
- Features and UI changes need a pre-approved issue. No issue, no review.
|
||||
- Exactly one `PR-*` label per PR. CI enforces it; it drives the changelog.
|
||||
- New behaviour ships with unit tests, a story if it renders, and an e2e spec if it
|
||||
is user-facing. All three.
|
||||
- Reuse an existing component. If you genuinely cannot, say so in the PR body and
|
||||
name what you rejected — never add a new one silently.
|
||||
- Nothing reads the page. No `window.location`, `document.body`, `window.inner*`,
|
||||
`@media`, global `i18next` or the `widget` global — take it from a provider.
|
||||
- The change works standalone, as a widget and as a component. Say what you checked.
|
||||
- Every gate below is green before you push.
|
||||
- Hand off after the first implementation, before the quality pass. Then commit on
|
||||
the user's word where the setup allows it, or hand them the message.
|
||||
|
||||
## Read before you
|
||||
|
||||
| … | … |
|
||||
| -------------------------------- | ---------------------------------------------------------- |
|
||||
| start a task of any size | [docs/agents/workflow.md](docs/agents/workflow.md) |
|
||||
| touch call logic or a view model | [docs/agents/architecture.md](docs/agents/architecture.md) |
|
||||
| write any code | [docs/agents/code-style.md](docs/agents/code-style.md) |
|
||||
| write a test, story or e2e spec | [docs/agents/testing.md](docs/agents/testing.md) |
|
||||
| open a PR | [CONTRIBUTING.md](CONTRIBUTING.md) |
|
||||
| implement from a feature spec | [FEATURES_SPEC/AGENTS.md](FEATURES_SPEC/AGENTS.md) |
|
||||
|
||||
## Gates
|
||||
|
||||
```sh
|
||||
pnpm lint # tsc, oxlint, knip, component externals
|
||||
pnpm format # oxfmt
|
||||
pnpm test # vitest: unit and storybook projects
|
||||
pnpm i18n:check
|
||||
```
|
||||
|
||||
`knip` fails on dead code; a file deliberately inert ahead of its consumer goes in
|
||||
`knip.ts` `ignoreFiles` with a reason.
|
||||
|
||||
## A good PR
|
||||
|
||||
- One slice, under ~400 changed lines, green on its own, linked to its issue.
|
||||
- View model + marble tests, thin view, a story per state, an e2e spec.
|
||||
- Any new shared component called out explicitly, with why nothing existing fit.
|
||||
- Template filled for real: what, why, before/after screenshots, repro steps.
|
||||
- Comments only where the code can't give the reason — see
|
||||
[code-style.md](docs/agents/code-style.md#comments).
|
||||
- Anything that redraws continuously — an animation frame, a stream — says what it
|
||||
costs, or better, counts the redraws in a test.
|
||||
- Branch `<handle>/<topic>`. Plain imperative commit subjects, no prefixes.
|
||||
- Once review starts, fix forward. Never force-push a regeneration over a review.
|
||||
@@ -1,3 +1,71 @@
|
||||
# Contributing code to Element
|
||||
|
||||
Element follows the same pattern as the [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md).
|
||||
|
||||
# Contributing to Element Call
|
||||
|
||||
Element Call is a native Matrix video conferencing application built on
|
||||
[MatrixRTC (MSC4143)](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)
|
||||
and [LiveKit](https://livekit.io/). It runs in multiple deployment contexts — as a
|
||||
standalone web app and as a widget embedded in Element Web, Element X iOS, and
|
||||
Element X Android. It is also the primary R&D foundation for MatrixRTC, which means
|
||||
its architecture, maintainability, and flexibility are held to a high standard.
|
||||
|
||||
We welcome contributions from the community. This document explains how to
|
||||
contribute effectively so that both you and the maintainers get the best outcome.
|
||||
|
||||
## Issue First Policy
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Before writing a single line of code for a new feature or UI change, you **must**
|
||||
> open an issue and have the approach agreed with the maintainers.
|
||||
>
|
||||
> **We will not review or merge feature or UI pull requests that arrive without a
|
||||
> corresponding, pre-approved issue.**
|
||||
|
||||
This is not gatekeeping — it's how we prevent wasted effort on both sides. Element
|
||||
Call must work correctly across multiple deployment contexts and meet specific product
|
||||
and design requirements. It is also a fast-moving codebase that underpins ongoing
|
||||
MatrixRTC development. A PR that looks reasonable in isolation can easily conflict
|
||||
with in-progress work, planned architecture changes, or design decisions that haven't
|
||||
been publicly documented yet.
|
||||
|
||||
The issue is where we resolve all of that **before** anyone writes code.
|
||||
|
||||
**Bug fixes** are no exception — most confirmed bugs should already have an issue anyways, existing issues that are marked as bugs have an implicit maintainer approval. If the solution for the bug is controversial it is highly recommended to discuss the approach in the issue before opening a PR.
|
||||
|
||||
## Contribution Workflow
|
||||
|
||||
1. **Open an issue** using the [Enhancement request](https://github.com/element-hq/element-call/issues/new?template=enhancement.yml) template.
|
||||
2. **Wait for feedback.** A maintainer will comment on the issue **within two weeks**. The use case and approach will get dicussed.
|
||||
This may involve questions, suggestions, or a request to adjust scope.
|
||||
This also allows to bring design and product into the loop before code gets created.
|
||||
3. **Get a green light.** Wait for explicit approval from a maintainer before starting
|
||||
implementation.
|
||||
4. **Implement.** Write the code against the agreed approach.
|
||||
5. **Open a PR.** Link to the issue in your PR description and satisfy the checklist
|
||||
in the PR template.
|
||||
|
||||
## Code Quality
|
||||
|
||||
Element Call moves fast and the codebase must stay clean and maintainable.
|
||||
|
||||
- **Take responsibility for AI-generated code.** AI tools can be a useful aid, but we expect all the generated code to be understood and reasoned about by the contributor. Questions by the maintainers should be answered without just forwarding them to AI. The maintainers also have access to AI tools. If your contribution is just transporting messages between LLM <-> maintaines all our time is better used if the maintainers decide to interact with AI for this specific problem by themselves.
|
||||
- **Think across deployment contexts.** Changes must work correctly in both standalone
|
||||
and widget modes. Consider how your change interacts with Element Web, Element X
|
||||
iOS, and Element X Android.
|
||||
- **Write tests.** New functionality should be covered by tests. Where it is feasible,
|
||||
existing uncovered code touched by your PR should also gain tests.
|
||||
|
||||
## Contributor License Agreement
|
||||
|
||||
All contributors must sign the
|
||||
[Element Contributor License Agreement](https://cla-assistant.io/element-hq/element-call)
|
||||
before their contribution can be merged. The CLA assistant bot will prompt you
|
||||
automatically when you open a PR.
|
||||
|
||||
## Getting Help
|
||||
|
||||
The best place to ask questions about Element Call development is the MatrixRTC room:
|
||||
|
||||
**[#matrixRtc:matrix.org](https://matrix.to/#/#matrixrtc:matrix.org)**
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
# Merging PR #4331 onto the SDK / Element Call split
|
||||
|
||||
How [element-hq/element-call#4331](https://github.com/element-hq/element-call/pull/4331)
|
||||
("LiveKit updates": MSC4195 client-server API tokens, `url` transports, delegation
|
||||
through the homeserver, a transport dev override) lands on `toger5/use-sdk`, where
|
||||
the MatrixRTC logic lives in `sdk/` and Element Call consumes it through
|
||||
`sdk/src/api.ts`. The PR was written against `main`, where all of this still sat
|
||||
in `src/state/CallViewModel/`. This document goes through the PR commit by commit,
|
||||
says whether each one lands in `sdk/` or `src/`, and how; the section
|
||||
[Changes to `api.ts`](#changes-to-apits) covers the public interface in full. The
|
||||
end state: this branch behaves exactly as the PR does, with the split and the
|
||||
`api.ts` seam intact.
|
||||
|
||||
The PR's branch is checked out at `agent-workspace/pr4331-merge/worktree` for
|
||||
comparison. The `matrix-js-sdk` dependency is pinned to the same `develop`
|
||||
commit the PR uses (`4df2f2bf`, 43.0.0), which brings `UnstableLivekitTransport`,
|
||||
`CallMembership.backendIdentities`, `_unstable_getLivekitToken` and
|
||||
`_unstable_delegateDelayedLeave`. That bump also renamed
|
||||
`MatrixRTCSession.leaveRoomSession` to `leave`, which the SDK now calls.
|
||||
|
||||
## Where each commit lands
|
||||
|
||||
| # | Commit | Lands in | How |
|
||||
| --- | ------------------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Remove stray pause and improve logging for hung e2e tests | e2e | Applied as is: `playwright.config.ts`, `playwright/widget/federated-call.test.ts`. |
|
||||
| 2 | Fix typo in the name of the createJWTToken Playwright fixture | e2e | Applied as is. |
|
||||
| 3 | Advertise the loopback address from the dev LiveKit SFUs | backend | Applied as is: `backend/dev_livekit*.yaml`. |
|
||||
| 4 | Match LiveKit participants against all backend identities | sdk | `RTCMember.backendIdentities` (new, see api.ts). `LivekitBackend.mediaFor` matches `member.backendIdentities.includes(p.identity)`; `RTCParticipation.data$` maps a packet's sender the same way. `mockRtcMembership` in both `sdk/src/utils/test.ts` and `src/utils/test.ts` takes the new 4-argument constructor and a `backendIdentities` override. |
|
||||
| 5 | Model ConnectionState as a discriminated union | sdk | `Connection.ts` state is `{state: "initialized" \| "authenticating" \| "authenticated" (+ `livekitState`) \| "error" (+ `error`) \| "stopped"}`. Internal to the LiveKit backend: `connectionState.ts` maps it onto the backend-neutral `MediaConnectionState`, so nothing above the backend changes. `DataChannel.ts` and `ConnectionFactory.ts` follow. |
|
||||
| 6 | Show the whole transport in debug UI and logs | sdk + src | SDK log lines use `JSON.stringify(transport)`. Element Call: `focusUrl$: Behavior<string>` on the member media view models becomes `transport$: Behavior<Transport>` (`TransportMetadata.raw`), carried through `GridTile`, `SpotlightTile`, `MediaView` to `RTCConnectionStats`, which prints the JSON. The old code showed `extractDomain(transport.id)`, which on this branch was already a JSON string, so this also fixes a latent bug. |
|
||||
| 7 | Group livekitRoomItems$ by connection | n/a | `livekitRoomItems$` and `LivekitRoomAudioRenderer` do not exist on this branch: audio renders from `tracks$` (`MemberAudioRenderer`). Nothing to port. |
|
||||
| 8 | Move SFU authentication into livekit/auth and choose the endpoint by role | sdk | `sdk/src/media-backend/livekit/auth/{index,openID,csApi,types}.ts` replace `openIDSFU.ts`. Publisher → legacy `/sfu/get`; subscriber → `/get_token`. `slotId` replaces the hard-coded `m.call#ROOM`: `MediaBackendContext.slotId`, taken from the js-sdk session in `RTCParticipation`. The `tokenEndpoint` option of `createLivekitBackend` and the derivation from `MatrixRTCMode` are gone. |
|
||||
| 9 | Let the publishing Connection authenticate itself | sdk | Every `Connection` has a `role` and fetches its own token; `existingSFUConfig` is gone. `prepareLocalTransport` no longer fetches a token itself: it starts the publisher connection (through the connection map) and resolves once that connection reports `authenticated`, rejecting with its error otherwise. Since `LocalMembership` joins only after `preparedTransport$` resolves, the join waits for the publisher's token exactly as the PR's `authenticated$` gate does. |
|
||||
| 10 | Reduce ConnectionManager to a ConnectionMap | sdk | `ConnectionMap.ts` replaces `ConnectionManager.ts`; `createConnectionMap$` takes a resolved local transport, and `LivekitBackend` wraps it in `mapScoped` over the local transport, as the PR's `CallViewModel` did. `LocalMedia.ts`, `DataChannel.ts` take `connectionMap$`. |
|
||||
| 11 | Use the unstable LiveKit transport type | sdk + src | `UnstableLivekitTransport` / `isUnstableLivekitTransport` throughout the backend; `src/settings/settings.ts`, `DeveloperSettingsTab`, test helpers. Transport discovery in `sdk/src/matrixrtc/` is now backend neutral (filters by `type`, see commit 15). |
|
||||
| 12 | Identify transports by server name as well as URLs | sdk | `TransportLocator {transport, serverName}` in `auth/types.ts`; `ConnectionMap.keyFor` keys on `(url, livekit_service_url, serverName)`. The server name is derived from the user id (`serverNameOf`) for remote members and from our own membership identity for the local transport, so neither `api.ts` nor the backend options need it. |
|
||||
| 13 | Pin matrix-js-sdk to the member_id branch | superseded | By commit 19; the lockfile pins the PR's final `develop` commit. |
|
||||
| 14 | Get SFU tokens and delegate delayed leaves through the MSC4195 CS API | sdk | `auth/csApi.ts` (`_unstable_getLivekitToken`, `_unstable_delegateDelayedLeave`), `auth/index.ts` facade with the fallback rules. `LivekitBackend.delegateDelayedLeave` goes through the facade; the delegation probe asks only the service the publisher flow uses. `LivekitBackendOptions.client` picks the two new client methods. |
|
||||
| 15 | Let developers override the whole transport | sdk + src | `RTCParticipationOptions.transport` / `fallbackTransport` take a `Transport` instead of `transportUrl` / `fallbackTransportUrl` (see api.ts). Element Call: setting `custom-transport` (JSON) replaces `custom-livekit-url`; `DeveloperSettingsTab` edits JSON and validates through `authenticateWithTransport`; `InCallView` turns `config.livekit.livekit_service_url` into the fallback transport. Locale keys `developer_mode.custom_transport.*`. |
|
||||
| 16 | Deprecate the livekit.livekit_service_url config option | src | Applied as is in `ConfigOptions.ts`. |
|
||||
| 17 | Cover url transports end to end | e2e + backend | `playwright/url-transport.spec.ts`, `backend/app-service*.yaml`, `docker-compose-dev.yml` (lk-jwt-service 0.8.0) copied as is. |
|
||||
| 18 | Retain compatibility in compatibility mode | superseded | Reverted by commit 23 inside the PR; only its e2e spec edits survive, in the final spec file. |
|
||||
| 19 | Switch JS SDK back to develop | deps | `pnpm-lock.yaml` pins `4df2f2bf` and `matrix-widget-api` 1.20.0; `package.json` keeps `#develop`. |
|
||||
| 20 | Fall back to get_token as subscriber on widget API errors | sdk | `isCsApiUnavailable` in `auth/index.ts`; `doNetworkOperationWithRetry(op, shouldRetry)` in `sdk/src/utils/network.ts` with `isNetworkError` in `csApi.ts`. |
|
||||
| 21 | Share a connection between homeservers using the same JWT service | sdk | `keyFor` ignores the server name for transports without a `url`; `removeDuplicateTransports` keeps the first locator so the local one wins. |
|
||||
| 22 | Avoid null in transport dev setting | src | `transportText()` in `DeveloperSettingsTab.tsx`. |
|
||||
| 23 | Make the auth choice mode independent again | sdk | `chooseAuthFlow(transport, role)` has no mode parameter; `config.ts` documents that `MatrixRTCMode` decides how the membership is written, not how the transport is authenticated. |
|
||||
| 24 | Cover identity matching and delegation across transport shapes | sdk + e2e | Unit tests in `LivekitBackend.test.ts` (see below); `playwright/url-transport.spec.ts` final version. |
|
||||
| 25 | Cover the subscriber token path, widget fallback and server names | sdk + e2e | `auth/openID.test.ts` subscriber cases; `playwright/widget/url-transport.test.ts`; the server-name test lives in `LivekitBackend.test.ts` ("connects to a remote member's transport on the member's homeserver"). |
|
||||
|
||||
## Changes to `api.ts`
|
||||
|
||||
Three changes to the public SDK interface, all forced by the PR's behaviour.
|
||||
|
||||
### `RTCMember.backendIdentities` (new)
|
||||
|
||||
```ts
|
||||
/** The membership's `rtcBackendIdentity`, `${userId}:${deviceId}`: a key and a debug label. */
|
||||
rtcBackendIdentity: string;
|
||||
/**
|
||||
* Every identity the member may publish under on its transport: the
|
||||
* participant identity the SFU sees, and the sender of its data packets.
|
||||
* One per authentication flow the member's transport allows, so a transport
|
||||
* with both a `url` and a `livekit_service_url` lists the hashed and the
|
||||
* legacy identity, and the backend matches whichever shows up.
|
||||
*/
|
||||
backendIdentities: readonly string[];
|
||||
```
|
||||
|
||||
Commit 4 of the PR. A member on a transport that carries both a `url` and a
|
||||
`livekit_service_url` may have authenticated through either flow and so has
|
||||
two candidate identities; the js-sdk now lists them on the membership. The
|
||||
backend matches media and data packets against the list.
|
||||
`rtcBackendIdentity` stays as the stable per-membership key Element Call uses
|
||||
for tile ids and the debug overlay, and its doc says that it is no longer
|
||||
what the backend matches on. Read off the membership, like the single identity
|
||||
before it; the backend will derive both once the identity scheme is its own.
|
||||
|
||||
### `RTCParticipationOptions.transport` / `fallbackTransport` replace the url options
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Use this transport instead of asking the homeserver. Any shape the
|
||||
* backend serves: for LiveKit a `url`, a `livekit_service_url` or both.
|
||||
*/
|
||||
transport?: Transport;
|
||||
/** Use this transport when the homeserver advertises none of the backend's type. */
|
||||
fallbackTransport?: Transport;
|
||||
```
|
||||
|
||||
Commit 15 of the PR. The developer override has to carry a whole transport so
|
||||
that a `url`-only or combined shape can be tested against a homeserver that
|
||||
does not advertise one yet; a URL cannot say which shape it is. This also
|
||||
removes the one piece of LiveKit knowledge that sat outside the backend:
|
||||
`LocalTransport.ts` and `TransportAutoDiscovery.ts` no longer build a
|
||||
`{type: "livekit", livekit_service_url}` object and filter by `transportType`
|
||||
only, which `LocalTransport.ts` had marked as the moment to do this. The
|
||||
host turns its config URL into a transport (`InCallView.rtcParticipationOptions`).
|
||||
|
||||
### `TransportMetadata` is unchanged, with one documented limit
|
||||
|
||||
`TransportMetadata.id` is still the raw transport serialised with sorted keys.
|
||||
The LiveKit backend now keys its connections on the transport plus the server
|
||||
name where the transport has a `url` (commits 12 and 21), so two homeservers
|
||||
advertising the same `url` get two connections but one `TransportMetadata`.
|
||||
`connectedTransports$` deduplicates, and `resolved$` reports the first
|
||||
connection's resolution. This is diagnostics only; the media and data paths
|
||||
go through the backend's own keys. Extending `TransportMetadata` with the
|
||||
server name was left out to keep the type transport neutral; a developer panel
|
||||
that needs it can be given a `serverName` on `BackendConnection` later.
|
||||
|
||||
### Related, outside `api.ts`
|
||||
|
||||
- `media-backend/api.ts`: `MediaBackendContext.slotId` (commit 8). The
|
||||
client-server API issues a token for a slot, so the backend needs it;
|
||||
`RTCParticipation` reads it from the js-sdk session.
|
||||
- `sdk/index.ts`: `authenticateWithTransport` is now the auth facade's
|
||||
`getSFUConfig({client, membership, transport, serverName, roomId, slotId, role, logger})`
|
||||
(object parameter, whole transport, server name); `AuthenticateWithTransportParams`
|
||||
is exported for it; `TokenEndpoint` is gone.
|
||||
- `createLivekitBackend` options: `tokenEndpoint` is gone; `client` now picks
|
||||
`_unstable_getLivekitToken` and `_unstable_delegateDelayedLeave` as well.
|
||||
- `config.ts`: the `MatrixRTCMode` doc no longer claims the mode picks the
|
||||
JWT endpoint (commit 23).
|
||||
|
||||
## Behaviour, side by side
|
||||
|
||||
| Behaviour | PR on `main` | This branch |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Endpoint choice | `chooseAuthFlow` in `src/livekit/auth` | Same function, `sdk/src/media-backend/livekit/auth/index.ts` |
|
||||
| Join waits for the publisher's token | `authenticated$` gate in `LocalMember.ts` | `prepareLocalTransport` resolves on the publisher connection's `authenticated` state; `LocalMembership` joins after it |
|
||||
| Delegation probe | The flow's service only, in `LocalMember.joinParams$` | The flow's service only, in `prepareLocalTransport` |
|
||||
| Delegation handover | `delegateDelayedLeave` facade from `LocalMember` | Same facade from `LivekitBackend.delegateDelayedLeave`, with the delay timeout from the session timings instead of `Config` |
|
||||
| Transport keys and connection sharing | `ConnectionMap.keyFor` on `TransportLocator` | Same, moved |
|
||||
| Server names | `membershipsAndTransports$` from the user id; `getLocalTransport` from `client.getDomain()` | `serverNameOf(userId)` for members; our own membership identity's user id for the local transport |
|
||||
| Identity matching | `membership.backendIdentities.includes(p.identity)` | `member.backendIdentities.includes(p.identity)` in `mediaFor` and for data packets |
|
||||
| Dev transport override | `customTransport` setting → `getLocalTransport` | `customTransport` setting → `RTCParticipationOptions.transport` |
|
||||
| Config fallback | `RtcTransportAutoDiscovery.tryConfigTransport` | Host builds the transport, `RTCParticipationOptions.fallbackTransport` |
|
||||
| Error for a `url` host without MSC4195 | `NoMatrix2AuthorizationService` from the publisher connection | Same error, surfaced through `prepareLocalTransport` → `state$ failed` → `fromMatrixRTCError` |
|
||||
|
||||
One deliberate difference: on `main` the publisher connection fetches its token
|
||||
on every (re)creation and nothing else does; here it is the same, since the
|
||||
up-front token fetch in `prepareLocalTransport` is gone. There is no second
|
||||
token request per join.
|
||||
|
||||
## Where the PR's new tests went
|
||||
|
||||
| PR test | Here |
|
||||
| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/livekit/auth/index.test.ts` | `sdk/src/media-backend/livekit/auth/index.test.ts`. Rewritten on the network layer (fetch-mock for the JWT service, mocked client methods) because a `vi.mock` of the sibling `openID` module does not reach the facade's own import under `sdk/`. Same cases, plus a table for `chooseAuthFlow`. |
|
||||
| `src/livekit/auth/openID.test.ts` | `sdk/src/media-backend/livekit/auth/openID.test.ts`, replacing `openIDSFU.test.ts` |
|
||||
| `ConnectionMap.test.ts` (incl. commit 21's sharing tests) | `sdk/src/media-backend/livekit/ConnectionMap.test.ts`, replacing `ConnectionManager.test.ts`; plus a role-tagging test |
|
||||
| `Connection.test.ts` changes | `sdk/src/media-backend/livekit/Connection.test.ts`; plus a publisher-through-the-JWT-service case |
|
||||
| `LocalMember.test.ts` "joins session with %s delegation support" (by transport shape) | `LivekitBackend.test.ts` "prepares the local transport and asks %s about delegation", "waits for the publisher connection to hold its token before the join", "fails the preparation with the publisher connection's error", "delegates the leave through the flow that authenticated the publisher" |
|
||||
| `MatrixLivekitMembers.test.ts` two-candidate-identity tests | `LivekitBackend.test.ts` › `mediaFor` |
|
||||
| `SessionBehaviors.test.ts` server-name test | `LivekitBackend.test.ts` "connects to a remote member's transport on the member's homeserver" |
|
||||
| `LocalTransport.test.ts` / `RtcTransportAutoDiscovery.test.ts` changes | `sdk/src/matrixrtc/TransportAutoDiscovery.test.ts` (fallback is a transport) |
|
||||
| `DeveloperSettingsTab.test.tsx` custom transport cases | `src/settings/DeveloperSettingsTab.test.tsx`, snapshot updated for the label |
|
||||
| `DataChannel`, `LivekitBackend` registry tests | Adapted to the state union and `TransportLocator` |
|
||||
| `playwright/url-transport.spec.ts`, `playwright/widget/url-transport.test.ts` | Copied as is: they drive the app, which behaves the same |
|
||||
|
||||
The PR's `CallViewModel.test.ts`, `CallViewModelTestUtils.ts`, `LocalMember.test.ts`
|
||||
(the rest) and `MatrixAudioRenderer.test.tsx` edits have no counterpart: those
|
||||
modules were replaced by the SDK before this merge.
|
||||
|
||||
## One fix the e2e run forced
|
||||
|
||||
The bumped js-sdk sets `rtcBackendIdentity` to the legacy `${userId}:${deviceId}`
|
||||
identity for every membership (it is deprecated there; the hashed one now only
|
||||
appears in `backendIdentities`). On this branch the call view model keyed its
|
||||
tiles by `[dup, mediaId, rtcBackendIdentity]`, so a device that rejoined after an
|
||||
improper leave, which has two memberships until the stale one expires, produced
|
||||
duplicate keys and crashed the host's page with `UNKNOWN_ERROR`
|
||||
(`spa-call-sticky.spec.ts`, "rejoin after improper leave"). The key is now the
|
||||
membership's own `memberId`, and `MemberAudioRenderer` and the SDK harness key
|
||||
by it too. The PR on `main` keyed by the participant object as well and did not
|
||||
hit this.
|
||||
|
||||
## Coverage added on the package branch, and one bug it found
|
||||
|
||||
The PR's tests mostly covered the auth layer; the SDK modules under them had
|
||||
little or none. Added on the package branch, which this branch is rebased onto:
|
||||
|
||||
| Module | Test |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `media-backend/livekit/Publisher.ts` | `Publisher.test.ts`: the cases from `main`'s `localMember/Publisher.test.ts` on a real `LocalParticipant` with faked device and signalling calls, plus publish, unpublish and destroy |
|
||||
| `media-backend/livekit/LocalMedia.ts` | `LocalMedia.test.ts`: on a fake connection map; publish before and after the connection, republish on a new connection, mutes remembered, first publish error, output device |
|
||||
| `RTCParticipation.ts` | `RTCParticipation.test.ts`: on a fake media backend; join options, state machine, publishing only while joined, delegation, member ids per mode, key replay, data sender check |
|
||||
| `matrixrtc/LocalTransport.ts` | `LocalTransport.test.ts`: override, homeserver, fallback, none |
|
||||
| `matrixrtc/joinJsSdkSession.ts` | `joinJsSdkSession.test.ts`: the js-sdk join options, the retry budget, the intent and sticky events |
|
||||
| `media-backend/livekit/publishOptions.ts` | `publishOptions.test.ts` |
|
||||
| `media-backend/livekit/ConnectionFactory.ts` | `ConnectionFactory.test.ts`: the room options from the requests and media quality (the cases from `main`'s `ECConnectionFactory.test.ts`), `resolved$` |
|
||||
| `LivekitBackend.ts` › `mediaFor` | A member on another transport, a member changing transport, a member without one (from `main`'s `MatrixLivekitMembers.test.ts`) |
|
||||
|
||||
`ConnectionFactory.test.ts` reads the options off the real LiveKit room the
|
||||
factory builds instead of mocking `livekit-client`: a `vi.mock` of the package
|
||||
reached the factory's import on the package branch but not on this one.
|
||||
|
||||
The `LocalMedia` tests found a race in `ObservableScope.reconcile`: the loop was
|
||||
marked as running until the promise of its last run had settled, a few
|
||||
microtasks after it had last checked for new values, so a value arriving in
|
||||
that window (a connection appearing at just that moment) was never processed.
|
||||
Fixed in `[SDK] Keep a value that reaches reconcile as its loop winds down`,
|
||||
with a test that drives the window microtask by microtask. Element Call's
|
||||
`src/state/ObservableScope.ts` re-exports the SDK's class, so the fix applies
|
||||
to the app as well.
|
||||
|
||||
## Checked
|
||||
|
||||
- `pnpm lint` (tsc, oxlint, knip, externals), `pnpm format:check`, `pnpm i18n:check`.
|
||||
- `pnpm test`: 119 unit files, 950 tests; 4 storybook files, 45 tests (after the coverage above).
|
||||
- Playwright on Chromium against the local Playwright backend, one worker:
|
||||
- `playwright/url-transport.spec.ts`: 10 of 10 pass, including delegation
|
||||
through the homeserver and the JWT service, the subscriber fallback, the
|
||||
publisher error and the federated token.
|
||||
- `playwright/widget/url-transport.test.ts`: passes.
|
||||
- The rest of `playwright/*.spec.ts`, `playwright/component`, `playwright/sdk`:
|
||||
48 pass, 1 skipped, 1 fails (`reconnect.spec.ts`, which fails on `main`
|
||||
locally too because the local JWT service is slower than the spec's wait).
|
||||
- `playwright/widget`: 7 of 10 pass, including `federated-call`. `huddle-call`
|
||||
fails before any call code runs (the Element Web develop image's room list no
|
||||
longer matches the helper's "Open room" selector), and `screen-share` looks
|
||||
for `video[data-lk-source="screen_share"]`, an attribute the LiveKit React
|
||||
components set and the SDK's plain `<video>` elements do not: a failure of this
|
||||
branch that predates the PR.
|
||||
- Local environment work needed for that: the othersite JWT service and nginx
|
||||
containers had to be recreated from the compose file, two stale containers held
|
||||
port 16080, and the widget helper's hard-coded `https://localhost:3000` was
|
||||
pointed at 3100 for the run only, since port 3000 is taken on this machine.
|
||||
- Standalone, widget and component all go through `InCallView.rtcParticipationOptions`
|
||||
and the SDK, so the change is the same in all three modes; the widget-specific
|
||||
fallback (a host without MSC4533) is in `isCsApiUnavailable`.
|
||||
@@ -34,15 +34,16 @@ You can find the latest development version continuously deployed to
|
||||
## ✨ Key Features
|
||||
|
||||
✅ **Decentralized & Federated** – No central authority; works across Matrix
|
||||
homeservers.
|
||||
✅ **End-to-End Encrypted** – Secure and private calls.
|
||||
✅ **Standalone & Widget Mode** – Use as an independent app or embed in Matrix
|
||||
clients.
|
||||
✅ **WebRTC-based** – No additional software required.
|
||||
homeservers.
|
||||
✅ **End-to-End Encrypted** – Secure and private calls.
|
||||
✅ **Standalone, Widget & Component Mode** – Use as an independent app, embed
|
||||
in Matrix clients as a widget, or (experimentally) mount it as a React component
|
||||
inside your own application.
|
||||
✅ **WebRTC-based** – No additional software required.
|
||||
✅ **Scalable with LiveKit** – Supports large meetings via SFU
|
||||
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
|
||||
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
|
||||
✅ **Raise Hand** – Participants can signal when they want to speak, helping to
|
||||
organize the flow of the meeting.
|
||||
organize the flow of the meeting.
|
||||
✅ **Emoji Reactions** – Users can react with emojis 👍️ 🎉 👏 🤘, adding
|
||||
engagement and interactivity to the conversation.
|
||||
|
||||
@@ -66,7 +67,7 @@ requiring a separate Matrix client.
|
||||
|
||||
### 📲 In-App Calling (Widget Mode in Messenger Apps)
|
||||
|
||||
When used as a widget 🧩, Element Call is solely responsible on the core calling
|
||||
When used as a widget 🧩, Element Call is solely responsible for the core calling
|
||||
functionality (MatrixRTC). Authentication, event handling, and room state
|
||||
updates (via the Client-Server API) are handled by the hosting client.
|
||||
Communication between Element Call and the client is managed through the widget
|
||||
@@ -90,7 +91,9 @@ and voice calls within Matrix rooms.
|
||||
|
||||
Element Call offers two packaging options: one for standalone or widget
|
||||
deployment, and another for seamless widget-based integration into messenger
|
||||
apps. Below is an overview of each option.
|
||||
apps. A third, experimental option builds it as a React component library for
|
||||
applications that want to render a call inside their own page rather than in an
|
||||
iframe. Below is an overview of each option.
|
||||
|
||||
**Full Package** – Supports both **Standalone** and **Widget** mode. It is
|
||||
hosted as a static web page and can be accessed via a URL when used as a widget.
|
||||
@@ -107,6 +110,11 @@ recommended method for embedding Element Call.
|
||||
<img src="./docs/embedded_package.drawio.png" alt="Element Call Embedded Package">
|
||||
</p>
|
||||
|
||||
**Component Package (experimental)** – A library build of Element Call as a
|
||||
React component, consumed as a dependency by a host application that already
|
||||
has a Matrix client. See
|
||||
[Element Call as a component](#element-call-as-a-component-experimental) below.
|
||||
|
||||
For more details on the packages, see the
|
||||
[Embedded vs. Standalone Guide](./docs/embedded_standalone.md).
|
||||
|
||||
@@ -115,30 +123,33 @@ For more details on the packages, see the
|
||||
For operating and deploying Element Call on your own server, refer to the
|
||||
[**Self-Hosting Guide**](./docs/self_hosting.md).
|
||||
|
||||
## 🧭 MatrixRTC Backend Discovery and Selection
|
||||
## MatrixRTC Transports
|
||||
|
||||
For proper Element Call operation each site deployment needs a MatrixRTC backend
|
||||
setup as outlined in the [Self-Hosting](#self_hosting). A typical federated site
|
||||
For proper operation of Element Call, each deployment needs to set up a
|
||||
MatrixRTC transport in the form of a LiveKit server as outlined in the
|
||||
[Self-Hosting Guide](./docs/self_hosting.md). A typical federated site
|
||||
deployment for three different sites A, B and C is depicted below.
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/Federated_Setup.drawio.png" alt="Element Call federated setup">
|
||||
</p>
|
||||
|
||||
### Backend Discovery
|
||||
### Transport Discovery
|
||||
|
||||
MatrixRTC backend (according to
|
||||
[MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)) is
|
||||
announced by the Matrix site's `.well-known/matrix/client` file and discovered
|
||||
via the `org.matrix.msc4143.rtc_foci` key, e.g.:
|
||||
Element Call discovers the available MatrixRTC transports (as defined by
|
||||
[MSC4519](https://github.com/matrix-org/matrix-spec-proposals/pull/4519)) by
|
||||
hitting the `GET /_matrix/client/unstable/org.matrix.msc4143/rtc/transports`
|
||||
endpoint of the Client-Server API. An example response:
|
||||
|
||||
```json
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"rtc_transports": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://matrix-rtc.example.com/livekit/jwt"
|
||||
},
|
||||
]
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://matrix-rtc.example.com/livekit/jwt"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
where the format for MatrixRTC using LiveKit backend is defined in
|
||||
@@ -149,13 +160,12 @@ via `livekit_service_url`.
|
||||
|
||||
### Backend Selection
|
||||
|
||||
- Each call participant proposes their discovered MatrixRTC backend from
|
||||
- Each call participant proposes their discovered MatrixRTC transport from
|
||||
`org.matrix.msc4143.rtc_foci` in their `org.matrix.msc3401.call.member` state event.
|
||||
- For **LiveKit** MatrixRTC backend
|
||||
- For the **LiveKit** MatrixRTC backend
|
||||
([MSC4195](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)),
|
||||
the **first participant who joined the call** defines via the `foci_preferred`
|
||||
key in their `org.matrix.msc3401.call.member` which actual MatrixRTC backend
|
||||
will be used for this call.
|
||||
the **first participant who joined the call** defines which backend will be used for this call via
|
||||
the `foci_preferred` key in their `org.matrix.msc3401.call.member` state event.
|
||||
- During the actual call join flow, the **[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)**
|
||||
provides the client with the **LiveKit SFU WebSocket URL** and an
|
||||
**access JWT token** in order to exchange media via WebRTC.
|
||||
@@ -178,6 +188,13 @@ discuss and coordinate translation efforts.
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Node.js (e.g. via [nvm](https://github.com/nvm-sh/nvm))
|
||||
- [Corepack](https://github.com/nodejs/corepack) (not bundled with Node.js anymore starting from 25.0.0)
|
||||
- Docker client and runtime + Docker Compose (for the backend)
|
||||
- On macOS you can install everything with `brew install colima docker docker-compose`
|
||||
|
||||
### Frontend
|
||||
|
||||
To get started clone and set up this project:
|
||||
@@ -202,49 +219,165 @@ pnpm dev
|
||||
|
||||
See also:
|
||||
|
||||
- [Developing with linked packages](./linking.md)
|
||||
- [Developing with linked packages](./docs/linking.md)
|
||||
|
||||
#### Element Call as a component (experimental)
|
||||
|
||||
Element Call can also be embedded directly into another React application
|
||||
rather than being loaded in an iframe as a widget. `pnpm build:component`
|
||||
builds it as a library into `component/dist` (the bundle, its stylesheet and
|
||||
type declarations), and
|
||||
|
||||
```sh
|
||||
pnpm dev:component
|
||||
```
|
||||
|
||||
serves a harness on port 3001 that stands in for such an application: it signs
|
||||
in twice against the development backend and shows two calls side by side, in
|
||||
resizable boxes, with page furniture of its own around them. Use it to see how
|
||||
Element Call behaves when it does not own the page — the size it is given,
|
||||
whether it stays inside its container, and what it says to its host, which is
|
||||
logged along the bottom. The harness is served with the same development
|
||||
certificate as the app, so unless the development CA is trusted, the browser
|
||||
needs a certificate exception for `https://localhost:3001` as well (see the
|
||||
note under [Backend](#backend)). It reads the same `public/config.json` as
|
||||
`pnpm dev` if one exists, and runs with Element Call's defaults otherwise.
|
||||
|
||||
The call lays itself out for the size of the element it is mounted in, not the
|
||||
window: a host that shrinks the container to a corner of its page gets the
|
||||
picture-in-picture layout, just as a host that shrank the whole iframe used to.
|
||||
The breakpoints in the stylesheets the component uses are
|
||||
`@container element-call` queries against its root element for the same reason;
|
||||
for the standalone app the root is the page, so they mean what the media queries
|
||||
they replaced did. (The standalone-only views, such as the home and login pages,
|
||||
still use plain media queries, since the component never shows them.)
|
||||
|
||||
The component's stylesheet is confined to the element it is mounted in: the
|
||||
build rewrites every selector so that it matches only Element Call's root or
|
||||
what is inside it, with `html`, `body` and `:root` standing for that root (see
|
||||
`component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles,
|
||||
and Element Call brings its own fonts and design tokens along.
|
||||
|
||||
The component speaks every language the app does. English is bundled in; the
|
||||
other locales are split into chunks the host's bundler loads the first time
|
||||
they are needed. It starts in the browser's language, and follows the host's
|
||||
own language setting through the `language` prop (`supportedLanguages` lists
|
||||
the tags it accepts, and anything else falls back to its base language or to
|
||||
English). The `theme` prop works the same way and takes the same values as the
|
||||
widget's `theme` URL parameter: `light`, `dark`, `light-high-contrast` or
|
||||
`dark-high-contrast`. Both can change while a call is running without
|
||||
disturbing it.
|
||||
|
||||
A host must call and await `initializeElementCall(config)` once before
|
||||
rendering the component: it loads the `Intl` polyfills, applies the
|
||||
deployment-wide `config.json`-style configuration and sets up translations.
|
||||
The component itself takes the host's `client` and the `roomId` to call in, an
|
||||
`intent` saying what the user asked for (which decides whether to show the
|
||||
lobby, ring, and so on), an optional `config` overriding what the intent
|
||||
implies, and an optional `hostBridge` through which Element Call tells the host
|
||||
that the user has joined or hung up, that it wants to stay on screen, and so
|
||||
on. The host makes its own requests (`join`, `hangUp`, `setDeviceMute`) through
|
||||
the handle exposed on `ref`. The full API is documented in the type declarations
|
||||
(`component/index.tsx` and `component/host.ts`).
|
||||
|
||||
A few things differ from the widget on purpose: the component draws a solid
|
||||
background rather than a gradient unless told otherwise, never offers to edit
|
||||
the user's profile (the account is the host's), scopes its keyboard shortcuts to
|
||||
its own root element so that several instances can share a page, and only shows
|
||||
its own post-call and error screens when the host has not supplied a `close()`
|
||||
callback; with one, it asks the host to unmount it instead. The
|
||||
[global JS controls](./docs/controls.md) on `window` are unchanged and remain
|
||||
page-wide, so with several instances on one page they apply to all of them.
|
||||
|
||||
The package is not published yet. A host installs it as a git dependency on the
|
||||
`component` directory of this repository,
|
||||
|
||||
```json
|
||||
"@element-hq/element-call-component": "github:element-hq/element-call#main&path:/component"
|
||||
```
|
||||
|
||||
whose `prepare` script runs the build on install. That build needs pnpm (via
|
||||
Corepack) on the host's machine, runs a full `pnpm install` of this repository,
|
||||
since it inherits the `--max-old-space-size` setting of
|
||||
the app build; the host's pnpm also has to allow it to run at all
|
||||
(`allowBuilds` in its `pnpm-workspace.yaml`). Note that `component/` is a pnpm
|
||||
project of its own for this reason, so pnpm commands run from inside that
|
||||
directory target it rather than the repository; run them from the repository
|
||||
root. The host imports the component from
|
||||
`@element-hq/element-call-component` and the stylesheet from
|
||||
`@element-hq/element-call-component/style.css`, and has to provide `react`,
|
||||
`react-dom`, `matrix-js-sdk`, `@vector-im/compound-web` and
|
||||
`@vector-im/compound-design-tokens` itself, since the bundle leaves them
|
||||
external.
|
||||
Compound's stylesheet are the host's to load too: the component shares
|
||||
the host's copy of Compound, (required for example for tooltips)
|
||||
|
||||
The component is large, and a host will usually load it lazily, only once a
|
||||
call is shown. Everything a host needs in order to talk about a call before
|
||||
then is also exported
|
||||
from `@element-hq/element-call-component/api` (a small entry point that does not
|
||||
load the component).
|
||||
|
||||
- props and the types they are made of
|
||||
- the handle
|
||||
- the host bridge
|
||||
- `UserIntent`
|
||||
- `HeaderStyle`
|
||||
- `BackgroundStyle` enums
|
||||
- `configurationForIntent`, the defaults each intent implies
|
||||
Import from it wherever a value such as
|
||||
`BackgroundStyle.Solid` is needed on a path that must stay light; the main
|
||||
entry point re-exports all of it too.
|
||||
|
||||
### Backend
|
||||
|
||||
A docker compose file `dev-backend-docker-compose.yml` is provided to start the
|
||||
A docker compose file `docker-compose-dev.yml` is provided to start the
|
||||
whole stack of components which is required for a local development environment
|
||||
including federation:
|
||||
|
||||
- Minimum Synapse Setup (servernameis: `synapse.m.localhost`, `synapse.othersite.m.localhost`)
|
||||
- MatrixRTC Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
|
||||
- Minimum Synapse Setup (servernames: `synapse.m.localhost`, `synapse.othersite.m.localhost`)
|
||||
- MatrixRTC Authorization Service (Note: requires Federation API and hence a TLS reverse proxy)
|
||||
- Minimum LiveKit SFU setup using dev defaults for config
|
||||
- Minimum `localhost` Certificate Authority (CA) for Transport Layer Security (TLS)
|
||||
- Hostnames: `m.localhost`, `*.m.localhost`, `*.othersite.m.localhost`
|
||||
- Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browsers trusted
|
||||
- Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browser's trusted
|
||||
certificates
|
||||
- Minimum TLS reverse proxy for
|
||||
- Synapse homeserver: `synapse.m.localhost` and `synapse.othersite.m.localhost`
|
||||
- MatrixRTC backend: `matrix-rtc.m.localhost` and `matrix-rtc.othersite.m.localhost`
|
||||
- Local Element Call development `call.m.localhost` via `yarn dev --host `
|
||||
- Local Element Call development `call.m.localhost` via `pnpm dev --host `
|
||||
- Element Web `app.m.localhost` and `app.othersite.m.localhost`
|
||||
- Note certificates will expire on Thr, 20 September 2035 14:27:35 CEST
|
||||
|
||||
These use a test 'secret' published in this repository, so this must be used
|
||||
only for local development and **_never be exposed to the public Internet._**
|
||||
|
||||
Run backend components:
|
||||
Make sure your Docker runtime is running (e.g. via `colima start`) and then start
|
||||
the backend components:
|
||||
|
||||
```sh
|
||||
pnpm backend
|
||||
# or for podman-compose
|
||||
# podman-compose -f dev-backend-docker-compose.yml up
|
||||
# or for podman-compose:
|
||||
# podman-compose -f docker-compose-dev.yml up
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> To ensure your local development frontend functions properly, you’ll need to
|
||||
> add certificate exceptions in your browser for `https://localhost:3000`,
|
||||
> `https://matrix-rtc.m.localhost/livekit/jwt/healthz` and
|
||||
> `https://synapse.m.localhost/.well-known/matrix/client`. This can be either
|
||||
> done by adding the minimum localhost CA
|
||||
> add certificate exceptions in your browser for `https://localhost:3000` and
|
||||
> `https://matrix-rtc.m.localhost/livekit/jwt/healthz`. This can be done either
|
||||
> by adding the minimum localhost CA
|
||||
> ([./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt)) to your web
|
||||
> browsers trusted certificates or by simply copying and pasting each URL into
|
||||
> browser's trusted certificates or by simply copying and pasting each URL into
|
||||
> your browser’s address bar and follow the prompts to add the exception.
|
||||
|
||||
### Updating snapshots
|
||||
|
||||
To update snapshots used in tests, use Vitest's `-u` flag, e.g.:
|
||||
|
||||
```sh
|
||||
pnpm test DeveloperSettingsTab -u
|
||||
```
|
||||
|
||||
### Playwright tests
|
||||
|
||||
Our Playwright tests run automatically as part of our CI along with our other
|
||||
@@ -257,7 +390,10 @@ running Playwright by following
|
||||
|
||||
However the Playwright tests are run, an element-call instance must be running
|
||||
on https://localhost:3000 (this is configured in `playwright.config.ts`) - this
|
||||
is what will be tested.
|
||||
is what will be tested. The tests under `playwright/component` instead drive
|
||||
the component harness (`pnpm dev:component`) on https://localhost:3001, which
|
||||
Playwright starts as a second web server; it is always a Vite dev server, even
|
||||
when the app itself is served from Docker with `USE_DOCKER`.
|
||||
|
||||
The local backend environment should be running for the test to work:
|
||||
`pnpm backend`
|
||||
@@ -344,9 +480,19 @@ Usage and other technical details about the project can be found here:
|
||||
|
||||
[**Docs**](./docs/README.md)
|
||||
|
||||
## GitHub Labels
|
||||
|
||||
GitHub labels in this repository are maintained in the [`labels.yml`](.github/labels.yml) file and
|
||||
automatically synced to GitHub using the [`sync-labels` workflow](.github/workflows/sync-labels.yml).
|
||||
We do this so that we can reuse the labels between repositories.
|
||||
|
||||
> [!WARNING]
|
||||
> Do not manually edit labels in the GitHub UI. Any manual changes will be overridden by the
|
||||
> workflow on its next invocation.
|
||||
|
||||
## 📝 Copyright & License
|
||||
|
||||
Copyright 2021-2025 New Vector Ltd
|
||||
Copyright 2021-2026 New Vector Ltd
|
||||
|
||||
This software is dual-licensed by New Vector Ltd (Element). It can be used
|
||||
either:
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
targets: {
|
||||
node: "current",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"@babel/preset-react",
|
||||
{
|
||||
runtime: "automatic",
|
||||
},
|
||||
],
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
plugins: ["babel-plugin-transform-vite-meta-env"],
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
id: "lk-jwt-service-othersite"
|
||||
as_token: "ec_dev_as_token_othersite_0123456789ab"
|
||||
hs_token: "ec_dev_hs_token_othersite_0123456789ab"
|
||||
sender_localpart: "_lk_jwt_service"
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: false
|
||||
regex: "@.*:synapse\\.othersite\\.m\\.localhost" # Cover all local users
|
||||
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
|
||||
url: "http://auth-server-1:16080"
|
||||
# Enable membership look-ups via /is_joined.
|
||||
io.element.msc4502.scopes:
|
||||
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
|
||||
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
|
||||
# service.
|
||||
io.element.msc4512.proxy_prefix: "rtc/livekit"
|
||||
io.element.msc4512.proxy_url: "http://auth-server-1:16080"
|
||||
@@ -0,0 +1,17 @@
|
||||
id: "lk-jwt-service"
|
||||
as_token: "ec_dev_as_token_main_0123456789ab"
|
||||
hs_token: "ec_dev_hs_token_main_0123456789ab"
|
||||
sender_localpart: "_lk_jwt_service"
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: false
|
||||
regex: "@.*:synapse\\.m\\.localhost" # Cover all local users
|
||||
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
|
||||
url: "http://auth-server:6080"
|
||||
# Enable membership look-ups via /is_joined.
|
||||
io.element.msc4502.scopes:
|
||||
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
|
||||
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
|
||||
# service.
|
||||
io.element.msc4512.proxy_prefix: "rtc/livekit"
|
||||
io.element.msc4512.proxy_url: "http://auth-server:6080"
|
||||
@@ -40,6 +40,15 @@ experimental_features:
|
||||
msc4222_enabled: true
|
||||
# sticky events for MatrixRTC user state
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
@@ -54,7 +63,6 @@ enable_registration_without_verification: true
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
# Ratelimiting settings for client actions (registration, login, messaging).
|
||||
#
|
||||
@@ -67,3 +75,8 @@ rc_message:
|
||||
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
|
||||
per_second: 0.5
|
||||
burst_count: 30
|
||||
|
||||
matrix_rtc:
|
||||
transports:
|
||||
- type: livekit
|
||||
livekit_service_url: https://matrix-rtc.othersite.m.localhost/livekit/jwt
|
||||
|
||||
@@ -40,6 +40,15 @@ experimental_features:
|
||||
msc4222_enabled: true
|
||||
# sticky events for MatrixRTC user state
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
@@ -54,7 +63,6 @@ enable_registration_without_verification: true
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
# Ratelimiting settings for client actions (registration, login, messaging).
|
||||
#
|
||||
@@ -67,3 +75,8 @@ rc_message:
|
||||
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
|
||||
per_second: 0.5
|
||||
burst_count: 30
|
||||
|
||||
matrix_rtc:
|
||||
transports:
|
||||
- type: livekit
|
||||
livekit_service_url: https://matrix-rtc.m.localhost/livekit/jwt
|
||||
|
||||
@@ -6,6 +6,11 @@ rtc:
|
||||
port_range_start: 50300
|
||||
port_range_end: 50400
|
||||
use_external_ip: false
|
||||
# Advertise the loopback address instead of the container IP, which is not
|
||||
# routable from the host when Docker runs in a VM (colima, Docker Desktop).
|
||||
# The browser then reaches the SFU through the published ports. Firefox
|
||||
# ignores loopback candidates, so this only works in Chromium-based browsers.
|
||||
node_ip: 127.0.0.1
|
||||
turn:
|
||||
enabled: false
|
||||
domain: localhost
|
||||
|
||||
@@ -6,6 +6,11 @@ rtc:
|
||||
port_range_start: 50100
|
||||
port_range_end: 50200
|
||||
use_external_ip: false
|
||||
# Advertise the loopback address instead of the container IP, which is not
|
||||
# routable from the host when Docker runs in a VM (colima, Docker Desktop).
|
||||
# The browser then reaches the SFU through the published ports. Firefox
|
||||
# ignores loopback candidates, so this only works in Chromium-based browsers.
|
||||
node_ip: 127.0.0.1
|
||||
turn:
|
||||
enabled: false
|
||||
domain: localhost
|
||||
|
||||
+2
-26
@@ -1,4 +1,4 @@
|
||||
# Synapse reverse proxy including .well-known/matrix/client
|
||||
# Synapse reverse proxy
|
||||
# domain synapse.m.localhost
|
||||
server {
|
||||
listen 80;
|
||||
@@ -11,18 +11,6 @@ server {
|
||||
ssl_certificate /root/ssl/cert.pem;
|
||||
ssl_certificate_key /root/ssl/key.pem;
|
||||
|
||||
# well-known config adding rtc_foci backend
|
||||
# Note well-known is currently not effective due to:
|
||||
# https://spec.matrix.org/v1.12/client-server-api/#well-known-uri the spec
|
||||
# says it must be at https://$server_name/... (implied port 443) Hence, we
|
||||
# currently rely for local development environment on deprecated config.json
|
||||
# setting for livekit_service_url
|
||||
location /.well-known/matrix/client {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
return 200 '{"m.homeserver": {"base_url": "https://synapse.m.localhost"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "https://matrix-rtc.m.localhost/livekit/jwt"}]}';
|
||||
default_type application/json;
|
||||
}
|
||||
|
||||
# Reverse proxy for Matrix Synapse Homeserver
|
||||
# This is also required for development environment.
|
||||
# Reason: the lk-jwt-service uses the federation API for the openid token
|
||||
@@ -44,7 +32,7 @@ server {
|
||||
|
||||
}
|
||||
|
||||
# Synapse reverse proxy including .well-known/matrix/client
|
||||
# Synapse reverse proxy
|
||||
# domain synapse.othersite.m.localhost
|
||||
server {
|
||||
listen 80;
|
||||
@@ -57,18 +45,6 @@ server {
|
||||
ssl_certificate /root/ssl/cert.pem;
|
||||
ssl_certificate_key /root/ssl/key.pem;
|
||||
|
||||
# well-known config adding rtc_foci backend
|
||||
# Note well-known is currently not effective due to:
|
||||
# https://spec.matrix.org/v1.12/client-server-api/#well-known-uri the spec
|
||||
# says it must be at https://$server_name/... (implied port 443) Hence, we
|
||||
# currently rely for local development environment on deprecated config.json
|
||||
# setting for livekit_service_url
|
||||
location /.well-known/matrix/client {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
return 200 '{"m.homeserver": {"base_url": "https://synapse.othersite.m.localhost"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "https://matrix-rtc.othersite.m.localhost/livekit/jwt"}]}';
|
||||
default_type application/json;
|
||||
}
|
||||
|
||||
# Reverse proxy for Matrix Synapse Homeserver
|
||||
# This is also required for development environment.
|
||||
# Reason: the lk-jwt-service uses the federation API for the openid token
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDGjCCAgKgAwIBAgIUbSbx+1UGptOTGefqEn7Zh3yoChIwDQYJKoZIhvcNAQEL
|
||||
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
|
||||
MzVaFw0zNTA5MjAxMjI3MzVaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
|
||||
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHODfkrFsOkqCnXnTb
|
||||
QWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+1azC
|
||||
SlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+I9O6
|
||||
NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBhwLyC
|
||||
DW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+Liyt
|
||||
X/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPERYS2H
|
||||
Ru/RAgMBAAGjUDBOMB0GA1UdDgQWBBTXNfLAKVayGQda/JZLPszrpz6LVzAfBgNV
|
||||
HSMEGDAWgBTXNfLAKVayGQda/JZLPszrpz6LVzAMBgNVHRMEBTADAQH/MA0GCSqG
|
||||
SIb3DQEBCwUAA4IBAQCvGfyopHHgZB+horGH6i/Xg41V+r4d0o092F1Lfr4vh86e
|
||||
XMakRw92vsyk/iWOnLPNPcpVWzPcvINaCs/bahgnGSOAnrA4jjcXqymyGIy/6xc5
|
||||
1EeZAxehiL9E5q4LQ841HDX0gps4ZzUO1BRYQcjG9Rdts83JO2ekkfHkZdNj2eQr
|
||||
KOrr92Na1/w+EQdo/T9Rs2ES623xKEOxPqb8d/rx5Z4DdeuGx1u+3AfS76Lpo4ni
|
||||
EJ0g1ImqdSUtiOLzeCQh6pqqb+vuFbxAyeyYSAJ49847EtFBvZCmWmPL2JICg9uq
|
||||
7rKW/qDfEK9GUs0GWCs3+mJkNvOOxBwtMuQrL7ZF
|
||||
MIIDGjCCAgKgAwIBAgIUOlA2wgQUGZkKqNDvvifFWEsJfvYwDQYJKoZIhvcNAQEL
|
||||
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNjA1MTgwOTM0
|
||||
MzFaFw0yODA3MjYwOTM0MzFaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
|
||||
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCcImv3pStfIUo7PbOO
|
||||
XRVUXuDlApBOrg2dCnvZQ1Jfaf4MftGHj/pURkF4eoBuyH4k4+NLzWD0VcU1cM74
|
||||
RnxowJt4AceCGe5RK/rqal5fapXc2vYMM8P6xaQR86gkxohpufsLgTnSweh74yqN
|
||||
B5WHUnCX00/X0bh1ho2BMUvGM9+dI4MdgKdaQDgWK4zg9hwp2Z6Yq7SkJ/D8+sGW
|
||||
WGpn3osDakL8HTBqop+YVJgF40db50yFurfcfQ0gjVtT4JW8ejO9j8PS/S2oQ/s5
|
||||
mA1B470XhLtT5qTjGm2bp3WpYkTi5widps8PDzBp5eNr0HrvJqcw7BGpbvBlLa+3
|
||||
7dhLAgMBAAGjUDBOMB0GA1UdDgQWBBRDfyRM4yKUqW6vu/2KUSXGb8vswDAfBgNV
|
||||
HSMEGDAWgBRDfyRM4yKUqW6vu/2KUSXGb8vswDAMBgNVHRMEBTADAQH/MA0GCSqG
|
||||
SIb3DQEBCwUAA4IBAQBoAhD4W4Yi/VJ2pTKrzhstn1UF1rgQnRddnn97v5BaEV/X
|
||||
uuBXbSO+/ewjQUupRjePZFp9FFe9co1OiduKcDExlvPU1eIqkWAwDWjMDpI+Lw5q
|
||||
KI5yHzplmMrT/7jn9Tepl9atrIcfDeFkP1dGtdRPyU6ARJEEWJSKeH9ftmImAsbM
|
||||
ykXAqSyRl8+bPx1ISG4cNihOxFd38VPDHIW53umaRBgRcN4GcvloKBGrVtRFNM//
|
||||
H+md8HmNQMP+e7FamETxs28DxjsdpygxjiFNY/T2eD67dH50ZxC3qCxEG6TJsoAg
|
||||
TYJafnqEcGDfiWQyNZRBypuaRsRmmTR27hCPVgi8
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHODfkrFsOkqCn
|
||||
XnTbQWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+
|
||||
1azCSlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+
|
||||
I9O6NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBh
|
||||
wLyCDW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+
|
||||
LiytX/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPER
|
||||
YS2HRu/RAgMBAAECggEABhB9CxYAE5p9D3s9nWsJcSDUdELRQSYlOoPFLmeMkF9c
|
||||
dcvq7LmduMh1Q8TnoivOBxRIwbj7pZHEYfYJM0TmH82wrQzXu5KLVltm4gTkVt9b
|
||||
DR8vjBgYdb8HVpM17Cl2xhW62XpJIiseFRUsHc/9sf2Egc3MIpPuIleGR0budbSW
|
||||
ybBkqEokTYTSiAztcu3G+VN0U9MsJgLMa8HApya7M48ojdrhzngVHZRUOXul9o7u
|
||||
zYJWSxPHIIYp5C4pYQBAx8OttThwKK1A9lwbQ2EJx0KnTbBC6O5Gna/jENpGd1h2
|
||||
rzK/9MONtsjln7IejP+4mDlNupS6SF3zzHPBHjqKAQKBgQDtXUIKPiVTFS45yWtK
|
||||
XD62s3j8jfIi+22b/C30fCPtppn0cm/0zY+vovgWVUBnQXkExafRthZCuxnE8ry7
|
||||
E29S40+4z9yivAC9dz7vHZUbyIFP6VG9WyhUYo+/WqOIePyh+iBISQ9TA1DneIYz
|
||||
+VZ8iU5GvdybUPl2C5WN8seaoQKBgQDW3EwVN2EEkChLRJbQYN2qpjn+0vYESMJ8
|
||||
K0sgMRtgh4+/T2Xb9b8O/dd87Fi/4oaUqWZ2E2sdsXq8P/IEo0cv6SRfHMy7GyxL
|
||||
RM7ztwUfMC4LVWi0ZIXMrm4gRDGN2XjGvhkX6fU2lSf6azWL1K3wI3amNV2b7P7d
|
||||
ItpvdkH3MQKBgQCXf29YJEQkXB9t6J3fDzND3xb4cwy5wSo7ZeBa7CTuWOhoeeX1
|
||||
JIJyAp0/e9goT0SThChRlFtu6gZPivJkoMnr6IOInLrg7we15fc4HPR/kCDgxTVT
|
||||
m2wJOAMxigNYZogwRfn2yRLL1BD+PBHD+H936xcX1bSJOUyPSGOC/xLhIQKBgQCb
|
||||
kCDd85ygyycBaAWxlZCor3WqFF/fNjbp5Aaepi9mMoBXSUs8eK7+UbelURHozEAY
|
||||
fpYaw3B4rTlp9vppdTZjb+/PlXB9v+zQCl+0gTyKGj4cIpiOk4F0co51eipOw7f4
|
||||
XUaZ0+CgxlmNq/W26iONjH+pU1YVQQA+Z6+zp/GW4QKBgQCrzYgeugxxqgJzyIRu
|
||||
0njJkIg+T5gHvsQrtpzq7LVob+HBiBiT7eDOeGDXTK8F//sk969QVrDMQsTMvGW9
|
||||
sG1oTqxciALTMqkJTf8+hT9Uogir0/iTbJUzTt5vPYpQOEQwQHIXMUTjZ9C6NDKT
|
||||
QlmeMCxeWyPYqoMfwKmdtDP/Iw==
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCcImv3pStfIUo7
|
||||
PbOOXRVUXuDlApBOrg2dCnvZQ1Jfaf4MftGHj/pURkF4eoBuyH4k4+NLzWD0VcU1
|
||||
cM74RnxowJt4AceCGe5RK/rqal5fapXc2vYMM8P6xaQR86gkxohpufsLgTnSweh7
|
||||
4yqNB5WHUnCX00/X0bh1ho2BMUvGM9+dI4MdgKdaQDgWK4zg9hwp2Z6Yq7SkJ/D8
|
||||
+sGWWGpn3osDakL8HTBqop+YVJgF40db50yFurfcfQ0gjVtT4JW8ejO9j8PS/S2o
|
||||
Q/s5mA1B470XhLtT5qTjGm2bp3WpYkTi5widps8PDzBp5eNr0HrvJqcw7BGpbvBl
|
||||
La+37dhLAgMBAAECggEAEsS4gc5jBk50I+bo3KYn2DqHgj/qpOqbTFNkS9uh3UJa
|
||||
fZoJCeiuyM6hNCBVq/uB3mFeg1Au5XAiAqiK2KFwdw8gIS7lkqgXU76brO4YZhPj
|
||||
6+aOSS03079KV7YYckNDRqJKoTlpgAI7Nhk6ljVhLiEk07tdD65wJACGpg8M8sg9
|
||||
dyAz+ANs9gs65iF5LYjH61O/AFlLqCRQh5/z0mjGX6G9uN27nxeUY4+n4QMAcd9D
|
||||
Gcygxjt+4nlQayNAlKMwVfps9bWNtI3Ye9knY4WGkrv5cJbW3bgjV6qrvQsbukbq
|
||||
xEYzcIlUiWGO9Tv7MN6rk5uQOyoKT/KUnfRmdVd3YQKBgQDMhWm6Q+WuI7Pyn57R
|
||||
tmY4rs+fSqTmv6xAOcozKJxffGaEwSUuNA15NvR/7iedcNqmH3XT9j90ZNVHe090
|
||||
ocm1HDUvzC9G5GRrdO6JTTksRaIMZEhosWxqH3DIuBJPLGbF/4obGE7//PJtmDEp
|
||||
QVL9Aa0WrcwAWhRzUdvCE+taMwKBgQDDbyZIvtlEr1w2V0bjXO536rRksBapc2ZJ
|
||||
XRKtrXivuVtiZYNDB0I7CCJ52cka61n3kyZz2mhQmLq4cAZXyKYWE2i643O+kc3S
|
||||
lpZEFSfDZ+3YlhxMxG9oEcgUSwVdbPlAhd/UR8V6n8o2Fm+gug9h6E2zY4fgHLJF
|
||||
8hOWoD4hiQKBgA7YXD1F8mT6eHRS+78zIyZYIf/o9iE9pm4fA7tE5lzT9ckLD/zT
|
||||
kGrM/2BN1BhMecJ3JCFXjXGQZB7FJ5ZKrA52VrH6ezAFIfjeyvWyYkUBZOrLWKoo
|
||||
vrrRP2mCWuneSjNzAf5HfGx+WsZztpXNBQ4SUhMEWHtqDnP0bCQhOAMbAoGAPfLv
|
||||
qcOFT3ZevoLv34ZHuQ9W20vOAyynUb4E+7SvOtSAmTIgZ5DXd6recs2MJ9JOlGG6
|
||||
oKKsyk9/cJNiD1V1AC5q1kLfH5tMKOK/AxnJnvFEvZDnq5Xg0pZAW95j9vdiEwfc
|
||||
qYeOm44nJPn7rHEOCzT93E1CdtHh2LYha2+kAjECgYEAh4qODleBi+2fnf2eq494
|
||||
/tAot3szu2+gjyCN00vGjtzoAuDKTYgo0cbU1ILk0Pgpp1NcIvdHz/wQnG9RLX7e
|
||||
Dfy1Q+UkyBK67SJUPvcYqBEaZ6ddyijJDunqh+U3nIBGP+IntKIKMIKiLF6wzTKz
|
||||
NRpK1HNmllp+O692ZtxoNDU=
|
||||
-----END PRIVATE KEY-----
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDgDCCAmigAwIBAgIUT9NYpZbrAKokSPSTE3zzsAMowvEwDQYJKoZIhvcNAQEL
|
||||
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
|
||||
MzVaFw0zNTA5MjAxMjI3MzVaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
|
||||
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDK1DNwTQWmyK71Ar56NvmSMQ8s
|
||||
qUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgfJlZMd4lEvt6V
|
||||
EEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+7Z22i6cGMOnQ
|
||||
0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6WxJmFF+eV9P/ax
|
||||
fxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiVG1jCrVcaKNGT
|
||||
PYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrncneIJR24/AgMB
|
||||
AAGjgbswgbgwHwYDVR0jBBgwFoAU1zXywClWshkHWvyWSz7M66c+i1cwCQYDVR0T
|
||||
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwSQYDVR0RBEIw
|
||||
QIIJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdIIXKi5vdGhl
|
||||
cnNpdGUubS5sb2NhbGhvc3QwHQYDVR0OBBYEFIkGX+cEJ1ISKIwuT1zzp7uHJ90e
|
||||
MA0GCSqGSIb3DQEBCwUAA4IBAQBnnnfB7KmyYo16ZYUCmoqGhbM4p8npeYTh5ySb
|
||||
K01YwGCnMU1qGfJnKHaRwQ2+KtVGZnpBdjmsHcOUetA3V2BirPaYowMCMtaI36LD
|
||||
LnxvboSZLX0mgEYuN7HmxW4a7fSelDecTYa7xti1sNhE/w8xW7Lky046/DousyUy
|
||||
d9x3wJ183GGj1W2p6bR1E4sqTr/VbmoULQxnqA3GUNOxW3lRL5e8lQ6jJVRmMF4k
|
||||
92BtMPrI/m7jwHj0f/WBLI8mdJ/O/W/NxQOG475FZePDfrg+MkeXPChPggf42/ou
|
||||
AMm56FNB7e1l0b1Fots730RfpCPuXpiAxL4pisS0X1dMVeeM
|
||||
MIIDijCCAnKgAwIBAgIUWkx2ad/F7QIj1JDaYfbLhiRV+EswDQYJKoZIhvcNAQEL
|
||||
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNjA1MTgwOTM0
|
||||
MzFaFw0yODA3MjYwOTM0MzFaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
|
||||
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0hora/UCYMtrLJc6BOjonPUPi
|
||||
bYpbNiZYnvnqI4doKbV0LBT2TfokT7tpgdPCtHKV0RknsVSL8vhlXpkRqIiWPml8
|
||||
sZaa0+5NDGCQxexS2WVBlsoNCmAaqi/HNSFop6xaxGpQ3bu0iV3oIkUihveXAl6H
|
||||
C0VYyGifQ8D5onzepW2ayhemu47YRNSo8wETY5vIi0i/iajTTaw6JvwS+8Kv5/QV
|
||||
5prdvcFlG/oBs12p0+KoRyxskyzcdBdyIarvfY+9nDZwym5GfN32xO/iqtDuDQzw
|
||||
Q09h2OsfHJCw70IpHcgXLlEQF2DsFbmbVpWSU6HcMm6B7Yw1YeE64W4PRJp3AgMB
|
||||
AAGjgcUwgcIwHwYDVR0jBBgwFoAUQ38kTOMilKlur7v9ilElxm/L7MAwCQYDVR0T
|
||||
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwUwYDVR0RBEww
|
||||
SoIJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdIIXKi5vdGhl
|
||||
cnNpdGUubS5sb2NhbGhvc3SCCCoubmlwLmlvMB0GA1UdDgQWBBSd0sKIKmZzTnxT
|
||||
gNHHjsJNnFcYaTANBgkqhkiG9w0BAQsFAAOCAQEAeffRTrD9o9PVRIoul5r2chwP
|
||||
WF7JtvPdC5xWy9rlCfmIKRNzNRnpVw/mDF/jdhlWcENt3psN8Vb1NM3SECKve9KL
|
||||
8bDD2rJEoLBHIFQPS+XpEPqVGLHQcfBtGgs2XdILKvgXJyBHY/pgNZkQmXxYDVoc
|
||||
bH9PjJJ4V3t6+tiVWZ792739EU/pHaSz7tab+ycTiggs7mo18E5jpYILhWsDqIVs
|
||||
Kz3uczK2OR8537Ix64Z9kmKiklVAqE53odV7Qx2B+7DoOD/7KBN7SMy1KvR1ae6I
|
||||
p1ivtDKpBZWbb1ccFxp2cQ30qRHLJrt2YRwz268gx/A6rGXuW6UQPYf4ISNR4Q==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDK1DNwTQWmyK71
|
||||
Ar56NvmSMQ8sqUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgf
|
||||
JlZMd4lEvt6VEEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+
|
||||
7Z22i6cGMOnQ0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6Wx
|
||||
JmFF+eV9P/axfxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiV
|
||||
G1jCrVcaKNGTPYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrnc
|
||||
neIJR24/AgMBAAECggEANRp6vzfDN4gKWoaV5TyYegCTNv+4rRl74cd9pjmx6Jam
|
||||
uWaUXCx1etpNqPPWcG1Z9OKLLRnk+kjgKGOqq4mObGvGreNeBot7bHOJZADtwMMI
|
||||
YG+Gp7StlclS1YoEHoDmezA/AcqDgTXa9KF0rdMBb1sGFJCLAuBNSJCxtVV6CQIz
|
||||
X26uT0m+Wx8MQyQWA7Sqy6DQNJo++IZkvr7a3cidqBOUPs+QvnIV5JsUb2gp5tGn
|
||||
zk+ObeRjoFFWYAN/NK7bneRenkP40m3MSL8ZfaEuuonui7CrxM1SiQyq2N1u/Aoy
|
||||
OE1JtNaVPbLBo6kG5al7Sj4Z0zhRt+iv93S2lZMkBQKBgQD2+FpLTqyLO1NDOFkE
|
||||
kxU+LdLOx0OV9wASC0ApPOu1dHMG6ksByr7TWeiu6GJDgajusPB7NVPOt2cm4iWU
|
||||
xPxXPO5l87uiSvu80h5uG4Qdj8KEijHkdap2wbVkU/mm8lBKC36jyBQIlJKySyXY
|
||||
zSEMfLK9jQPKz5cKKT3dVj/fAwKBgQDSPq9oks6K96MAB66o6cm214otQlnTQkPM
|
||||
xgjtjddX+Lp9tgihGvtSfPbyy89oUDHCfKvW/AHG52e5dec5YUi6mVdHEWbk33Kt
|
||||
BoQuxeK3XseIDlD/JD9Dd7KfUyO5w2jtYLfNdqez41O4qj2N52m1KwJYTwMsc8Kq
|
||||
izVgkC5hFQKBgBFAc/5CtqbbNAvECePZ6mf3h3xOSxhUsrqP8qFu0gBQ7CAVibvM
|
||||
T9wvsaNWNFcG3age0A2rQfl0sk3zCjEEOaRWa0jP59GEb2VXQCzs2yO9gRcFGEsf
|
||||
NRMqoOMrQos47gbeGrCSL2QSDNVLjo9AdQiMRWgcS6GFMsXQ77NgbQHFAoGBAI4a
|
||||
YGTGFWRITJvQlXUFz5kNxg8hMaVgvILDt3UY0dxb+XDOgLajjgsK+77Pkrhmu7tA
|
||||
mMUOQAU4kxr/XfGil43H5v3Z/Tnk7ZWVOfKDPeHC5gpH4ucQkNIBLXISt6rvMRSA
|
||||
srrk4CTuGcBPEJvBNemF0Gfvv61j8MdkoAdMbIyhAoGAfGR6yZLBmRMsW5PKmcpT
|
||||
nq2oSeUpmtGZra6pWz/3XU7AgrCLcx1DmqEjm4w7y5NQJmxyMZqqdTJILCjr3Srt
|
||||
+2F0NqQL6Li+xQGibAvDj0Jxyol38RvFC0J/w2vQmuF0hTuH95yknSd7FPXK+DPG
|
||||
qYgXLjun9dht6kx9vGJ69wI=
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0hora/UCYMtrL
|
||||
Jc6BOjonPUPibYpbNiZYnvnqI4doKbV0LBT2TfokT7tpgdPCtHKV0RknsVSL8vhl
|
||||
XpkRqIiWPml8sZaa0+5NDGCQxexS2WVBlsoNCmAaqi/HNSFop6xaxGpQ3bu0iV3o
|
||||
IkUihveXAl6HC0VYyGifQ8D5onzepW2ayhemu47YRNSo8wETY5vIi0i/iajTTaw6
|
||||
JvwS+8Kv5/QV5prdvcFlG/oBs12p0+KoRyxskyzcdBdyIarvfY+9nDZwym5GfN32
|
||||
xO/iqtDuDQzwQ09h2OsfHJCw70IpHcgXLlEQF2DsFbmbVpWSU6HcMm6B7Yw1YeE6
|
||||
4W4PRJp3AgMBAAECggEAIgdIbk4VmnrfjjKCsg5JPvNH9AsE7PuQj9zrq+xljkdq
|
||||
aksS6ni5YZXb9F/iDE4aWU4waTB+iODUXLtPrCnyESwTk0sgYe/39/MQ0slUKivL
|
||||
b+keDgY6JlyVI/5KXWFZ1kQ27CZXxwiruGGZWZBKZF8wdVE1Ea65Neg+HHA6DHee
|
||||
Jck002gtgO/J1MMbB1MzdtGcsejYLrA+mO6YddQhA65xdQMljTEfyUwgTVv0pWde
|
||||
biyKegGK77vlsOyoCkMpVYORG5NMV1Kxs+htA79yuIW71tWHqVbcRMyoM+BaHzPh
|
||||
7uprs+8vYDFrO39LseczA8gURWwUsCgQ0yQ6Ix5W7QKBgQDnSK4AzjPpDEArdHuV
|
||||
VGKyzrfPtzH0VV/yTH9hvByNG6i/x8sE/r2KPi5nRMi4PAjjqmxyO1G5qwDOfzvK
|
||||
sBvwFrTRpmbnqGITVKPPivdoI9+RveN+FxhOXVA8NylAOv/dtSoakYwg3e507UsC
|
||||
RuFW3Re0Oc+0XFq4C8rQyLkIOwKBgQDH0T9gww+XbwIGCtiNpnEziU9FXBKSVwXf
|
||||
dCxYcTLPATq3BqHmP4OUA0v+sa3wPcnBkXF7q6eoB9+S6ZYQA/b2BXGU5/j9xYd4
|
||||
29cF4DlPkhTwF9S8b+h1zhlGIn96Lw/vZuj7Bc3wuwxvB17d8dpyo8bZynIe7BvF
|
||||
KSPJz+2O9QKBgFFyd8xS0VcFeGeVKpwozmUXhQWCBvZ7RkGGjOk3HHrYvbFjw2vr
|
||||
5YWUZjT5tRGkGqFJ98y2dQ5EWRFfHwg+wmfnJyAZUG3OD1OtX86Lqpqi321siHtz
|
||||
2JxoIgRCjKVQ4aAK11vp24YLgZjto5eWrG4xh9Jw9WMXjt73UCH8PaTXAoGBAIff
|
||||
TY1qlmuO3H1nWqHXkBpPQEwVs7s22ZN817q8HqSMXXSfWe/LOJmpND/YakJ2gX7S
|
||||
e6xwqOylje3EUHpLd98LDJUIuFM3wkr4klo4gkANQZeRXONV5WhV4PHD+5MF9XwB
|
||||
KmOnKsaLKoVFKckZ8EUMAOePtdI5ExkaRG+yqAMRAoGAJyUFK+V9ST1N/6wYgqor
|
||||
vywMSRE2cF2WvVIxdMvWffmpoj40bG6lAlaSWm29E2T8SVvAKsRid0wDgCQ4QTEn
|
||||
ft7yUDjqVALCJVCrOFHDY0BPStkm6njMWagr/0lGr9zUWqbBOKJhNfDJlykv8gaF
|
||||
8kWTgabrMCKmpTi7fBWbzZA=
|
||||
-----END PRIVATE KEY-----
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Step 1: Create a Root CA key and cert
|
||||
openssl genrsa -out dev_tls_local-ca.key 2048
|
||||
openssl req -x509 -new -nodes \
|
||||
-days 3650 \
|
||||
-days 800 \
|
||||
-subj "/CN=Element Call Dev CA" \
|
||||
-key dev_tls_local-ca.key \
|
||||
-out dev_tls_local-ca.crt \
|
||||
@@ -21,7 +21,7 @@ openssl x509 \
|
||||
-CA dev_tls_local-ca.crt -CAkey dev_tls_local-ca.key \
|
||||
-CAcreateserial \
|
||||
-out dev_tls_m.localhost.crt \
|
||||
-days 3650 \
|
||||
-days 800 \
|
||||
-sha256 \
|
||||
-extfile <( cat <<EOF
|
||||
authorityKeyIdentifier=keyid,issuer
|
||||
@@ -35,5 +35,6 @@ DNS.1 = localhost
|
||||
DNS.2 = m.localhost
|
||||
DNS.3 = *.m.localhost
|
||||
DNS.4 = *.othersite.m.localhost
|
||||
DNS.5 = *.nip.io
|
||||
EOF
|
||||
)
|
||||
|
||||
@@ -40,6 +40,15 @@ experimental_features:
|
||||
msc4222_enabled: true
|
||||
# sticky events for MatrixRTC user state
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
@@ -54,7 +63,6 @@ enable_registration_without_verification: true
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
# Ratelimiting settings for client actions (registration, login, messaging).
|
||||
#
|
||||
@@ -84,3 +92,8 @@ rc_login:
|
||||
rc_registration:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
matrix_rtc:
|
||||
transports:
|
||||
- type: livekit
|
||||
livekit_service_url: https://matrix-rtc.othersite.m.localhost/livekit/jwt
|
||||
|
||||
@@ -40,6 +40,15 @@ experimental_features:
|
||||
msc4222_enabled: true
|
||||
# sticky events for MatrixRTC user state
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
@@ -54,7 +63,6 @@ enable_registration_without_verification: true
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
# Ratelimiting settings for client actions (registration, login, messaging).
|
||||
#
|
||||
@@ -84,3 +92,8 @@ rc_login:
|
||||
rc_registration:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
matrix_rtc:
|
||||
transports:
|
||||
- type: livekit
|
||||
livekit_service_url: https://matrix-rtc.m.localhost/livekit/jwt
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# Coverage comes from a single vitest run (see .github/workflows/test.yaml), so
|
||||
# the app and the SDK are split by path into codecov components rather than
|
||||
# uploaded under separate flags. Each component gets its own coverage figure in
|
||||
# the PR comment and on codecov.io.
|
||||
#
|
||||
# Validate with: curl --data-binary @codecov.yml https://codecov.io/validate
|
||||
|
||||
comment:
|
||||
layout: "reach, diff, flags, components, files"
|
||||
|
||||
component_management:
|
||||
individual_components:
|
||||
- component_id: app
|
||||
name: app
|
||||
paths:
|
||||
- src/**
|
||||
- component_id: sdk
|
||||
name: sdk
|
||||
paths:
|
||||
- sdk/**
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { type ReactNode } from "react";
|
||||
import { Tooltip, TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { CallPortalRoot } from "./CallPortalRoot";
|
||||
|
||||
// A button whose accessible name comes from its tooltip, as every icon button
|
||||
// in the call does: the name is lost whenever the tooltip has nowhere to go.
|
||||
const LabelledButton = (): ReactNode => (
|
||||
<TooltipProvider>
|
||||
<Tooltip label="Microphone">
|
||||
<button>🎤</button>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
test("floats into the host document with no root", () => {
|
||||
render(
|
||||
<CallPortalRoot root={undefined}>
|
||||
<LabelledButton />
|
||||
</CallPortalRoot>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Microphone" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("floats into the root it is given", () => {
|
||||
const root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
render(
|
||||
<CallPortalRoot root={root}>
|
||||
<LabelledButton />
|
||||
</CallPortalRoot>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Microphone" });
|
||||
const label = document.getElementById(
|
||||
button.getAttribute("aria-labelledby")!,
|
||||
);
|
||||
expect(root.contains(label)).toBe(true);
|
||||
root.remove();
|
||||
});
|
||||
|
||||
test("waits for a root that is not available yet", () => {
|
||||
render(
|
||||
<CallPortalRoot root={null}>
|
||||
<LabelledButton />
|
||||
</CallPortalRoot>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Microphone" })).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, type ReactNode } from "react";
|
||||
import { PortalRoot } from "@vector-im/compound-web";
|
||||
|
||||
interface Props {
|
||||
root: HTMLElement | null | undefined;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Compound floats the call's menus and tooltips. With no `root` the call
|
||||
* is not wrapped at all and they go into the host document's body; `null` is
|
||||
* Compound's "not available yet", and the floating parts wait for it.
|
||||
*/
|
||||
export const CallPortalRoot: FC<Props> = ({ root, children }): ReactNode =>
|
||||
root === undefined ? (
|
||||
children
|
||||
) : (
|
||||
<PortalRoot root={root}>{children}</PortalRoot>
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/* The container a host mounts us into. It fills whatever space the host gives
|
||||
it, and nothing we draw may leave it.
|
||||
|
||||
That takes two separate things, which are easy to mistake for one. `isolation`
|
||||
gives us a stacking context, so nothing inside can be layered above the host's
|
||||
own interface. Containment makes us the containing block for `position: fixed`
|
||||
descendants, and clips what we paint to our own box: without it, the modal
|
||||
scrim and dialog — which are positioned `fixed` and centred, since in the app
|
||||
they are meant to cover the page — resolve against the viewport and appear in
|
||||
the middle of the host's window rather than in the middle of the call.
|
||||
|
||||
The clipping cuts both ways: a menu near the edge of a small container is
|
||||
trimmed rather than overflowing into the host. That is the trade being a
|
||||
component rather than a page makes. */
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
isolation: isolate;
|
||||
contain: layout paint;
|
||||
position: relative;
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
color: var(--cpd-color-text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* The call itself. It is a stacking context of its own, as the standalone
|
||||
page's `#root` is, so that the layers the call view uses for its header, footer
|
||||
and overlays stay below the modals and toasts, which are portalled into the
|
||||
root and carry no layer of their own.
|
||||
|
||||
What Compound floats — tooltips, menus — is not in here at all: it goes into
|
||||
the host's body, as it would for any other component on the host's page, and is
|
||||
styled and layered by the host's copy of Compound. */
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL
|
||||
*
|
||||
* What a host needs in order to talk about Element Call, without Element Call:
|
||||
* the component's props and the types they are made of, the handle and the
|
||||
* host bridge, the intents a call can be started with and the configuration
|
||||
* each implies.
|
||||
*
|
||||
* This is an entry point of its own, `@element-hq/element-call-component/api`,
|
||||
* so that a host can import it on a path that must stay light — a call model
|
||||
* that needs `BackgroundStyle.Solid` as a value, say — and load the component
|
||||
* itself, and everything it brings, only when a call is shown. The main entry
|
||||
* point re-exports all of it, so a host that does not care may import
|
||||
* everything from there.
|
||||
*
|
||||
* Nothing in here may reach anything that is not a type or a constant: the
|
||||
* point of this module is what it does not import.
|
||||
*/
|
||||
|
||||
import type { Ref } from "react";
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import type { UrlProperties } from "../src/UrlParams";
|
||||
import type { ElementCallHandle, ElementCallHostBridge } from "./host";
|
||||
import {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
HeaderStyle,
|
||||
type UrlConfiguration,
|
||||
UserIntent,
|
||||
} from "../src/UrlConfiguration";
|
||||
|
||||
// How the host and Element Call talk to each other, and what they say
|
||||
export type { ElementCallHandle, ElementCallHostBridge } from "./host";
|
||||
export type { DeviceMuteRequest, DeviceMuteState } from "../src/HostBridge";
|
||||
export type { JoinCallData } from "../src/widget";
|
||||
// The deployment-wide configuration, as distinct from ElementCallConfiguration
|
||||
// below, which is per call
|
||||
export type { ConfigOptions } from "../src/config/ConfigOptions";
|
||||
// The values that appear in ElementCallConfiguration and in the intent, and
|
||||
// what each intent means by default
|
||||
export {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
HeaderStyle,
|
||||
type UrlConfiguration,
|
||||
UserIntent,
|
||||
};
|
||||
|
||||
/**
|
||||
* How Element Call should behave. Everything is optional; anything left out
|
||||
* takes the default that {@link ElementCallProps.intent} implies.
|
||||
*
|
||||
* This is the behaviour a widget can be configured with through its URL, plus
|
||||
* the one fact about the call a host has a say in here, the background. The
|
||||
* rest of what a widget's URL carries — who the user is, how to reach the
|
||||
* homeserver, where to report analytics, the shared secret of a room that is
|
||||
* encrypted with one — a component host supplies by other routes, or not at
|
||||
* all; and what can change while the call is running, the theme and the
|
||||
* language, is a prop of its own.
|
||||
*/
|
||||
export type ElementCallConfiguration = Partial<UrlConfiguration> &
|
||||
Partial<Pick<UrlProperties, "background">>;
|
||||
|
||||
export interface ElementCallProps {
|
||||
/**
|
||||
* The client to place the call with. Element Call does not authenticate
|
||||
* anyone or manage a session of its own; this one is the host's.
|
||||
*/
|
||||
client: MatrixClient;
|
||||
/** The room to call in. The host's client must already know about it. */
|
||||
roomId: string;
|
||||
/**
|
||||
* What the user asked for — whether they started the call or joined one that
|
||||
* was already running, and whether it is a call in a group or a DM. Element
|
||||
* Call decides what each of those means: whether to show the lobby first,
|
||||
* whether to ring, and so on.
|
||||
*
|
||||
* Defaults to joining an existing group call, which is the most conservative
|
||||
* reading, but a host that knows which button the user pressed should say so.
|
||||
*/
|
||||
intent?: UserIntent;
|
||||
/**
|
||||
* How Element Call should behave, overriding whatever {@link intent} implies.
|
||||
* A host that finds itself setting a lot of these probably wants a different
|
||||
* intent instead.
|
||||
*
|
||||
* Compared by value, so it is fine to write this inline; only a change to
|
||||
* what it says restarts anything.
|
||||
*/
|
||||
config?: ElementCallConfiguration;
|
||||
/**
|
||||
* What Element Call tells the host while the call is running: that the user
|
||||
* has joined or hung up, that it would like to be kept on screen, and so on.
|
||||
* Without one, Element Call assumes nobody is listening.
|
||||
*/
|
||||
hostBridge?: ElementCallHostBridge;
|
||||
/**
|
||||
* What the host tells Element Call: to hang up, to mute, to join. Available
|
||||
* once the component has rendered.
|
||||
*/
|
||||
ref?: Ref<ElementCallHandle>;
|
||||
/**
|
||||
* The theme to show Element Call in, `light` or `dark`. Left out, Element
|
||||
* Call picks. Changes take effect at once, and cost nothing else.
|
||||
*/
|
||||
theme?: string;
|
||||
/**
|
||||
* The language to show Element Call in, as a BCP 47 tag: one of the
|
||||
* `supportedLanguages` the main entry point exports, or something that falls
|
||||
* back to one (`de-AT` to `de`). Left out, the browser's language is used.
|
||||
*
|
||||
* Translations are one thing shared by every Element Call on the page, so
|
||||
* the most recently set language wins for all of them.
|
||||
*/
|
||||
language?: string;
|
||||
/**
|
||||
* Where the parts of Element Call that float above it — its menus and
|
||||
* tooltips, which Compound portals out of the call — are rendered. Left out,
|
||||
* they go into the host document's body, as they do for any other component
|
||||
* on the host's page.
|
||||
*
|
||||
* A host that moves Element Call's DOM into another document, such as a
|
||||
* Document Picture-in-Picture window, passes that document's body here so
|
||||
* that a menu opens in the window its button is in. Changes take effect at
|
||||
* once. `null` means the root is not available yet, as from a callback ref
|
||||
* that has not fired: menus and tooltips wait for it rather than falling
|
||||
* back to the body.
|
||||
*/
|
||||
portalRoot?: HTMLElement | null;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import postcss from "postcss";
|
||||
|
||||
import { ROOT_SELECTOR, scopeStylesToRoot } from "./scopeStylesToRoot";
|
||||
|
||||
const inRoot = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`;
|
||||
const isRoot = `:where(${ROOT_SELECTOR})`;
|
||||
|
||||
async function scope(css: string, file = "base.css"): Promise<string> {
|
||||
const result = await postcss([scopeStylesToRoot()]).process(css, {
|
||||
from: file,
|
||||
});
|
||||
return result.css;
|
||||
}
|
||||
|
||||
describe("scopeStylesToRoot", () => {
|
||||
it("makes the root stand in for the document", async () => {
|
||||
expect(await scope("html { line-height: 1.15 }")).toBe(
|
||||
`${isRoot} { line-height: 1.15 }`,
|
||||
);
|
||||
expect(await scope("body { margin: 0 }")).toBe(`${isRoot} { margin: 0 }`);
|
||||
expect(await scope(":root { --a: 1 }")).toBe(`${isRoot} { --a: 1 }`);
|
||||
expect(await scope("body.no-scroll-body { position: fixed }")).toBe(
|
||||
`${isRoot}.no-scroll-body { position: fixed }`,
|
||||
);
|
||||
expect(await scope("body .x { color: red }")).toBe(
|
||||
`${isRoot} .x { color: red }`,
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses selectors that all became the root", async () => {
|
||||
expect(await scope("html, body, input { font: inherit }")).toBe(
|
||||
`${isRoot},input${inRoot} { font: inherit }`,
|
||||
);
|
||||
});
|
||||
|
||||
it("confines everything else to the root and what is inside it", async () => {
|
||||
expect(await scope("h1 { margin: 0 }")).toBe(`h1${inRoot} { margin: 0 }`);
|
||||
expect(await scope(".cpd-theme-dark { --a: 1 }")).toBe(
|
||||
`.cpd-theme-dark${inRoot} { --a: 1 }`,
|
||||
);
|
||||
expect(await scope("* { box-sizing: border-box }")).toBe(
|
||||
`*${inRoot} { box-sizing: border-box }`,
|
||||
);
|
||||
expect(await scope(".a > .b + .c { color: red }")).toBe(
|
||||
`.a>.b+.c${inRoot} { color: red }`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps pseudo-elements last", async () => {
|
||||
expect(await scope("button::-moz-focus-inner { border: 0 }")).toBe(
|
||||
`button${inRoot}::-moz-focus-inner { border: 0 }`,
|
||||
);
|
||||
expect(await scope(".a .b:hover::after { content: '' }")).toBe(
|
||||
`.a .b:hover${inRoot}::after { content: '' }`,
|
||||
);
|
||||
expect(await scope("p:first-letter { color: red }")).toBe(
|
||||
`p${inRoot}:first-letter { color: red }`,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves alone what already names the root", async () => {
|
||||
const css = `${ROOT_SELECTOR}[data-platform="ios"] { --a: 1 }`;
|
||||
expect(await scope(css)).toBe(css);
|
||||
});
|
||||
|
||||
it("reaches into layers and media queries", async () => {
|
||||
expect(
|
||||
await scope(
|
||||
"@layer normalize { h1 { margin: 0 } } @media (min-width: 1px) { p { margin: 0 } }",
|
||||
),
|
||||
).toBe(
|
||||
`@layer normalize { h1${inRoot} { margin: 0 } } @media (min-width: 1px) { p${inRoot} { margin: 0 } }`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not touch keyframes or nested rules", async () => {
|
||||
expect(
|
||||
await scope("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }"),
|
||||
).toBe("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }");
|
||||
expect(
|
||||
await scope(
|
||||
".a { color: red; &:hover { color: blue } .b { color: green } }",
|
||||
),
|
||||
).toBe(
|
||||
`.a${inRoot} { color: red; &:hover { color: blue } .b { color: green } }`,
|
||||
);
|
||||
});
|
||||
|
||||
it("only touches the bare selectors of a CSS module", async () => {
|
||||
const file = "Settings.module.css";
|
||||
expect(await scope("pre { font-size: 1px }", file)).toBe(
|
||||
`pre${inRoot} { font-size: 1px }`,
|
||||
);
|
||||
expect(await scope(".modal pre { font-size: 1px }", file)).toBe(
|
||||
`.modal pre${inRoot} { font-size: 1px }`,
|
||||
);
|
||||
expect(await scope(".box_abc12 { border: 0 }", file)).toBe(
|
||||
".box_abc12 { border: 0 }",
|
||||
);
|
||||
expect(await scope(".a .b_abc12:hover { border: 0 }", file)).toBe(
|
||||
".a .b_abc12:hover { border: 0 }",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type AtRule,
|
||||
type Container,
|
||||
type Document,
|
||||
type Plugin,
|
||||
type Rule,
|
||||
} from "postcss";
|
||||
import selectorParser, {
|
||||
type Node,
|
||||
type Pseudo,
|
||||
type Selector,
|
||||
} from "postcss-selector-parser";
|
||||
|
||||
/**
|
||||
* How the stylesheets find Element Call's root element. The attribute is put
|
||||
* there by `useTheme`, on the container the host gives the component.
|
||||
*/
|
||||
export const ROOT_SELECTOR = "[data-element-call-root]";
|
||||
|
||||
// Both are `:where()`, which has no specificity of its own, so the rules keep
|
||||
// exactly the weight they had before being scoped and nothing in Element Call's
|
||||
// cascade changes — only where it applies.
|
||||
//
|
||||
// The root, or anything inside it. Appended to the element a rule is about,
|
||||
// rather than prepended to the whole selector, so that a rule about the root
|
||||
// itself (its theme class, say) still matches.
|
||||
const IN_ROOT = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`;
|
||||
// The root itself, standing in for the document.
|
||||
const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
||||
|
||||
/**
|
||||
* Confines a stylesheet to Element Call's root element, for the build of
|
||||
* Element Call as a component.
|
||||
*
|
||||
* As a page of its own, Element Call can style the document: normalize.css and
|
||||
* its own base styles speak of `html`, `body` and bare elements, and its custom
|
||||
* properties are declared on `:root`. As a component, all of that would land on
|
||||
* the host's document too. This rewrites every selector so that it matches only
|
||||
* the root or its descendants:
|
||||
*
|
||||
* - `html`, `body` and `:root` become the root element, which is what stands in
|
||||
* for the document inside a host.
|
||||
* - Everything else keeps its selector and gains `:where([data-element-call-root],
|
||||
* [data-element-call-root] *)` on the element it styles.
|
||||
* - Selectors that already name the root are left alone, as are keyframe
|
||||
* selectors and rules nested inside another rule, which are relative to it.
|
||||
*
|
||||
* CSS modules are scoped by their class names already, so only their selectors
|
||||
* that would match by element alone — `pre` rather than `.pre` — are touched.
|
||||
*
|
||||
* The root's custom properties are still inherited by everything inside it,
|
||||
* the way they were from `:root`. Compound's own stylesheet — the design
|
||||
* tokens, the component styles, the fonts — is not in this build at all: the
|
||||
* host supplies it, unscoped, along with its copy of Compound.
|
||||
*/
|
||||
export function scopeStylesToRoot(): Plugin {
|
||||
return {
|
||||
postcssPlugin: "element-call-scope-styles-to-root",
|
||||
Once(root) {
|
||||
const isModule =
|
||||
root.source?.input.file?.endsWith(".module.css") ?? false;
|
||||
root.walkRules((rule) => {
|
||||
if (isRelative(rule)) return;
|
||||
rule.selector = (isModule ? scopeBare : scopeAll).processSync(
|
||||
rule.selector,
|
||||
{ lossless: false },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a rule's selectors are relative to something other than the document. */
|
||||
function isRelative(rule: Rule): boolean {
|
||||
let parent: Container | Document | undefined = rule.parent;
|
||||
while (parent !== undefined) {
|
||||
if (parent.type === "rule") return true;
|
||||
if (parent.type === "atrule") {
|
||||
const { name } = parent as AtRule;
|
||||
if (name.endsWith("keyframes") || name === "page") return true;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const processor = (isModule: boolean): ReturnType<typeof selectorParser> =>
|
||||
selectorParser((selectors) => {
|
||||
selectors.each((selector) => {
|
||||
scopeSelector(selector, isModule);
|
||||
});
|
||||
// Mapping `html, body` onto the root leaves the same selector twice
|
||||
const seen = new Set<string>();
|
||||
selectors.each((selector) => {
|
||||
const text = String(selector).trim();
|
||||
if (seen.has(text)) selector.remove();
|
||||
else seen.add(text);
|
||||
});
|
||||
});
|
||||
|
||||
// Everything, for stylesheets that speak of the document; only what a class
|
||||
// does not already confine, for CSS modules
|
||||
const scopeAll = processor(false);
|
||||
const scopeBare = processor(true);
|
||||
|
||||
function scopeSelector(selector: Selector, isModule: boolean): void {
|
||||
if (String(selector).includes(ROOT_SELECTOR)) return;
|
||||
|
||||
const compounds = splitCompounds(selector);
|
||||
if (compounds.length === 0) return;
|
||||
|
||||
// Something said of the document is said of the root instead
|
||||
const document = compounds[0].find(isDocumentSelector);
|
||||
if (document !== undefined) {
|
||||
document.replaceWith(pseudo(IS_ROOT));
|
||||
return;
|
||||
}
|
||||
|
||||
const subject = compounds.at(-1)!;
|
||||
if (isModule && subject.some((node) => node.type === "class")) return;
|
||||
|
||||
// Pseudo-elements have to come last in a compound selector
|
||||
const pseudoElement = subject.find(isPseudoElement);
|
||||
if (pseudoElement === undefined) selector.append(pseudo(IN_ROOT));
|
||||
else selector.insertBefore(pseudoElement, pseudo(IN_ROOT));
|
||||
}
|
||||
|
||||
/** The compound selectors making up a complex selector, in order. */
|
||||
function splitCompounds(selector: Selector): Node[][] {
|
||||
const compounds: Node[][] = [[]];
|
||||
for (const node of selector.nodes) {
|
||||
if (node.type === "combinator") compounds.push([]);
|
||||
else if (node.type !== "comment") compounds.at(-1)!.push(node);
|
||||
}
|
||||
return compounds.filter((compound) => compound.length > 0);
|
||||
}
|
||||
|
||||
function isDocumentSelector(node: Node): boolean {
|
||||
return (
|
||||
(node.type === "tag" && (node.value === "html" || node.value === "body")) ||
|
||||
(node.type === "pseudo" && node.value === ":root")
|
||||
);
|
||||
}
|
||||
|
||||
function isPseudoElement(node: Node): node is Pseudo {
|
||||
if (node.type !== "pseudo") return false;
|
||||
return (
|
||||
node.value.startsWith("::") ||
|
||||
[":before", ":after", ":first-line", ":first-letter"].includes(node.value)
|
||||
);
|
||||
}
|
||||
|
||||
function pseudo(text: string): Pseudo {
|
||||
return selectorParser().astSync(text).nodes[0].nodes[0].clone() as Pseudo;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ElementCallHostBridge } from "../index";
|
||||
|
||||
/**
|
||||
* A host bridge that reports everything it is told, so that the harness can
|
||||
* watch what Element Call says to its host. (What the host says to Element
|
||||
* Call goes through the component's handle, and is logged by the pane.)
|
||||
*/
|
||||
export function createDevHostBridge(
|
||||
log: (message: string) => void,
|
||||
/** What the host does when Element Call asks to be closed. */
|
||||
onClose: () => void,
|
||||
): ElementCallHostBridge {
|
||||
/**
|
||||
* Records something Element Call told the host. Nothing is sent anywhere, so
|
||||
* this is only asynchronous because a real host's answer would have to be.
|
||||
*/
|
||||
const told = async (message: string): Promise<void> => {
|
||||
log(`→ ${message}`);
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
return {
|
||||
setAlwaysOnScreen: async (alwaysOnScreen): Promise<void> =>
|
||||
await told(`setAlwaysOnScreen(${alwaysOnScreen})`),
|
||||
contentLoaded: async (): Promise<void> => await told("contentLoaded"),
|
||||
notifyJoined: async (): Promise<void> => await told("notifyJoined"),
|
||||
notifyHungUp: async (): Promise<void> => await told("notifyHungUp"),
|
||||
notifyDeviceMute: async (state): Promise<void> =>
|
||||
await told(
|
||||
`notifyDeviceMute(audio: ${state.audio_enabled}, video: ${state.video_enabled})`,
|
||||
),
|
||||
// Present because this host really can dismiss Element Call, which is what
|
||||
// makes it offer a close affordance at all
|
||||
close: async (): Promise<void> => {
|
||||
await told("close");
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.credentials {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-inline-size: 420px;
|
||||
margin: 48px auto;
|
||||
padding: 24px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.harness {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.header,
|
||||
.paneBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-block-end: 1px solid #d4d4d8;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.middle {
|
||||
display: flex;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex: 0 0 220px;
|
||||
padding: 12px;
|
||||
border-inline-end: 1px solid #d4d4d8;
|
||||
background-color: #ffffff;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
/* So that a pane is the size it was dragged to, rather than being stretched
|
||||
to fill the row */
|
||||
align-items: flex-start;
|
||||
align-content: flex-start;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.paneBar {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
border-block-end: none;
|
||||
}
|
||||
|
||||
/* The space the host gives Element Call. Resizable so that the sizes it has to
|
||||
cope with can be found by dragging rather than by rebuilding, and `overflow:
|
||||
hidden` both to enable the resize handle and to show up anything inside Element
|
||||
Call that does not fit the box it was given. */
|
||||
.paneCall {
|
||||
inline-size: 560px;
|
||||
block-size: 420px;
|
||||
min-inline-size: 180px;
|
||||
min-block-size: 180px;
|
||||
resize: both;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log {
|
||||
max-block-size: 180px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
border-block-start: 1px solid #d4d4d8;
|
||||
background-color: #ffffff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log h2 {
|
||||
font-size: 13px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.log ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* A host overlay, which Element Call must not be able to draw over */
|
||||
.dialogScrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background-color: rgb(0 0 0 / 50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type FC,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
ElementCall,
|
||||
type ElementCallHandle,
|
||||
supportedLanguages,
|
||||
} from "../index";
|
||||
import { createDevHostBridge } from "./DevHostBridge";
|
||||
import { createSession, joinRoom } from "./session";
|
||||
import styles from "./Harness.module.css";
|
||||
|
||||
interface Credentials {
|
||||
homeserver: string;
|
||||
username: string;
|
||||
password: string;
|
||||
room: string;
|
||||
}
|
||||
|
||||
const CREDENTIALS_KEY = "element-call-component-harness";
|
||||
|
||||
const DEFAULT_CREDENTIALS: Credentials = {
|
||||
homeserver: "https://synapse.m.localhost",
|
||||
username: "",
|
||||
password: "",
|
||||
room: "",
|
||||
};
|
||||
|
||||
/**
|
||||
* The credentials to start with: the last ones used, so that a reload does not
|
||||
* mean typing them again, overridden by anything in the query string.
|
||||
*
|
||||
* A host reading its own URL is entirely proper — it was Element Call doing so
|
||||
* that was the mistake. It lets the end-to-end tests, or a shared link, say
|
||||
* which account and room to use.
|
||||
*/
|
||||
function loadCredentials(): Credentials {
|
||||
let stored: Partial<Credentials> = {};
|
||||
try {
|
||||
const json = localStorage.getItem(CREDENTIALS_KEY);
|
||||
if (json !== null) stored = JSON.parse(json) as Credentials;
|
||||
} catch (e) {
|
||||
logger.warn("Could not read the stored harness credentials", e);
|
||||
}
|
||||
|
||||
const query = new URLSearchParams(location.search);
|
||||
const fromUrl = Object.fromEntries(
|
||||
(["homeserver", "username", "password", "room"] as const)
|
||||
.map((name) => [name, query.get(name)])
|
||||
.filter(([, value]) => value !== null),
|
||||
) as Partial<Credentials>;
|
||||
|
||||
return { ...DEFAULT_CREDENTIALS, ...stored, ...fromUrl };
|
||||
}
|
||||
|
||||
interface Session {
|
||||
label: string;
|
||||
client: MatrixClient;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { phase: "credentials" }
|
||||
| { phase: "starting"; progress: string }
|
||||
| { phase: "started"; roomId: string; sessions: Session[] }
|
||||
| { phase: "failed"; error: string };
|
||||
|
||||
interface LogEntry {
|
||||
pane: string;
|
||||
message: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One Element Call component, with the controls a host would have over it: the
|
||||
* requests it can make of Element Call, and the ability to take it off screen
|
||||
* altogether.
|
||||
*/
|
||||
const Pane: FC<{
|
||||
session: Session;
|
||||
roomId: string;
|
||||
theme: string | undefined;
|
||||
language: string | undefined;
|
||||
log: (pane: string, message: string) => void;
|
||||
}> = ({ session, roomId, theme, language, log }): ReactNode => {
|
||||
const [mounted, setMounted] = useState(true);
|
||||
|
||||
const bridge = useMemo(
|
||||
() =>
|
||||
createDevHostBridge(
|
||||
(message) => log(session.label, message),
|
||||
() => setMounted(false),
|
||||
),
|
||||
[log, session.label],
|
||||
);
|
||||
|
||||
// What the host asks of Element Call goes through the component's handle.
|
||||
// Worth saying out loud when a request is refused — asking to hang up when
|
||||
// there is no call, say — since that is the sort of thing the harness is for.
|
||||
const handle = useRef<ElementCallHandle>(null);
|
||||
const ask = (
|
||||
name: string,
|
||||
make: (handle: ElementCallHandle) => Promise<unknown>,
|
||||
): void => {
|
||||
if (handle.current === null) {
|
||||
log(session.label, `← ${name}: not mounted`);
|
||||
return;
|
||||
}
|
||||
log(session.label, `← ${name}`);
|
||||
make(handle.current).then(
|
||||
(reply) =>
|
||||
log(
|
||||
session.label,
|
||||
`→ ${name} acknowledged${reply === undefined ? "" : `: ${JSON.stringify(reply)}`}`,
|
||||
),
|
||||
(e: unknown) => log(session.label, `→ ${name} refused: ${e}`),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.pane} data-testid="call-pane">
|
||||
<div className={styles.paneBar}>
|
||||
<strong>{session.label}</strong>
|
||||
<code>{session.client.getDeviceId()}</code>
|
||||
<button onClick={(): void => setMounted((m) => !m)}>
|
||||
{mounted ? "Unmount" : "Mount"}
|
||||
</button>
|
||||
<button
|
||||
onClick={(): void =>
|
||||
ask(
|
||||
"setDeviceMute(audio: false)",
|
||||
async (h) => await h.setDeviceMute({ audio_enabled: false }),
|
||||
)
|
||||
}
|
||||
>
|
||||
Mute
|
||||
</button>
|
||||
<button
|
||||
onClick={(): void => ask("hangUp", async (h) => await h.hangUp())}
|
||||
>
|
||||
Hang up
|
||||
</button>
|
||||
</div>
|
||||
{/* Resizable, because how Element Call copes with the size it is given is
|
||||
one of the things we cannot find out from the standalone app */}
|
||||
<div className={styles.paneCall} data-testid="call-container">
|
||||
{mounted && (
|
||||
<ElementCall
|
||||
ref={handle}
|
||||
client={session.client}
|
||||
roomId={roomId}
|
||||
hostBridge={bridge}
|
||||
theme={theme}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/** Host furniture, to make it visible if Element Call styles anything but itself. */
|
||||
const HostChrome: FC = (): ReactNode => (
|
||||
<nav className={styles.sidebar}>
|
||||
<h2>Host chrome</h2>
|
||||
<p>
|
||||
This column belongs to the host. If Element Call's stylesheet reaches
|
||||
outside its own container, it shows up here.
|
||||
</p>
|
||||
<hr />
|
||||
<ul>
|
||||
<li>Some room</li>
|
||||
<li>Another room</li>
|
||||
</ul>
|
||||
<button>A host button</button>
|
||||
</nav>
|
||||
);
|
||||
|
||||
/**
|
||||
* A dialog of the host's own, over the top of the calls. Element Call as a
|
||||
* component has to sit underneath this — being unable to is one of the reasons
|
||||
* for a component rather than an iframe.
|
||||
*/
|
||||
const HostDialog: FC<{ onClose: () => void }> = ({ onClose }): ReactNode => (
|
||||
<div className={styles.dialogScrim}>
|
||||
<div className={styles.dialog}>
|
||||
<h2>A dialog belonging to the host</h2>
|
||||
<p>This should cover the calls completely.</p>
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Stands in for a host application using the Element Call component: it owns the Matrix
|
||||
* clients, the page and the space each call is given, and reaches Element Call
|
||||
* only through the component's public interface.
|
||||
*
|
||||
* Two calls at once, from two devices of the same account, so that a real call
|
||||
* happens between them and anything Element Call keeps once per process rather
|
||||
* than once per call shows itself.
|
||||
*/
|
||||
export const Harness: FC = (): ReactNode => {
|
||||
const [credentials, setCredentials] = useState(loadCredentials);
|
||||
const [state, setState] = useState<State>({ phase: "credentials" });
|
||||
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
// The host's language setting, which Element Call follows. Undefined means
|
||||
// the host has none and Element Call uses the browser's.
|
||||
const [language, setLanguage] = useState<string | undefined>(undefined);
|
||||
const [theme, setTheme] = useState<string | undefined>(undefined);
|
||||
|
||||
const log = useCallback((pane: string, message: string): void => {
|
||||
setEntries((entries) =>
|
||||
[
|
||||
...entries,
|
||||
{ pane, message, at: new Date().toLocaleTimeString() },
|
||||
].slice(-100),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
(event: FormEvent): void => {
|
||||
event.preventDefault();
|
||||
localStorage.setItem(CREDENTIALS_KEY, JSON.stringify(credentials));
|
||||
const { homeserver, username, password, room } = credentials;
|
||||
|
||||
const progress = (message: string): void =>
|
||||
setState({ phase: "starting", progress: message });
|
||||
progress("Starting");
|
||||
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
// One at a time: two logins at once from the same account is the
|
||||
// shape of request homeservers rate limit
|
||||
const sessions: Session[] = [];
|
||||
for (const label of ["Call A", "Call B"])
|
||||
sessions.push({
|
||||
label,
|
||||
client: await createSession(
|
||||
homeserver,
|
||||
username,
|
||||
password,
|
||||
(message) => progress(`${label}: ${message}`),
|
||||
),
|
||||
});
|
||||
|
||||
progress("Joining the room");
|
||||
let roomId = room;
|
||||
for (const { client } of sessions)
|
||||
roomId = await joinRoom(client, roomId);
|
||||
|
||||
setState({ phase: "started", roomId, sessions });
|
||||
} catch (e) {
|
||||
logger.error("The harness could not start", e);
|
||||
setState({ phase: "failed", error: `${e}` });
|
||||
}
|
||||
})();
|
||||
},
|
||||
[credentials],
|
||||
);
|
||||
|
||||
const field = (
|
||||
name: keyof Credentials,
|
||||
label: string,
|
||||
type = "text",
|
||||
): ReactNode => (
|
||||
<label className={styles.field}>
|
||||
{label}
|
||||
<input
|
||||
type={type}
|
||||
value={credentials[name]}
|
||||
onChange={(e): void =>
|
||||
setCredentials((c) => ({ ...c, [name]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
if (state.phase !== "started")
|
||||
return (
|
||||
<form className={styles.credentials} onSubmit={start}>
|
||||
<h1>Element Call component harness</h1>
|
||||
<p>
|
||||
Signs in twice and shows the Element Call component twice, in a page
|
||||
that is not Element Call's own.
|
||||
</p>
|
||||
{field("homeserver", "Homeserver")}
|
||||
{field("username", "Username")}
|
||||
{field("password", "Password", "password")}
|
||||
{field("room", "Room ID or alias")}
|
||||
<button type="submit" disabled={state.phase === "starting"}>
|
||||
Start
|
||||
</button>
|
||||
{state.phase === "starting" && <p>{state.progress}</p>}
|
||||
{state.phase === "failed" && (
|
||||
<p className={styles.error}>{state.error}</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.harness}>
|
||||
<header className={styles.header}>
|
||||
<h1>Element Call component harness</h1>
|
||||
<code>{state.roomId}</code>
|
||||
<button onClick={(): void => setDialogOpen(true)}>
|
||||
Open a host dialog
|
||||
</button>
|
||||
<label>
|
||||
Theme{" "}
|
||||
<select
|
||||
value={theme ?? ""}
|
||||
onChange={(e): void => setTheme(e.target.value || undefined)}
|
||||
>
|
||||
<option value="">Element Call's choice</option>
|
||||
<option value="light">light</option>
|
||||
<option value="dark">dark</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Language{" "}
|
||||
<select
|
||||
value={language ?? ""}
|
||||
onChange={(e): void => setLanguage(e.target.value || undefined)}
|
||||
>
|
||||
<option value="">Browser default</option>
|
||||
{supportedLanguages.map((tag) => (
|
||||
<option key={tag} value={tag}>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</header>
|
||||
<div className={styles.middle}>
|
||||
<HostChrome />
|
||||
<main className={styles.panes}>
|
||||
{state.sessions.map((session) => (
|
||||
<Pane
|
||||
key={session.label}
|
||||
session={session}
|
||||
roomId={state.roomId}
|
||||
theme={theme}
|
||||
language={language}
|
||||
log={log}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
<section className={styles.log} data-testid="bridge-log">
|
||||
<h2>Host bridge</h2>
|
||||
<ol>
|
||||
{entries.map((entry, i) => (
|
||||
<li key={i}>
|
||||
<code>{entry.at}</code> <strong>{entry.pane}</strong>{" "}
|
||||
{entry.message}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
{dialogOpen && <HostDialog onClose={(): void => setDialogOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/* The host page's own styles. Deliberately plain, and deliberately not using
|
||||
Element Call's design tokens: the harness should look like it does because of
|
||||
this file, not because Element Call styled it. */
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
background-color: #f4f4f5;
|
||||
color: #18181b;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Element Call component harness</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Deliberately plain: this page stands in for a host application, so
|
||||
anything it looks like must have come from the host's own styles or from
|
||||
Element Call reaching outside its container. -->
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
// What a host provides along with its copy of Compound: its stylesheet, the
|
||||
// design tokens and the fonts they name. The component does not bundle them.
|
||||
import "@fontsource/inter/400.css";
|
||||
import "@fontsource/inter/500.css";
|
||||
import "@fontsource/inter/600.css";
|
||||
import "@fontsource/inter/700.css";
|
||||
import "@fontsource/inconsolata/400.css";
|
||||
import "@fontsource/inconsolata/700.css";
|
||||
import "@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css";
|
||||
import "@vector-im/compound-web/dist/style.css";
|
||||
import { type ConfigOptions, initializeElementCall } from "../index";
|
||||
import { Harness } from "./Harness";
|
||||
// After Element Call's, so that the host has the last word on its own page
|
||||
import "./host.css";
|
||||
|
||||
/**
|
||||
* The development app's own `config.json`, so that the harness runs Element
|
||||
* Call the way `pnpm dev` does. It is not in the repository — developers copy
|
||||
* it from `config/config.devenv.json` — so its absence is expected rather than
|
||||
* an error.
|
||||
*/
|
||||
async function loadConfig(): Promise<ConfigOptions> {
|
||||
try {
|
||||
const response = await fetch("/config.json");
|
||||
if (response.ok) return (await response.json()) as ConfigOptions;
|
||||
logger.warn(
|
||||
`No config.json (${response.status}); running with Element Call's defaults`,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.warn("Could not read config.json", e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
await initializeElementCall(await loadConfig());
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<Harness />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
type MatrixClient,
|
||||
MemoryStore,
|
||||
SyncState,
|
||||
} from "matrix-js-sdk";
|
||||
|
||||
/**
|
||||
* Logs in and brings up a client the way a host application would, so that the
|
||||
* component is handed a real one rather than something Element Call built for
|
||||
* itself.
|
||||
*
|
||||
* Everything is kept in memory and a fresh login happens on every reload. That
|
||||
* costs a device on the development homeserver each time, which is harmless,
|
||||
* and buys the harness two clients that cannot tread on each other's storage.
|
||||
* Persisting the login to make reloads quicker would mean persisting the
|
||||
* crypto store too: reusing a device ID with a fresh crypto store generates new
|
||||
* device keys, and uploading them conflicts with the ones the server already
|
||||
* holds.
|
||||
*/
|
||||
export async function createSession(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
onProgress: (message: string) => void,
|
||||
): Promise<MatrixClient> {
|
||||
onProgress("Logging in");
|
||||
const login = await createClient({ baseUrl: homeserver }).login(
|
||||
"m.login.password",
|
||||
{ identifier: { type: "m.id.user", user: username }, password },
|
||||
);
|
||||
|
||||
const client = createClient({
|
||||
baseUrl: homeserver,
|
||||
accessToken: login.access_token,
|
||||
userId: login.user_id,
|
||||
deviceId: login.device_id,
|
||||
store: new MemoryStore(),
|
||||
useAuthorizationHeader: true,
|
||||
fallbackICEServerAllowed: true,
|
||||
});
|
||||
|
||||
onProgress(`Setting up crypto for ${login.device_id}`);
|
||||
await client.initRustCrypto({ useIndexedDB: false });
|
||||
|
||||
onProgress(`Syncing ${login.device_id}`);
|
||||
await client.startClient();
|
||||
await new Promise<void>((resolve) => {
|
||||
const onSync = (state: SyncState): void => {
|
||||
if (state !== SyncState.Prepared && state !== SyncState.Syncing) return;
|
||||
client.off(ClientEvent.Sync, onSync);
|
||||
resolve();
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* The room to call in, joining it if this session is not in it yet — a host
|
||||
* hands Element Call a room it already knows about, so the harness has to get
|
||||
* itself into that position first.
|
||||
*/
|
||||
export async function joinRoom(
|
||||
client: MatrixClient,
|
||||
roomIdOrAlias: string,
|
||||
): Promise<string> {
|
||||
const room = await client.joinRoom(roomIdOrAlias);
|
||||
return room.roomId;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type ElementCallHandle,
|
||||
type ElementCallHostBridge,
|
||||
useComponentHostBridge,
|
||||
} from "./host";
|
||||
|
||||
describe("useComponentHostBridge", () => {
|
||||
test("keeps one identity while the host supplies new objects", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ supplied }: { supplied: ElementCallHostBridge }) =>
|
||||
useComponentHostBridge(supplied, undefined, undefined),
|
||||
{ initialProps: { supplied: {} } },
|
||||
);
|
||||
const first = result.current;
|
||||
rerender({ supplied: { notifyJoined: async () => {} } });
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
|
||||
test("forwards to whatever the host most recently supplied", async () => {
|
||||
const before = vi.fn().mockResolvedValue(undefined);
|
||||
const after = vi.fn().mockResolvedValue(undefined);
|
||||
const { result, rerender } = renderHook(
|
||||
({ supplied }: { supplied: ElementCallHostBridge }) =>
|
||||
useComponentHostBridge(supplied, undefined, undefined),
|
||||
{ initialProps: { supplied: { notifyJoined: before } } },
|
||||
);
|
||||
rerender({ supplied: { notifyJoined: after } });
|
||||
|
||||
await result.current.notifyJoined();
|
||||
expect(before).not.toHaveBeenCalled();
|
||||
expect(after).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("is quiet about what the host did not implement", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useComponentHostBridge(undefined, undefined, undefined),
|
||||
);
|
||||
await expect(result.current.contentLoaded()).resolves.toBeUndefined();
|
||||
await expect(
|
||||
result.current.notifyDeviceMute({
|
||||
audio_enabled: true,
|
||||
video_enabled: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(result.current.supportsReactions).toBe(true);
|
||||
// Starting the user unmuted unasked is something a host has to opt into
|
||||
expect(result.current.allowJoinUnmutedViaIntent).toBe(false);
|
||||
});
|
||||
|
||||
test("lets the host allow joining unmuted on the intent", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ supplied }: { supplied: ElementCallHostBridge }) =>
|
||||
useComponentHostBridge(supplied, undefined, undefined),
|
||||
{ initialProps: { supplied: {} } },
|
||||
);
|
||||
expect(result.current.allowJoinUnmutedViaIntent).toBe(false);
|
||||
|
||||
// Read through to whatever the host most recently said
|
||||
rerender({ supplied: { allowJoinUnmutedViaIntent: true } });
|
||||
expect(result.current.allowJoinUnmutedViaIntent).toBe(true);
|
||||
});
|
||||
|
||||
test("only has a close when the host has one, since that is a signal", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ supplied }: { supplied: ElementCallHostBridge }) =>
|
||||
useComponentHostBridge(supplied, undefined, undefined),
|
||||
{ initialProps: { supplied: {} } },
|
||||
);
|
||||
expect(result.current.close).toBeUndefined();
|
||||
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
rerender({ supplied: { close } });
|
||||
expect(result.current.close).toBeDefined();
|
||||
});
|
||||
|
||||
test("never offers profile changes, since the account is the host's", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useComponentHostBridge(undefined, undefined, undefined),
|
||||
);
|
||||
expect(result.current.supportsProfileChanges).toBe(false);
|
||||
});
|
||||
|
||||
describe("the handle", () => {
|
||||
test("delivers a request to what is listening and resolves on its reply", async () => {
|
||||
const ref = createRef<ElementCallHandle>();
|
||||
const { result } = renderHook(() =>
|
||||
useComponentHostBridge(undefined, ref, undefined),
|
||||
);
|
||||
|
||||
const received = vi.fn();
|
||||
result.current.deviceMute$.subscribe(({ data, reply }) => {
|
||||
received(data);
|
||||
reply({ audio_enabled: data.audio_enabled!, video_enabled: true });
|
||||
});
|
||||
|
||||
await expect(
|
||||
ref.current!.setDeviceMute({ audio_enabled: false }),
|
||||
).resolves.toEqual({ audio_enabled: false, video_enabled: true });
|
||||
expect(received).toHaveBeenCalledWith({ audio_enabled: false });
|
||||
});
|
||||
|
||||
test("refuses a request nothing in Element Call is listening for", async () => {
|
||||
const ref = createRef<ElementCallHandle>();
|
||||
renderHook(() => useComponentHostBridge(undefined, ref, undefined));
|
||||
|
||||
await expect(ref.current!.hangUp()).rejects.toThrow(
|
||||
"Nothing in Element Call can hang up right now",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the theme", () => {
|
||||
test("reaches a subscriber that arrives after it was set", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useComponentHostBridge(undefined, undefined, "light"),
|
||||
);
|
||||
const names: (string | undefined)[] = [];
|
||||
result.current.themeChange$.subscribe(({ data }) =>
|
||||
names.push(data.name),
|
||||
);
|
||||
expect(names).toEqual(["light"]);
|
||||
});
|
||||
|
||||
test("follows the prop", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ theme }: { theme: string | undefined }) =>
|
||||
useComponentHostBridge(undefined, undefined, theme),
|
||||
{ initialProps: { theme: "light" } },
|
||||
);
|
||||
const names: (string | undefined)[] = [];
|
||||
result.current.themeChange$.subscribe(({ data }) =>
|
||||
names.push(data.name),
|
||||
);
|
||||
|
||||
rerender({ theme: "dark" });
|
||||
expect(names).toEqual(["light", "dark"]);
|
||||
});
|
||||
|
||||
test("says nothing when the host leaves the theme to Element Call", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useComponentHostBridge(undefined, undefined, undefined),
|
||||
);
|
||||
const names: (string | undefined)[] = [];
|
||||
result.current.themeChange$.subscribe(({ data }) =>
|
||||
names.push(data.name),
|
||||
);
|
||||
expect(names).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How a host application and the Element Call component talk to each other.
|
||||
*
|
||||
* Inside Element Call the host is a {@link HostBridge}, which carries the
|
||||
* host's requests as rxjs observables because that is what the widget API and
|
||||
* the view models work in. A host should not have to know about rxjs, or agree
|
||||
* with us on a version of it, so a component host sees neither: it implements
|
||||
* plain async callbacks for what Element Call tells it, and makes its own
|
||||
* requests through an imperative handle on the component, the way it would
|
||||
* call `play()` on a video element. This module adapts the one to the other.
|
||||
*/
|
||||
|
||||
import { type Ref, useEffect, useImperativeHandle } from "react";
|
||||
import { ReplaySubject, Subject } from "rxjs";
|
||||
|
||||
import {
|
||||
type DeviceMuteRequest,
|
||||
type DeviceMuteState,
|
||||
type HostBridge,
|
||||
type HostRequest,
|
||||
} from "../src/HostBridge";
|
||||
import { type JoinCallData } from "../src/widget";
|
||||
import { useInitial } from "../src/useInitial";
|
||||
import { useLatest } from "../src/useLatest";
|
||||
|
||||
/**
|
||||
* What Element Call tells the application hosting it as a component.
|
||||
* Everything is optional: a host implements what it wants to hear about.
|
||||
*
|
||||
* Compared by nothing — Element Call always calls whichever one it was most
|
||||
* recently given, so this may be written inline.
|
||||
*/
|
||||
export interface ElementCallHostBridge {
|
||||
/**
|
||||
* Asks the host to keep Element Call on screen (or stop doing so), so that a
|
||||
* call in progress is not torn down when the user navigates elsewhere.
|
||||
*/
|
||||
setAlwaysOnScreen?(alwaysOnScreen: boolean): Promise<void>;
|
||||
/** Tells the host that Element Call has finished loading. */
|
||||
contentLoaded?(): Promise<void>;
|
||||
/** Tells the host that the user has joined the call. */
|
||||
notifyJoined?(): Promise<void>;
|
||||
/** Tells the host that the user has hung up. */
|
||||
notifyHungUp?(): Promise<void>;
|
||||
/** Tells the host the user's current audio and video mute state. */
|
||||
notifyDeviceMute?(state: DeviceMuteState): Promise<void>;
|
||||
/**
|
||||
* Asks the host to close Element Call: to unmount the component. Its
|
||||
* presence is what makes Element Call offer a close button on its error
|
||||
* screens, and leave the host to decide what is shown once a call has ended.
|
||||
* Without it, Element Call shows its own post-call screen, if it has one for
|
||||
* the situation, or nothing.
|
||||
*/
|
||||
close?(): Promise<void>;
|
||||
/**
|
||||
* Whether Element Call may send and receive reactions in this room.
|
||||
* Defaults to true.
|
||||
*/
|
||||
readonly supportsReactions?: boolean;
|
||||
/**
|
||||
* Whether the user may start unmuted when the intent skips the lobby, so
|
||||
* that they never see their devices before joining. Defaults to false: the
|
||||
* user starts muted and unmutes themselves. A host that chose the intent on
|
||||
* the user's behalf, and is sure they expect to be heard and seen at once,
|
||||
* says so here — as a Matrix client hosting Element Call as a widget does.
|
||||
*/
|
||||
readonly allowJoinUnmutedViaIntent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a host can ask of a mounted Element Call, reached through the
|
||||
* component's `ref`. Each request resolves once Element Call has acted on it,
|
||||
* and rejects if nothing in Element Call is in a position to act: hanging up
|
||||
* when there is no call, say.
|
||||
*/
|
||||
export interface ElementCallHandle {
|
||||
/**
|
||||
* Joins the call, when Element Call was configured to `preload` and is
|
||||
* waiting to be told to. Says which devices to join with.
|
||||
*/
|
||||
join(devices: JoinCallData): Promise<void>;
|
||||
/** Leaves the call. */
|
||||
hangUp(): Promise<void>;
|
||||
/**
|
||||
* Changes the mute state, for whichever of audio and video is given, and
|
||||
* reports the state that results.
|
||||
*/
|
||||
setDeviceMute(request: DeviceMuteRequest): Promise<DeviceMuteState>;
|
||||
}
|
||||
|
||||
/** Hands a request to Element Call and waits for it to be acknowledged. */
|
||||
async function request<Data, Reply>(
|
||||
listeners: Subject<HostRequest<Data, Reply>>,
|
||||
what: string,
|
||||
data: Data,
|
||||
): Promise<Reply> {
|
||||
if (!listeners.observed)
|
||||
throw new Error(`Nothing in Element Call can ${what} right now`);
|
||||
return await new Promise((resolve) =>
|
||||
listeners.next({ data, reply: resolve }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link HostBridge} the rest of Element Call sees, built from what a
|
||||
* component host supplies and wired to the handle it is given.
|
||||
*
|
||||
* The bridge is created once and never changes identity — everything that
|
||||
* depends on it would otherwise restart when the host re-rendered with a new
|
||||
* object — and forwards each call to whatever the host most recently passed.
|
||||
*/
|
||||
export function useComponentHostBridge(
|
||||
supplied: ElementCallHostBridge | undefined,
|
||||
ref: Ref<ElementCallHandle> | undefined,
|
||||
/** The theme the host wants, or undefined to leave it to Element Call. */
|
||||
theme: string | undefined,
|
||||
): HostBridge {
|
||||
const latest = useLatest(supplied ?? {});
|
||||
|
||||
const requests = useInitial(() => ({
|
||||
// The theme is state, not an event: a `theme` prop rather than a request
|
||||
// on the handle. It travels this channel because that is how the rest of
|
||||
// Element Call hears about a host's theme, and replays so that whatever
|
||||
// subscribes after the host has set it — everything, on first render —
|
||||
// still hears the current one.
|
||||
themeChange$: new ReplaySubject<HostRequest<{ name?: string }>>(1),
|
||||
join$: new Subject<HostRequest<JoinCallData>>(),
|
||||
hangUp$: new Subject<HostRequest<Record<string, never>>>(),
|
||||
deviceMute$: new Subject<HostRequest<DeviceMuteRequest, DeviceMuteState>>(),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== undefined)
|
||||
requests.themeChange$.next({ data: { name: theme }, reply: () => {} });
|
||||
}, [requests, theme]);
|
||||
|
||||
const bridge = useInitial((): HostBridge => ({
|
||||
setAlwaysOnScreen: async (alwaysOnScreen) => {
|
||||
await latest.current.setAlwaysOnScreen?.(alwaysOnScreen);
|
||||
},
|
||||
contentLoaded: async () => {
|
||||
await latest.current.contentLoaded?.();
|
||||
},
|
||||
notifyJoined: async () => {
|
||||
await latest.current.notifyJoined?.();
|
||||
},
|
||||
notifyHungUp: async () => {
|
||||
await latest.current.notifyHungUp?.();
|
||||
},
|
||||
notifyDeviceMute: async (state) => {
|
||||
await latest.current.notifyDeviceMute?.(state);
|
||||
},
|
||||
// Whether these exist is itself information, so they are read through
|
||||
// rather than wrapped unconditionally
|
||||
get close() {
|
||||
const close = latest.current.close;
|
||||
return close === undefined
|
||||
? undefined
|
||||
: async (): Promise<void> => await close();
|
||||
},
|
||||
// Not offered to a component host: the client it hands over holds the
|
||||
// credentials to fetch media itself. A widget's client does not, which
|
||||
// is what the internal bridge's `downloadMedia` is for.
|
||||
get supportsReactions(): boolean {
|
||||
return latest.current.supportsReactions ?? true;
|
||||
},
|
||||
get allowJoinUnmutedViaIntent(): boolean {
|
||||
return latest.current.allowJoinUnmutedViaIntent ?? false;
|
||||
},
|
||||
// Whatever the host says or does not say, the account is its own: it
|
||||
// signed the user in and handed us the client. So Element Call never
|
||||
// offers to edit the profile from inside a component.
|
||||
supportsProfileChanges: false,
|
||||
...requests,
|
||||
}));
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): ElementCallHandle => ({
|
||||
join: async (devices) =>
|
||||
await request(requests.join$, "join a call", devices),
|
||||
hangUp: async () => await request(requests.hangUp$, "hang up", {}),
|
||||
setDeviceMute: async (muteRequest) =>
|
||||
await request(
|
||||
requests.deviceMute$,
|
||||
"change the mute state",
|
||||
muteRequest,
|
||||
),
|
||||
}),
|
||||
[requests],
|
||||
);
|
||||
|
||||
return bridge;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL
|
||||
*
|
||||
* Element Call as a React component, for an application that wants to show a
|
||||
* call inside itself rather than in an iframe.
|
||||
*
|
||||
* The host supplies the client and says which room to call in; Element Call
|
||||
* supplies the call. Everything it would otherwise take from the page it is on
|
||||
* — the URL, the document body, a Matrix session of its own — comes from the
|
||||
* host instead, or is confined to the container it is mounted in.
|
||||
*/
|
||||
|
||||
// The element defaults and custom properties every Element Call stylesheet
|
||||
// builds on. Written for a page, they speak of `html`, `body` and bare
|
||||
// elements; the component build confines them, and every other stylesheet in
|
||||
// this bundle, to the root element below (see build/scopeStylesToRoot.ts), so
|
||||
// that the host's document is left as it was.
|
||||
//
|
||||
// Compound is not in here. The component uses the host's copy of it (a peer
|
||||
// dependency, left external by the build), and the host loads Compound's
|
||||
// stylesheet, design tokens and fonts along with it: what Compound floats into
|
||||
// the host's body — tooltips, menus — is then styled by the same stylesheet as
|
||||
// everything else there, and Element Call's root takes the tokens and fonts of
|
||||
// the theme class it carries, as any element on the host's page would.
|
||||
//
|
||||
// Where these land relative to the component stylesheets is the bundler's
|
||||
// choice — the standalone app puts them first, this build puts them in the
|
||||
// middle — so nothing in base.css may depend on winning or losing against a
|
||||
// component's own rules at equal specificity. It currently does not: what it
|
||||
// declares unlayered is custom properties on Element Call's root, which
|
||||
// components inherit rather than compete with.
|
||||
import "../src/base.css";
|
||||
|
||||
import {
|
||||
type FC,
|
||||
type JSX,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { ErrorBoundary } from "@sentry/react";
|
||||
import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill";
|
||||
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js";
|
||||
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
import EN from "../locales/en/app.json";
|
||||
import { CallView } from "../src/room/CallView";
|
||||
import { ErrorPage } from "../src/FullScreenView";
|
||||
import { ClientProvider } from "../src/ClientContext";
|
||||
import { HostBridgeProvider } from "../src/HostBridge";
|
||||
import { RootElementProvider, useRootElement } from "../src/RootElementContext";
|
||||
import {
|
||||
configurationForIntent,
|
||||
componentProperties,
|
||||
type UrlParams,
|
||||
UrlParamsProvider,
|
||||
UserIntent,
|
||||
useUrlParams,
|
||||
} from "../src/UrlParams";
|
||||
import { MediaDevicesContext } from "../src/MediaDevicesContext";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { ProcessorProvider } from "../src/tracks/TrackProcessorContext";
|
||||
import { Config } from "../src/config/Config";
|
||||
import { type ConfigOptions } from "../src/config/ConfigOptions";
|
||||
import { i18n } from "../src/utils/i18n";
|
||||
import { useTheme } from "../src/useTheme";
|
||||
import { useStableValue } from "../src/useStableValue";
|
||||
import styles from "./ElementCall.module.css";
|
||||
import { useComponentHostBridge } from "./host";
|
||||
import { CallPortalRoot } from "./CallPortalRoot";
|
||||
import { type ElementCallProps } from "./api";
|
||||
import { supportedLanguages, translationsBackend } from "./localization";
|
||||
|
||||
// The languages Element Call can be shown in
|
||||
export { supportedLanguages } from "./localization";
|
||||
// Everything a host needs to talk about Element Call — the props, the handle,
|
||||
// the host bridge, the intents and what each means — also available without
|
||||
// the component itself from `@element-hq/element-call-component/api`
|
||||
export * from "./api";
|
||||
|
||||
/**
|
||||
* Prepares the things Element Call needs before it can be shown: translations,
|
||||
* `Intl` polyfills for older browsers, and its configuration.
|
||||
*
|
||||
* Await this once, before rendering {@link ElementCall}.
|
||||
*/
|
||||
export async function initializeElementCall(
|
||||
config: ConfigOptions = {},
|
||||
): Promise<void> {
|
||||
const polyfills: Promise<unknown>[] = [];
|
||||
if (shouldPolyfillSegmenter())
|
||||
polyfills.push(import("@formatjs/intl-segmenter/polyfill-force"));
|
||||
if (shouldPolyfillDurationFormat())
|
||||
polyfills.push(import("@formatjs/intl-durationformat/polyfill-force.js"));
|
||||
await Promise.all(polyfills);
|
||||
|
||||
Config.initWith(config);
|
||||
await i18n
|
||||
.use(translationsBackend)
|
||||
.use(new LanguageDetector())
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
defaultNS: "app",
|
||||
keySeparator: ".",
|
||||
nsSeparator: false,
|
||||
pluralSeparator: "_",
|
||||
contextSeparator: "|",
|
||||
supportedLngs: [...supportedLanguages],
|
||||
interpolation: { escapeValue: false },
|
||||
// English is bundled in, so the fallback never has to be loaded; every
|
||||
// other language arrives from the backend when first asked for.
|
||||
partialBundledLanguages: true,
|
||||
resources: { en: { app: EN } },
|
||||
detection: {
|
||||
// The browser's language, until the host says otherwise through the
|
||||
// `language` prop. Nothing is remembered: the choice is the host's.
|
||||
order: ["navigator"],
|
||||
caches: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Applies the theme and background to the container, before it is painted. */
|
||||
const Decoration: FC<{ children: JSX.Element }> = ({ children }) => {
|
||||
useTheme();
|
||||
const { background } = useUrlParams();
|
||||
const rootElement = useRootElement();
|
||||
useLayoutEffect(() => {
|
||||
rootElement.setAttribute("data-background", background);
|
||||
}, [rootElement, background]);
|
||||
return children;
|
||||
};
|
||||
|
||||
export const ElementCall: FC<ElementCallProps> = ({
|
||||
client,
|
||||
roomId,
|
||||
intent = UserIntent.JoinExistingCall,
|
||||
config,
|
||||
hostBridge: suppliedHostBridge,
|
||||
ref,
|
||||
theme,
|
||||
language,
|
||||
portalRoot,
|
||||
}): ReactNode => {
|
||||
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
|
||||
|
||||
useEffect(() => {
|
||||
if (language !== undefined)
|
||||
i18n
|
||||
.changeLanguage(language)
|
||||
.catch((e) => logger.error(`Could not switch to ${language}`, e));
|
||||
}, [language]);
|
||||
|
||||
// The container is what Element Call decorates and portals into, so nothing
|
||||
// inside can render until we have it.
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
// Element Call has no URL of its own to read any of this from, and the
|
||||
// host's URL is not Element Call's business, so the defaults come from the
|
||||
// intent with the host's wishes over the top.
|
||||
//
|
||||
// Everything downstream — the mute state, the call view model and with it
|
||||
// the media connection — is keyed on the identity of this object, so it has
|
||||
// to be stable for as long as its contents are. A host writing `config`
|
||||
// inline would otherwise tear the call down on every render.
|
||||
const stableConfig = useStableValue(config);
|
||||
const params = useMemo(
|
||||
(): UrlParams => ({
|
||||
...componentProperties,
|
||||
roomId,
|
||||
...configurationForIntent(intent),
|
||||
...stableConfig,
|
||||
}),
|
||||
[roomId, intent, stableConfig],
|
||||
);
|
||||
|
||||
// Created in an effect so that the scope it lives in ends when the component
|
||||
// is unmounted (or these options change), rather than keeping its device
|
||||
// observers running for the rest of the page's life. Null until then, which
|
||||
// is one render.
|
||||
const { controlledAudioDevices, callIntent } = params;
|
||||
const [mediaDevices, setMediaDevices] = useState<MediaDevices | null>(null);
|
||||
useEffect(() => {
|
||||
const scope = new ObservableScope();
|
||||
setMediaDevices(
|
||||
new MediaDevices(scope, { controlledAudioDevices, callIntent }),
|
||||
);
|
||||
return (): void => {
|
||||
setMediaDevices(null);
|
||||
scope.end();
|
||||
};
|
||||
}, [controlledAudioDevices, callIntent]);
|
||||
|
||||
const room = client.getRoom(roomId);
|
||||
const rtcSession = useMemo(
|
||||
() => (room === null ? null : client.matrixRTC.getRoomSession(room)),
|
||||
[client, room],
|
||||
);
|
||||
|
||||
if (rtcSession === null)
|
||||
logger.error(
|
||||
`Element Call was asked to call in ${roomId}, which its host's client does not know about`,
|
||||
);
|
||||
|
||||
// Everything the call needs is in hand once these exist, and the first
|
||||
// render with them is where the call itself appears: the moment the host
|
||||
// is told that Element Call has loaded, as the widget tells its client once
|
||||
// its own initialisation is over. Once per mount, however often the pieces
|
||||
// are later swapped out.
|
||||
const ready =
|
||||
container !== null && rtcSession !== null && mediaDevices !== null;
|
||||
const announcedLoaded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!ready || announcedLoaded.current) return;
|
||||
announcedLoaded.current = true;
|
||||
hostBridge
|
||||
.contentLoaded()
|
||||
.catch((e) => logger.error("Could not tell the host we had loaded", e));
|
||||
}, [ready, hostBridge]);
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<HostBridgeProvider value={hostBridge}>
|
||||
<UrlParamsProvider value={params}>
|
||||
<div ref={setContainer} className={styles.root}>
|
||||
{ready && (
|
||||
// Stands in for the standalone page's `#root`: see `.content`
|
||||
// in ElementCall.module.css
|
||||
<div className={styles.content}>
|
||||
<CallPortalRoot root={portalRoot}>
|
||||
<RootElementProvider value={container}>
|
||||
{/* Whatever goes wrong in here is shown in here. Left to
|
||||
propagate, an error would unmount the host's own tree. */}
|
||||
<ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} />}
|
||||
// A broken call should not hold the host on screen
|
||||
onError={() => void hostBridge.setAlwaysOnScreen(false)}
|
||||
>
|
||||
<Decoration>
|
||||
<TooltipProvider>
|
||||
<ClientProvider client={client}>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<CallView
|
||||
client={client}
|
||||
rtcSession={rtcSession}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={params.confineToRoom}
|
||||
preload={params.preload}
|
||||
skipLobby={params.skipLobby}
|
||||
/>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
</TooltipProvider>
|
||||
</Decoration>
|
||||
</ErrorBoundary>
|
||||
</RootElementProvider>
|
||||
</CallPortalRoot>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</UrlParamsProvider>
|
||||
</HostBridgeProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { supportedLanguages, translationsBackend } from "./localization";
|
||||
|
||||
const read = async (
|
||||
language: string,
|
||||
namespace = "app",
|
||||
): Promise<Record<string, unknown>> =>
|
||||
await new Promise((resolve, reject) =>
|
||||
translationsBackend.read(language, namespace, (error, data) => {
|
||||
if (error) reject(error);
|
||||
else resolve(data as Record<string, unknown>);
|
||||
}),
|
||||
);
|
||||
|
||||
describe("component translations", () => {
|
||||
test("offer every language in locales/, tagged as its directory is", () => {
|
||||
expect(supportedLanguages).toContain("en");
|
||||
expect(supportedLanguages).toContain("de");
|
||||
expect(supportedLanguages).toContain("zh-Hans");
|
||||
expect(new Set(supportedLanguages).size).toBe(supportedLanguages.length);
|
||||
});
|
||||
|
||||
test("load a language's translations on demand", async () => {
|
||||
const de = await read("de");
|
||||
expect(de).toHaveProperty("action");
|
||||
expect(de).not.toEqual(await read("en"));
|
||||
});
|
||||
|
||||
test("refuse a language there are no translations for", async () => {
|
||||
await expect(read("xx")).rejects.toThrow("No app translations for xx");
|
||||
});
|
||||
|
||||
test("refuse a namespace there are no translations for", async () => {
|
||||
await expect(read("en", "other")).rejects.toThrow(
|
||||
"No other translations for en",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Translations for Element Call as a component.
|
||||
*
|
||||
* The standalone app fetches its locale files at runtime from URLs its own
|
||||
* build emits, which a host serving the library from somewhere else could not
|
||||
* resolve. The component instead has the bundler split every locale into a
|
||||
* chunk of its own, loaded the first time its language is asked for; English,
|
||||
* the fallback, is bundled in so that the first paint never waits for it.
|
||||
*/
|
||||
|
||||
import { type BackendModule, type ResourceKey } from "i18next";
|
||||
|
||||
import { languageOfLocalePath } from "../src/utils/i18n";
|
||||
|
||||
/** Every locale, as a lazily imported module. */
|
||||
const translations = import.meta.glob<{ default: ResourceKey }>(
|
||||
"../locales/*/app.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The languages Element Call can be shown in, as BCP 47 tags — `en`, `de`,
|
||||
* `zh-Hans` and so on. A language that is not one of these falls back to its
|
||||
* base language where there is one (`de-AT` to `de`), and to English otherwise.
|
||||
*/
|
||||
export const supportedLanguages: readonly string[] = [
|
||||
...new Set(Object.keys(translations).map(languageOfLocalePath)),
|
||||
];
|
||||
|
||||
/** Loads translations on demand. */
|
||||
export const translationsBackend: BackendModule = {
|
||||
type: "backend",
|
||||
init(): void {},
|
||||
read(language: string, namespace: string, callback): void {
|
||||
const load = translations[`../locales/${language}/${namespace}.json`];
|
||||
if (load === undefined) {
|
||||
callback(new Error(`No ${namespace} translations for ${language}`), null);
|
||||
return;
|
||||
}
|
||||
load().then(
|
||||
(module) => callback(null, module.default),
|
||||
(error: unknown) =>
|
||||
callback(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
null,
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user