mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Compare commits
217
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 | ||
|
|
426acd222e |
@@ -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`);
|
||||
@@ -102,6 +102,9 @@
|
||||
- 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +1,12 @@
|
||||
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:
|
||||
lint:
|
||||
name: Lint, format & type check
|
||||
@@ -9,6 +15,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
|
||||
@@ -19,7 +27,10 @@ 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: Formatting
|
||||
run: "pnpm run format:check"
|
||||
- name: i18n
|
||||
|
||||
@@ -3,6 +3,25 @@ on:
|
||||
pull_request: {}
|
||||
push:
|
||||
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,7 +42,10 @@ 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
|
||||
@@ -35,8 +59,11 @@ jobs:
|
||||
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
|
||||
if: ${{ !inputs.disable_coverage }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -45,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
|
||||
@@ -59,7 +89,10 @@ 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
|
||||
|
||||
@@ -41,3 +41,6 @@ yarn-error.log
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# Nested checkout made by scripts/layered.sh
|
||||
/matrix-js-sdk/
|
||||
|
||||
+9
-1
@@ -2,5 +2,13 @@
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 80,
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": ["pnpm-lock.yaml", "node_modules", "dist"]
|
||||
"ignorePatterns": [
|
||||
"pnpm-lock.yaml",
|
||||
"node_modules",
|
||||
"dist",
|
||||
".agents",
|
||||
".claude",
|
||||
"agent-workspace",
|
||||
"tools/oxlint/anti-slop"
|
||||
]
|
||||
}
|
||||
|
||||
+38
-1
@@ -1,6 +1,22 @@
|
||||
{
|
||||
"$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",
|
||||
@@ -13,7 +29,8 @@
|
||||
],
|
||||
"jsPlugins": [
|
||||
"eslint-plugin-storybook",
|
||||
"eslint-plugin-element-call"
|
||||
"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"
|
||||
],
|
||||
@@ -35,6 +52,26 @@
|
||||
],
|
||||
"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",
|
||||
|
||||
@@ -48,6 +48,8 @@ pnpm i18n:check
|
||||
- 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.
|
||||
|
||||
+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,16 +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.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -297,8 +297,8 @@ The package is not published yet. A host installs it as a git dependency on the
|
||||
```
|
||||
|
||||
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
|
||||
and is memory-hungry, since it inherits the `--max-old-space-size` setting of
|
||||
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
|
||||
@@ -306,8 +306,28 @@ 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` and `livekit-client` itself, since the bundle leaves
|
||||
them external.
|
||||
`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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service"
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: false
|
||||
regex: "@.*:homeserver-1" # Cover all local users
|
||||
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.
|
||||
|
||||
@@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service"
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: false
|
||||
regex: "@.*:homeserver" # Cover all local users
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+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>
|
||||
);
|
||||
@@ -34,10 +34,18 @@ component rather than a page makes. */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Compound's overlay container, which holds tooltips and popovers, has to fill
|
||||
the container for the elements inside it to be positioned against it. The
|
||||
standalone page does the same for the container under `#root`. */
|
||||
.root > [data-overlay-container] {
|
||||
position: relative;
|
||||
/* 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;
|
||||
}
|
||||
@@ -40,10 +40,10 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
||||
* Element Call as a component.
|
||||
*
|
||||
* As a page of its own, Element Call can style the document: normalize.css and
|
||||
* Compound speak of `html`, `body` and bare elements, and the design tokens 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:
|
||||
* 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.
|
||||
@@ -55,9 +55,10 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
||||
* 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 fonts and design tokens are still inherited by everything inside
|
||||
* it, the way they were from `body` and `:root`, and `@font-face` declarations
|
||||
* stay global, which they are by nature.
|
||||
* 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 {
|
||||
|
||||
@@ -9,6 +9,16 @@ 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
|
||||
|
||||
+52
-128
@@ -17,33 +17,37 @@ Please see LICENSE in the repository root for full details.
|
||||
* host instead, or is confined to the container it is mounted in.
|
||||
*/
|
||||
|
||||
// The design tokens, fonts and element defaults every Element Call stylesheet
|
||||
// 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, and everything from Compound
|
||||
// sits in a `@layer`, which loses to unlayered rules either way.
|
||||
// components inherit rather than compete with.
|
||||
import "../src/base.css";
|
||||
|
||||
import {
|
||||
type FC,
|
||||
type JSX,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
@@ -62,119 +66,32 @@ import { RootElementProvider, useRootElement } from "../src/RootElementContext";
|
||||
import {
|
||||
configurationForIntent,
|
||||
componentProperties,
|
||||
type UrlConfiguration,
|
||||
type UrlParams,
|
||||
UrlParamsProvider,
|
||||
type UrlProperties,
|
||||
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/livekit/TrackProcessorContext";
|
||||
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 {
|
||||
type ElementCallHandle,
|
||||
type ElementCallHostBridge,
|
||||
useComponentHostBridge,
|
||||
} from "./host";
|
||||
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";
|
||||
|
||||
// How the host and Element Call talk to each other, and what they say
|
||||
export { type ElementCallHandle, type ElementCallHostBridge } from "./host";
|
||||
export {
|
||||
type DeviceMuteRequest,
|
||||
type DeviceMuteState,
|
||||
} from "../src/HostBridge";
|
||||
export { type JoinCallData } from "../src/widget";
|
||||
// The deployment-wide configuration, as distinct from ElementCallConfiguration
|
||||
// above, which is per call
|
||||
export { type ConfigOptions } from "../src/config/ConfigOptions";
|
||||
// The values that appear in ElementCallConfiguration and in the intent
|
||||
export {
|
||||
BackgroundStyle,
|
||||
HeaderStyle,
|
||||
UserIntent,
|
||||
type UrlConfiguration,
|
||||
} from "../src/UrlParams";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* {@link supportedLanguages}, 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;
|
||||
}
|
||||
// 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,
|
||||
@@ -238,6 +155,7 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
ref,
|
||||
theme,
|
||||
language,
|
||||
portalRoot,
|
||||
}): ReactNode => {
|
||||
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
|
||||
|
||||
@@ -321,34 +239,40 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
<UrlParamsProvider value={params}>
|
||||
<div ref={setContainer} className={styles.root}>
|
||||
{ready && (
|
||||
<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>
|
||||
// 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>
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
"types": "./dist/types/component/index.d.ts",
|
||||
"default": "./dist/element-call.js"
|
||||
},
|
||||
"./api": {
|
||||
"types": "./dist/types/component/api.d.ts",
|
||||
"default": "./dist/api.js"
|
||||
},
|
||||
"./style.css": "./dist/element-call.css"
|
||||
},
|
||||
"sideEffects": [
|
||||
@@ -34,7 +38,8 @@
|
||||
"prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:component"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"livekit-client": "^2.18.1",
|
||||
"@vector-im/compound-design-tokens": "^11.0.0",
|
||||
"@vector-im/compound-web": "^10.3.1",
|
||||
"matrix-js-sdk": "*",
|
||||
"react": "^19",
|
||||
"react-dom": "^19"
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"rootDir": "..",
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
// The entry point, plus the ambient declarations (CSS modules, `?react` SVGs,
|
||||
// `import.meta.env`, …) that the sources it reaches rely on.
|
||||
"include": ["./index.tsx", "../src/@types/*.d.ts"],
|
||||
// The entry points, plus the ambient declarations (CSS modules, `?react` SVGs,
|
||||
// `import.meta.env`, …) that the sources they reach rely on.
|
||||
"include": ["./index.tsx", "./api.ts", "../src/@types/*.d.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"network_error_retry_ms": 100
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_mode": "compatibility",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"network_error_retry_ms": 100
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"network_error_retry_ms": 100
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"network_error_retry_ms": 100
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ networks:
|
||||
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
auth-service-1:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
|
||||
pull_policy: always
|
||||
hostname: auth-server-1
|
||||
environment:
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:c76d29903090eeb08ff625272277ede02ea8ee93cbaa067b77adfa89c89a494d
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:e464b2aca73e248195b24b8c6f62cd331100302284d60cc735405807fb2730be
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:c76d29903090eeb08ff625272277ede02ea8ee93cbaa067b77adfa89c89a494d
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:e464b2aca73e248195b24b8c6f62cd331100302284d60cc735405807fb2730be
|
||||
|
||||
@@ -72,8 +72,24 @@ Code that builds in only one is a bug.
|
||||
|
||||
- `build:full` — standalone app, also widget mode.
|
||||
- `build:embedded` — `@element-hq/element-call-embedded`.
|
||||
- `build:sdk` — SDK library, entry `sdk/main.ts`.
|
||||
- `build:sdk` — `@element-hq/matrixrtc-sdk`, sources in `sdk/`, entry `sdk/index.ts`.
|
||||
Packaged exactly like the component (own pnpm project, run pnpm from the repo
|
||||
root, types from `sdk/tsconfig.build.json`, externals checked by
|
||||
`pnpm lint:externals`). Interface only so far; the design is `sdk-plan.md`.
|
||||
- `build:sdk-target-based-on-call-view-model` — the SDK demo that predates the
|
||||
package, one bundle of the `CallViewModel`; entry
|
||||
`sdk-target-based-on-call-view-model/main.ts`. CI's `sdk` package
|
||||
(`build:sdk:production` / `build:sdk:development`, the Netlify `-sdk` preview)
|
||||
is still this bundle, not `build:sdk`.
|
||||
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
|
||||
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
|
||||
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
|
||||
`matrix-js-sdk` / `livekit-client` subpath the externals list omits.
|
||||
`matrix-js-sdk` / `@vector-im/compound-web` /
|
||||
`@vector-im/compound-design-tokens` subpath the externals list omits.
|
||||
`livekit-client` is bundled on purpose: no host imports it, and the pinned
|
||||
version travels with the component.
|
||||
Compound's stylesheets are loaded by `src/index.css` for the page and by the
|
||||
host for the component, never by `src/base.css`. Two entry
|
||||
points: `index.tsx` (the component) and `api.ts` (types, enums and
|
||||
`configurationForIntent`, for a host that must not load the component). `api.ts`
|
||||
may only reach types and constants — `src/UrlConfiguration.ts`, not `UrlParams`.
|
||||
|
||||
@@ -31,7 +31,28 @@ copy, the PR says so.
|
||||
- **Suites.** Test cases first, helpers below. Older suites invert this; follow the
|
||||
rule in new ones, don't reorder old ones.
|
||||
- **Names.** What a thing means, not what it is made of: `naturalLayout$`, not
|
||||
`computedLayout$`. Comments explain why, never what.
|
||||
`computedLayout$`.
|
||||
|
||||
## Comments
|
||||
|
||||
No comment is the default. One earns its place by giving a reason the code
|
||||
cannot: a platform quirk, an outside constraint, a trap that looks like a
|
||||
simplification.
|
||||
|
||||
- Say the reason, then stop. Test each sentence: does the reason still stand
|
||||
without it? If so, cut it. Most reasons fit on a line, so a long comment is
|
||||
worth a second look — it is usually carrying something that isn't one.
|
||||
- Write about the line the comment sits on, in terms of what the code does
|
||||
there — leave layout to the stylesheet and measurements to the design.
|
||||
- Name the condition ("where the platform can't route audio"), not the browser
|
||||
or the spec entry.
|
||||
- Put history — what was tried, removed or rejected, the dev journal — in the
|
||||
commit or the PR description.
|
||||
- In tests, comment only structure that looks wrong but isn't: batching, timers,
|
||||
why this scroll position.
|
||||
|
||||
Before keeping one: would it still be true after a CSS change or a refactor of
|
||||
its caller? If not, cut it.
|
||||
|
||||
## TypeScript, React and RxJS idiom win on a clash
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ translations. The browser is Playwright's, so a fresh clone needs
|
||||
- `playwright/component/` — the component in a host page, via the harness on port
|
||||
3001 that Playwright starts as a second web server. Catches container-relative
|
||||
layout, styles escaping the root, two instances on a page, host-bridge reports.
|
||||
- `playwright/sdk/` — the MatrixRTC SDK through its harness on port 3002, a page
|
||||
with none of Element Call on it. The smoke test is what the implementation is
|
||||
built against.
|
||||
- `playwright/mobile/` — Pixel 7, `mobile` project only.
|
||||
|
||||
Test what a user observes: the peer sees the change, it survives a reconnect, it is
|
||||
@@ -50,4 +53,5 @@ pnpm test # unit + storybook
|
||||
pnpm backend # Synapse + LiveKit, required for e2e
|
||||
pnpm test:playwright # or :open
|
||||
pnpm dev:component # component harness, port 3001
|
||||
pnpm dev:sdk # SDK harness, port 3002
|
||||
```
|
||||
|
||||
@@ -60,6 +60,21 @@ git config --local core.hooksPath .githooks
|
||||
|
||||
This will add the hook path for this repository only to .gihooks. which is a tracked (by git) folder containing the pre-commit hook.
|
||||
|
||||
## Testing against a matrix-js-sdk branch in CI
|
||||
|
||||
Linking covers local development. In CI, `scripts/layered.sh` plays the same
|
||||
role for `matrix-js-sdk`: after the usual frozen install, `scripts/fetchdep.sh`
|
||||
looks for a matrix-js-sdk branch with the same name as the pull request's head
|
||||
branch (in the fork first, when the pull request comes from one). If there is
|
||||
one, it is cloned, packed and installed over the locked version. If there is
|
||||
none, the install stays as `pnpm-lock.yaml` pins it.
|
||||
|
||||
This means a change that needs a not yet merged matrix-js-sdk change can be tested
|
||||
by giving both branches the same name. This mirrors how element-web tests against
|
||||
matrix-js-sdk. Once the matrix-js-sdk branch is merged and deleted, the pull
|
||||
request falls back to the lockfile and needs a lockfile bump to stay green,
|
||||
which is also what keeps `main` consistent after the merge.
|
||||
|
||||
## Background
|
||||
|
||||
Information, why this approach is used can be found in the [linking concept reasoning](./linking_concept_reasoning.md) document.
|
||||
|
||||
@@ -24,7 +24,7 @@ homeserver does not support sticky events. This is the default.
|
||||
MSC4354 and all clients on v0.17.0 or later. The local membership requests its
|
||||
token from the Matrix 2.0 JWT endpoint of the
|
||||
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
|
||||
and identifies the room by a hashed identity instead of a `livekit_alias`.
|
||||
and identifies the room by a hashed identity instead of the Matrix `room_id`.
|
||||
(Remote memberships always try the new endpoint first and fall back to the
|
||||
legacy one, so remote participants can be on either.)
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ These parameters are relevant to both [widget](./embedded_standalone.md) and [st
|
||||
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
|
||||
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
|
||||
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
|
||||
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
|
||||
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
|
||||
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
|
||||
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
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 { dirname, resolve, sep } from "node:path";
|
||||
import { ESLintUtils } from "@typescript-eslint/utils";
|
||||
|
||||
/**
|
||||
* Packages a host supplies and the SDK must not know about: UI, i18n and
|
||||
* Element Call's design system.
|
||||
*/
|
||||
const BANNED_PACKAGES = [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-i18next",
|
||||
"i18next",
|
||||
"@vector-im/",
|
||||
];
|
||||
|
||||
/**
|
||||
* Packages only a media backend may import. The session speaks MatrixRTC and
|
||||
* sees media through `sdk/src/media-backend/api.ts`; a backend under
|
||||
* `sdk/src/media-backend/<name>/` is the one place its protocol's library appears.
|
||||
*/
|
||||
const BACKEND_PACKAGES = ["livekit-client", "@livekit/"];
|
||||
|
||||
/**
|
||||
* Keeps the SDK free of Element Call: nothing under `sdk/` may import a file
|
||||
* outside `sdk/` by relative path (that is `src/`, `component/`,
|
||||
* `playwright/`) or one of the packages above. The development harness under
|
||||
* `sdk/dev/` is a host, not part of the library, and is exempt. Keeps the
|
||||
* session free of any one media backend, too: outside `sdk/src/media-backend/`,
|
||||
* a backend's packages may only be imported for their types, and only in
|
||||
* the public API, the errors and the test helpers that have to name them.
|
||||
*/
|
||||
const rule = ESLintUtils.RuleCreator(
|
||||
() => "https://github.com/element-hq/element-call",
|
||||
)({
|
||||
name: "sdk-import-boundary",
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow the SDK importing Element Call (anything outside sdk/) or host-supplied packages.",
|
||||
},
|
||||
messages: {
|
||||
outsideSdk:
|
||||
"The SDK must not import '{{specifier}}': it resolves outside sdk/. Copy or move what it needs into sdk/src.",
|
||||
bannedPackage:
|
||||
"The SDK must not import '{{specifier}}': a host supplies that, the SDK has no UI.",
|
||||
backendPackage:
|
||||
"Only a media backend under sdk/src/media-backend/ may import '{{specifier}}'; the session sees media through media-backend/api.ts.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
const filename = context.filename;
|
||||
const sdkRoot = resolve(context.cwd, "sdk") + sep;
|
||||
if (!filename.startsWith(sdkRoot)) return {};
|
||||
if (filename.startsWith(resolve(sdkRoot, "dev") + sep)) return {};
|
||||
const inBackend = filename.startsWith(
|
||||
resolve(sdkRoot, "src", "media-backend") + sep,
|
||||
);
|
||||
|
||||
const matchesPackage = (specifier, pkg) =>
|
||||
specifier === pkg ||
|
||||
specifier.startsWith(pkg.endsWith("/") ? pkg : `${pkg}/`);
|
||||
|
||||
const check = (node, specifier, typeOnly = false) => {
|
||||
if (typeof specifier !== "string") return;
|
||||
if (
|
||||
!inBackend &&
|
||||
!typeOnly &&
|
||||
BACKEND_PACKAGES.some((pkg) => matchesPackage(specifier, pkg))
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "backendPackage",
|
||||
data: { specifier },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (specifier.startsWith(".")) {
|
||||
const target = resolve(dirname(filename), specifier);
|
||||
if (!target.startsWith(sdkRoot))
|
||||
context.report({
|
||||
node,
|
||||
messageId: "outsideSdk",
|
||||
data: { specifier },
|
||||
});
|
||||
} else if (
|
||||
BANNED_PACKAGES.some((pkg) => matchesPackage(specifier, pkg))
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "bannedPackage",
|
||||
data: { specifier },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
check(
|
||||
node,
|
||||
node.source.value,
|
||||
node.importKind === "type" ||
|
||||
node.specifiers.every((s) => s.importKind === "type"),
|
||||
);
|
||||
},
|
||||
ExportNamedDeclaration(node) {
|
||||
if (node.source) check(node, node.source.value);
|
||||
},
|
||||
ExportAllDeclaration(node) {
|
||||
check(node, node.source.value);
|
||||
},
|
||||
ImportExpression(node) {
|
||||
if (node.source.type === "Literal") check(node, node.source.value);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default rule;
|
||||
@@ -4,5 +4,6 @@ module.exports = {
|
||||
"no-observablescope-leak": require("./NoObservableScopeLeak").default,
|
||||
"no-top-level-logger-get-child": require("./NoTopLevelLoggerGetChild")
|
||||
.default,
|
||||
"sdk-import-boundary": require("./SdkImportBoundary").default,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,11 +13,18 @@ export default {
|
||||
"vite.config.ts",
|
||||
"vite-embedded.config.ts",
|
||||
"vite-sdk.config.ts",
|
||||
"vite-sdk-dev.config.ts",
|
||||
"vite-sdk-target-based-on-call-view-model.config.ts",
|
||||
"vite-component.config.ts",
|
||||
"vite-component-dev.config.ts",
|
||||
],
|
||||
},
|
||||
entry: ["src/main.tsx", "eslint/index.js", "i18next.config.ts"],
|
||||
entry: [
|
||||
"src/main.tsx",
|
||||
"eslint/index.js",
|
||||
"tools/oxlint/anti-slop/index.ts",
|
||||
"i18next.config.ts",
|
||||
],
|
||||
ignoreBinaries: [
|
||||
// This is deprecated, so Knip doesn't actually recognize it as a globally
|
||||
// installed binary. TODO We should switch to Compose v2:
|
||||
@@ -31,7 +38,14 @@ export default {
|
||||
// Deliberately added prior to any component or business logic
|
||||
// implementation
|
||||
"src/state/ServiceInterruptionsViewModel.ts",
|
||||
// Agent skills installed by the skills CLI, not project source
|
||||
".agents/**",
|
||||
],
|
||||
ignoreIssues: {
|
||||
// Vendored anti-slop plugin: upstream owns its exports, and effect/ stays
|
||||
// unregistered until this repo depends on effect
|
||||
"tools/oxlint/anti-slop/**": ["files", "exports", "types"],
|
||||
},
|
||||
ignoreDependencies: [
|
||||
// Used in CSS
|
||||
"normalize.css",
|
||||
|
||||
+12
-3
@@ -59,10 +59,10 @@
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
|
||||
"crypto_version": "Crypto version: {{version}}",
|
||||
"custom_livekit_url": {
|
||||
"current_url": "Currently set to: ",
|
||||
"custom_transport": {
|
||||
"current": "Currently set to: ",
|
||||
"from_config": "Currently, no overwrite is set. Url from config is used.",
|
||||
"label": "Custom Livekit-url",
|
||||
"label": "Custom transport JSON",
|
||||
"reset": "Reset overwrite",
|
||||
"save": "Save",
|
||||
"saving": "Saving..."
|
||||
@@ -163,6 +163,12 @@
|
||||
"login_auth_links_prompt": "Not registered yet?",
|
||||
"login_subheading": "To continue to Element",
|
||||
"login_title": "Login",
|
||||
"microphone_level": {
|
||||
"label": "Microphone level",
|
||||
"no_device": "No microphone found. Connect one, then try again.",
|
||||
"permission_denied": "Microphone access is blocked. Allow it, then try again.",
|
||||
"value": "{{level}} of {{max}}"
|
||||
},
|
||||
"microphone_off": "Microphone off",
|
||||
"microphone_on": "Microphone on",
|
||||
"mute_microphone_button_label": "Mute microphone",
|
||||
@@ -215,11 +221,14 @@
|
||||
"activating": "Activating…",
|
||||
"camera": "Camera",
|
||||
"camera_numbered": "Camera {{n}}",
|
||||
"camera_source": "Camera Source",
|
||||
"change_device_button": "Change audio device",
|
||||
"default": "Default",
|
||||
"default_named": "Default <2>({{name}})</2>",
|
||||
"default_named_plain": "Default ({{name}})",
|
||||
"handset": "Handset",
|
||||
"loudspeaker": "Loudspeaker",
|
||||
"mic_source": "Mic Source",
|
||||
"microphone": "Microphone",
|
||||
"microphone_numbered": "Microphone {{n}}",
|
||||
"speaker": "Speaker",
|
||||
|
||||
+13
-10
@@ -7,16 +7,20 @@
|
||||
"dev:full": "vite",
|
||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||
"dev:component": "vite --config vite-component-dev.config.ts",
|
||||
"dev:sdk": "vite --config vite-sdk-dev.config.ts",
|
||||
"build": "pnpm build:full",
|
||||
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
|
||||
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
|
||||
"build:full:production": "pnpm build:full",
|
||||
"build:full:development": "pnpm build:full --mode development",
|
||||
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
|
||||
"build:embedded:production": "pnpm build:embedded",
|
||||
"build:embedded:development": "pnpm build:embedded --mode development",
|
||||
"build:sdk:development": "pnpm build:sdk --mode development",
|
||||
"build:sdk": "pnpm build:full --config vite-sdk.config.js",
|
||||
"build:sdk:production": "pnpm build:sdk",
|
||||
"build:sdk": "pnpm build:sdk:js && pnpm build:sdk:types",
|
||||
"build:sdk:js": "pnpm build:full --config vite-sdk.config.js",
|
||||
"build:sdk:types": "tsc -p sdk/tsconfig.build.json",
|
||||
"build:sdk-target-based-on-call-view-model": "pnpm build:full --config vite-sdk-target-based-on-call-view-model.config.js",
|
||||
"build:sdk:production": "pnpm build:sdk-target-based-on-call-view-model",
|
||||
"build:sdk:development": "pnpm build:sdk-target-based-on-call-view-model --mode development",
|
||||
"build:component": "pnpm build:component:js && pnpm build:component:types",
|
||||
"build:component:js": "pnpm build:full --config vite-component.config.js",
|
||||
"build:component:types": "tsc -p component/tsconfig.build.json",
|
||||
@@ -26,8 +30,8 @@
|
||||
"format": "oxfmt",
|
||||
"format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc",
|
||||
"lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip && pnpm lint:externals",
|
||||
"lint:oxlint": "oxlint src component playwright",
|
||||
"lint:oxlint-fix": "oxlint --fix src component playwright",
|
||||
"lint:oxlint": "oxlint src component sdk playwright",
|
||||
"lint:oxlint-fix": "oxlint --fix src component sdk playwright",
|
||||
"lint:knip": "knip",
|
||||
"lint:externals": "node scripts/check-component-externals.mjs",
|
||||
"lint:types": "tsc",
|
||||
@@ -56,6 +60,7 @@
|
||||
"@livekit/components-react": "^2.0.0",
|
||||
"@livekit/track-processors": "^0.7.1",
|
||||
"@mediapipe/tasks-vision": "^0.10.18",
|
||||
"@oxlint/plugins": "1.81.0",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
@@ -83,8 +88,8 @@
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@typescript-eslint/utils": "^8.61.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^10.0.0",
|
||||
"@vector-im/compound-web": "^10.0.0",
|
||||
"@vector-im/compound-design-tokens": "^11.0.0",
|
||||
"@vector-im/compound-web": "^10.4.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
@@ -107,7 +112,6 @@
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
"oxfmt": "^0.66.0",
|
||||
"oxlint": "^1.70.0",
|
||||
"oxlint-tsgolint": "^0.25.0",
|
||||
@@ -130,7 +134,6 @@
|
||||
"uuid": "^14.0.0",
|
||||
"vaul": "^1.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-generate-file": "^0.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-node-polyfills": "^0.28.0",
|
||||
"vite-plugin-node-stdlib-browser": "^0.2.1",
|
||||
|
||||
+27
-2
@@ -12,6 +12,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts";
|
||||
import { SDK_HARNESS_URL } from "./playwright/sdk/harness.ts";
|
||||
|
||||
const baseURL = process.env.USE_DOCKER
|
||||
? "http://localhost:8080"
|
||||
@@ -38,8 +39,20 @@ export default defineConfig({
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/**
|
||||
* Reporter to use. See https://playwright.dev/docs/test-reporters
|
||||
*
|
||||
* On CI, `list` prints a line per test as it finishes, which keeps the job
|
||||
* log moving; `github` annotates failures on the PR; `html` is uploaded as
|
||||
* an artifact and must never try to open a browser there.
|
||||
*/
|
||||
reporter: process.env.CI
|
||||
? [["list"], ["github"], ["html", { open: "never" }]]
|
||||
: [["list"], ["html"]],
|
||||
/* Stop a broken run early on CI instead of waiting out every retry. */
|
||||
maxFailures: process.env.CI ? 5 : undefined,
|
||||
/* Under the job's 60 minute limit, so a hung run still ends with a report. */
|
||||
globalTimeout: process.env.CI ? 50 * 60 * 1000 : undefined,
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
@@ -146,5 +159,17 @@ export default defineConfig({
|
||||
timeout: 500,
|
||||
},
|
||||
},
|
||||
{
|
||||
// The harness that uses the MatrixRTC SDK without Element Call. A Vite
|
||||
// dev server for the same reason as the component's.
|
||||
command: "pnpm dev:sdk",
|
||||
url: SDK_HARNESS_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
ignoreHTTPSErrors: true,
|
||||
gracefulShutdown: {
|
||||
signal: "SIGTERM",
|
||||
timeout: 500,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
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, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers.ts";
|
||||
import { installFakeDevices } from "./utils/fake-devices.ts";
|
||||
|
||||
test.describe("the quick audio menu", () => {
|
||||
test("lists speakers and microphones with a live level meter", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFakeDevices(page, { microphones: 3, speakers: 3 });
|
||||
await joinACall(page, "Menu user", "Audio menu");
|
||||
await openAudioMenu(page);
|
||||
|
||||
await expect(page.getByRole("group", { name: "Speaker" })).toBeVisible();
|
||||
await expect(page.getByRole("group", { name: "Microphone" })).toBeVisible();
|
||||
// By name, not count: the browser adds its own fake output and a Default.
|
||||
for (const n of [1, 2, 3])
|
||||
await expect(
|
||||
page
|
||||
.getByRole("group", { name: "Speaker" })
|
||||
.getByRole("menuitemradio", { name: `Fake Speaker ${n}` }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole("menuitemradio", { checked: true }),
|
||||
).toHaveCount(2);
|
||||
const meter = page.getByRole("meter", { name: "Microphone level" });
|
||||
await expect(meter).toBeVisible();
|
||||
await expect(meter).toHaveAttribute("aria-valuenow", /\d+/);
|
||||
await expect(meter).toHaveAttribute("aria-valuetext", /\d+ of \d+/);
|
||||
|
||||
// Both browsers here can route audio; the platform that can't is a unit check.
|
||||
await expect(
|
||||
page
|
||||
.getByRole("group", { name: "Speaker" })
|
||||
.getByRole("menuitemradio")
|
||||
.first(),
|
||||
).toHaveAttribute("aria-disabled", "false");
|
||||
});
|
||||
|
||||
test("moves the microphone and the speaker without disturbing the call", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// Two browsers and a real call.
|
||||
test.slow();
|
||||
const hostContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const host = await hostContext.newPage();
|
||||
await installFakeDevices(host, { microphones: 3, speakers: 3 });
|
||||
await joinACall(host, "Host", "Device switch");
|
||||
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(host);
|
||||
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const guest = await guestContext.newPage();
|
||||
await SpaHelpers.joinCallFromInviteLink(guest, inviteLink, "Guest");
|
||||
await SpaHelpers.expectVideoTilesCount(guest, 2);
|
||||
|
||||
await openAudioMenu(host);
|
||||
await selectDevice(host, "Microphone", "Fake Microphone 2");
|
||||
await openAudioMenu(host);
|
||||
await selectDevice(host, "Speaker", "Fake Speaker 2");
|
||||
|
||||
// Not a rejoin: neither side drops, and the guest still has both tiles.
|
||||
await expect(
|
||||
host.getByRole("dialog", { name: "Reconnecting…" }),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
guest.getByRole("dialog", { name: "Reconnecting…" }),
|
||||
).not.toBeVisible();
|
||||
await SpaHelpers.expectVideoTilesCount(guest, 2);
|
||||
await expect(guest.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
await hostContext.close();
|
||||
await guestContext.close();
|
||||
});
|
||||
|
||||
test("keeps the meter moving while muted, and sends nothing", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// Two browsers and a real call.
|
||||
test.slow();
|
||||
const hostContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const host = await hostContext.newPage();
|
||||
await installFakeDevices(host);
|
||||
await joinACall(host, "Muted host", "Muted meter");
|
||||
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(host);
|
||||
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const guest = await guestContext.newPage();
|
||||
await SpaHelpers.joinCallFromInviteLink(guest, inviteLink, "Listener");
|
||||
await SpaHelpers.expectVideoTilesCount(guest, 2);
|
||||
|
||||
const mute = host.getByTestId("incall_mute");
|
||||
await mute.click();
|
||||
await expect(mute).toHaveAttribute("aria-checked", "false");
|
||||
await openAudioMenu(host);
|
||||
|
||||
const meter = host.getByRole("meter", { name: "Microphone level" });
|
||||
await expect(meter).toBeVisible();
|
||||
// By test id: the modal menu hides the rest of the call from the a11y tree.
|
||||
await expect(mute).toHaveAttribute("aria-checked", "false");
|
||||
await expect(mute).toBeVisible();
|
||||
// And the guest is shown the mute.
|
||||
await expect(
|
||||
guest.getByTestId("videoTile").filter({ hasText: "Muted host" }),
|
||||
).toBeVisible();
|
||||
|
||||
await hostContext.close();
|
||||
await guestContext.close();
|
||||
});
|
||||
|
||||
test("keeps the meter at the foot of the list while it scrolls", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFakeDevices(page, { microphones: 20, speakers: 4 });
|
||||
await joinACall(page, "Scroller", "Long device list");
|
||||
await openAudioMenu(page);
|
||||
|
||||
const meter = page.getByRole("meter", { name: "Microphone level" });
|
||||
await expect(meter).toBeVisible();
|
||||
|
||||
// Scrolled so the microphones start at the top: only there does a pinned
|
||||
// meter differ from one that is simply last.
|
||||
const list = page.locator("[role='menu'] div[role='none']").first();
|
||||
await list.evaluate((element) => {
|
||||
const group = element.querySelector("[role='group'][aria-label*='icro']");
|
||||
element.scrollTop +=
|
||||
group!.getBoundingClientRect().top -
|
||||
element.getBoundingClientRect().top;
|
||||
});
|
||||
|
||||
await expect(meter).toBeInViewport();
|
||||
await expectPinnedInside(meter, list);
|
||||
await expect(
|
||||
page.getByRole("menuitemradio", { name: "Fake Microphone 20" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows the focus ring only when the keyboard moved the focus", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"Headless Firefox does not deliver synthetic key presses reliably; see reconnect.spec.ts",
|
||||
);
|
||||
await installFakeDevices(page);
|
||||
await joinACall(page, "Keyboard user", "Focus ring");
|
||||
await openAudioMenu(page);
|
||||
|
||||
const first = page.getByRole("menuitemradio").first();
|
||||
// Opened by pointer: no ring, though Radix has moved focus into the menu.
|
||||
await expect.poll(async () => outlineWidth(first)).toBe(0);
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
const focused = page.locator("[role='menuitemradio']:focus");
|
||||
await expect.poll(async () => outlineWidth(focused)).toBeGreaterThan(0);
|
||||
|
||||
// And the pointer takes it away again.
|
||||
await first.hover();
|
||||
await expect.poll(async () => outlineWidth(focused)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/** Creates a call and joins it, leaving the page in the call. */
|
||||
async function joinACall(
|
||||
page: Page,
|
||||
userName: string,
|
||||
callName: string,
|
||||
): Promise<void> {
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, userName, callName, true);
|
||||
await expect(page.getByTestId("name_tag")).toContainText(userName);
|
||||
// The media controls stay disabled until devices have enumerated.
|
||||
await expect(page.getByTestId("incall_mute")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function openAudioMenu(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
}
|
||||
|
||||
async function selectDevice(
|
||||
page: Page,
|
||||
section: "Speaker" | "Microphone",
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const item = page
|
||||
.getByRole("group", { name: section })
|
||||
.getByRole("menuitemradio", { name });
|
||||
await item.click();
|
||||
// Selecting doesn't close the menu, so dismiss it.
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("menu")).not.toBeVisible();
|
||||
}
|
||||
|
||||
/** Asserts the meter sits within the scrollport and inside the menu's frame. */
|
||||
async function expectPinnedInside(
|
||||
meter: Locator,
|
||||
list: Locator,
|
||||
): Promise<void> {
|
||||
const meterBox = (await meter.boundingBox())!;
|
||||
const listBox = (await list.boundingBox())!;
|
||||
const frame = (await meter.page().getByRole("menu").boundingBox())!;
|
||||
|
||||
expect(meterBox.y + meterBox.height).toBeLessThanOrEqual(
|
||||
listBox.y + listBox.height + 1,
|
||||
);
|
||||
expect(meterBox.y).toBeGreaterThanOrEqual(listBox.y - 1);
|
||||
expect(meterBox.x).toBeGreaterThan(frame.x);
|
||||
expect(meterBox.x + meterBox.width).toBeLessThan(frame.x + frame.width);
|
||||
}
|
||||
|
||||
/** Painted outline width, in px. */
|
||||
async function outlineWidth(item: Locator): Promise<number> {
|
||||
if ((await item.count()) === 0) return 0;
|
||||
return item.first().evaluate((element) => {
|
||||
const { outlineStyle, outlineWidth } = getComputedStyle(element);
|
||||
return outlineStyle === "none" ? 0 : Number.parseFloat(outlineWidth) || 0;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
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, type Locator, type Page, test } from "@playwright/test";
|
||||
|
||||
import { createUserAndRoom, resizeContainer, startHarness } from "./harness.ts";
|
||||
import { installFakeDevices } from "../utils/fake-devices.ts";
|
||||
|
||||
/**
|
||||
* The device menu with Element Call as a component in a host page, where the
|
||||
* portalled menu must be sized against the call, not the window. Driven from
|
||||
* the lobby: it builds the same menu, without two connections' worth of flake.
|
||||
*/
|
||||
|
||||
// Sign-in, crypto setup and sync happen twice before anything shows.
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("sizes the device list against the call, not the window", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFakeDevices(page, { microphones: 20, speakers: 4 });
|
||||
const { username, roomId } = await createUserAndRoom("menusize");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
|
||||
// A short call in a much taller page.
|
||||
const container = pane.getByTestId("call-container");
|
||||
await resizeContainer(container, { width: 900, height: 400 });
|
||||
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const list = await openDeviceList(page, pane);
|
||||
const callHeight = (await container.boundingBox())!.height;
|
||||
const windowHeight = page.viewportSize()!.height;
|
||||
const listHeight = (await list.boundingBox())!.height;
|
||||
|
||||
expect(callHeight).toBeLessThan(windowHeight * 0.75);
|
||||
expect(listHeight).toBeLessThanOrEqual(callHeight);
|
||||
expect(listHeight).toBeLessThan(windowHeight * 0.6);
|
||||
});
|
||||
|
||||
test("follows the call area when the host resizes it", async ({ page }) => {
|
||||
await installFakeDevices(page, { microphones: 20, speakers: 4 });
|
||||
const { username, roomId } = await createUserAndRoom("menuresize");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
|
||||
const container = pane.getByTestId("call-container");
|
||||
await resizeContainer(container, { width: 900, height: 360 });
|
||||
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const list = await openDeviceList(page, pane);
|
||||
const whenShort = (await list.boundingBox())!.height;
|
||||
|
||||
// The host grows the call while the menu is open.
|
||||
await resizeContainer(container, { width: 900, height: 700 });
|
||||
await expect
|
||||
.poll(async () => (await list.boundingBox())!.height)
|
||||
.toBeGreaterThan(whenShort);
|
||||
});
|
||||
|
||||
test("keeps every device reachable in a small container", async ({ page }) => {
|
||||
await installFakeDevices(page, { microphones: 20, speakers: 4 });
|
||||
const { username, roomId } = await createUserAndRoom("menureach");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
|
||||
const container = pane.getByTestId("call-container");
|
||||
await resizeContainer(container, { width: 400, height: 360 });
|
||||
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const list = await openDeviceList(page, pane);
|
||||
// More devices than fit, so the list must scroll.
|
||||
expect(await list.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const last = page.getByRole("menuitemradio", { name: "Fake Microphone 20" });
|
||||
await last.scrollIntoViewIfNeeded();
|
||||
await expect(last).toBeInViewport();
|
||||
// Reachable means clickable: the menu may be drawn outside the call area.
|
||||
await last.click();
|
||||
await expect(last).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
test("tracks the focus source of its own call, not the page", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFakeDevices(page);
|
||||
const { username, roomId } = await createUserAndRoom("menufocus");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
const other = panes.nth(1);
|
||||
|
||||
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await openDeviceList(page, pane);
|
||||
const menu = page.getByRole("menu");
|
||||
|
||||
// Asserted on the attribute: the component build's scoped stylesheet doesn't
|
||||
// reach the portalled menu, so its ring can't be read here.
|
||||
await expect(menu).toHaveAttribute("data-focus-source", "pointer");
|
||||
|
||||
// A key pressed in the other call says nothing about this menu.
|
||||
await other.evaluate((element) =>
|
||||
element.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
|
||||
),
|
||||
);
|
||||
await expect(menu).toHaveAttribute("data-focus-source", "pointer");
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(menu).toHaveAttribute("data-focus-source", "keyboard");
|
||||
});
|
||||
|
||||
/** Opens a component's microphone menu and returns its device list. */
|
||||
async function openDeviceList(page: Page, pane: Locator): Promise<Locator> {
|
||||
await pane
|
||||
.getByRole("button", { name: "Microphone" })
|
||||
.click({ timeout: 60_000 });
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
const list = page.getByRole("menu").getByTestId("device-list");
|
||||
await expect(list).toBeVisible();
|
||||
return list;
|
||||
}
|
||||
@@ -92,6 +92,12 @@ export async function expectWithin(
|
||||
outer: Locator,
|
||||
): Promise<void> {
|
||||
await expect(inner).toBeVisible();
|
||||
// Wait for animations to settle.
|
||||
await inner.evaluate(async (element) => {
|
||||
for (const animation of element.getAnimations({ subtree: true }))
|
||||
await animation.finished;
|
||||
});
|
||||
|
||||
const innerBox = await inner.boundingBox();
|
||||
const outerBox = await outer.boundingBox();
|
||||
if (innerBox === null || outerBox === null)
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { createJTWToken } from "./fixtures/jwt-token";
|
||||
import { createJWTToken } from "./fixtures/jwt-token";
|
||||
|
||||
test("Should show error screen if fails to get JWT token", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
@@ -95,7 +95,7 @@ test("Should show error screen if call creation is restricted", async ({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
url: "wss://badurltotricktest/livekit/sfu",
|
||||
jwt: createJTWToken("@fake:user", "!fake:room"),
|
||||
jwt: createJWTToken("@fake:user", "!fake:room"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
export function createJTWToken(sub: string, room: string): string {
|
||||
export function createJWTToken(sub: string, room: string): string {
|
||||
return [
|
||||
{}, // header
|
||||
{
|
||||
|
||||
@@ -132,7 +132,7 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
|
||||
await ewPage1
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.click();
|
||||
|
||||
await ewPage1.getByRole("menuitem", { name: "Start chat" }).click();
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
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 "@playwright/test";
|
||||
|
||||
import { startPair, tileOf } from "./harness";
|
||||
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test("a message sent beside the media reaches the peer, and only the peer", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { pages, users } = await startPair(browser, "sdkdata");
|
||||
const [sender, receiver] = pages;
|
||||
// Both are in the call before anything is sent, so the receiver knows the
|
||||
// sender as a member and does not drop the message
|
||||
for (const page of pages)
|
||||
await expect(page.getByTestId("member")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(tileOf(receiver, users[0])).toBeVisible();
|
||||
|
||||
await sender.getByPlaceholder("message").fill("hello from the SDK");
|
||||
await sender.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
const message = receiver.getByTestId("message");
|
||||
await expect(message).toHaveText(
|
||||
`${users[0].displayName}: hello from the SDK`,
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
await expect(message).toHaveAttribute("data-topic", "chat");
|
||||
await expect(message).toHaveAttribute("data-user-id", users[0].userId);
|
||||
// Our own messages are not echoed back
|
||||
await expect(sender.getByTestId("message")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
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 Browser,
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
import { SynapseAdmin } from "../utils/synapse-admin.ts";
|
||||
|
||||
/**
|
||||
* Where the SDK harness is served — `sdk/dev`, a page that uses the SDK the way
|
||||
* a host would, with none of Element Call in it.
|
||||
*/
|
||||
export const SDK_HARNESS_URL = "https://localhost:3002";
|
||||
|
||||
const HOMESERVER_URL = "https://synapse.m.localhost";
|
||||
const PASSWORD = "foobarbaz1!";
|
||||
|
||||
/** A registered user, with the session the registration opened. */
|
||||
export interface User {
|
||||
username: string;
|
||||
displayName: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers two users through the Synapse admin API and has the first create a
|
||||
* public room, so that the second can join it by id without an invite. The
|
||||
* registration logs each user in, which spares the harness a `/login` call
|
||||
* that the homeserver rate-limits.
|
||||
*/
|
||||
export async function createUsersAndRoom(
|
||||
name: string,
|
||||
): Promise<{ users: [User, User]; roomId: string }> {
|
||||
const admin = SynapseAdmin.forHomeserver(HOMESERVER_URL);
|
||||
const users = (await Promise.all(
|
||||
["a", "b"].map(async (letter, index): Promise<User> => {
|
||||
const username = `${name}_${letter}_${Date.now()}`;
|
||||
const displayName = `${name} ${"AB"[index]}`;
|
||||
const registration = await admin.registerUser(
|
||||
username,
|
||||
PASSWORD,
|
||||
displayName,
|
||||
);
|
||||
return {
|
||||
username,
|
||||
displayName,
|
||||
userId: registration.user_id,
|
||||
deviceId: registration.device_id,
|
||||
accessToken: registration.access_token,
|
||||
};
|
||||
}),
|
||||
)) as [User, User];
|
||||
|
||||
const response = await fetch(
|
||||
`${HOMESERVER_URL}/_matrix/client/v3/createRoom`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${users[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: `${name}'s session`,
|
||||
preset: "public_chat",
|
||||
// Per-participant media keys travel in encrypted to-device messages,
|
||||
// which only reach devices the sender's crypto tracks, and it tracks
|
||||
// the members of encrypted rooms
|
||||
initial_state: [
|
||||
{
|
||||
type: "m.room.encryption",
|
||||
state_key: "",
|
||||
content: { algorithm: "m.megolm.v1.aes-sha2" },
|
||||
},
|
||||
],
|
||||
// Every member has to be allowed to write its own membership, which
|
||||
// a public room does not grant by default; the same levels Element
|
||||
// Call gives a room it creates for a call
|
||||
power_level_content_override: {
|
||||
state_default: 0,
|
||||
events_default: 0,
|
||||
users_default: 0,
|
||||
events: { "org.matrix.msc3401.call.member": 0 },
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Could not create a room: ${response.status} ${await response.text()}`,
|
||||
);
|
||||
const { room_id: roomId } = (await response.json()) as { room_id: string };
|
||||
|
||||
return { users, roomId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the harness signed in as the given user and waits until the SDK
|
||||
* reports the session as connected. The status line carries the SDK's error
|
||||
* if it does not get there, so the failure says why.
|
||||
*/
|
||||
export async function startHarness(
|
||||
page: Page,
|
||||
user: User,
|
||||
roomId: string,
|
||||
): Promise<void> {
|
||||
const query = new URLSearchParams({
|
||||
homeserver: HOMESERVER_URL,
|
||||
accessToken: user.accessToken,
|
||||
userId: user.userId,
|
||||
deviceId: user.deviceId,
|
||||
room: roomId,
|
||||
});
|
||||
await page.goto(`${SDK_HARNESS_URL}/?${query.toString()}`);
|
||||
await page.getByRole("button", { name: "Start" }).click();
|
||||
await waitForConnected(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* A login, a crypto setup and an initial sync happen before the session can
|
||||
* connect. An error is final, so it is not worth waiting out the timeout for
|
||||
* "connected" after one.
|
||||
*/
|
||||
export async function waitForConnected(page: Page): Promise<void> {
|
||||
const status = page.getByTestId("status");
|
||||
await expect(status).toHaveText(/^(connected|Error)/, { timeout: 120_000 });
|
||||
const text = await status.textContent();
|
||||
if (text !== "connected")
|
||||
throw new Error(`The harness did not connect to the session: ${text}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A context of its own per user, since a browser profile holds one login. No
|
||||
* permissions to grant: each browser is launched with fake media that is
|
||||
* handed out without asking (see playwright.config.ts).
|
||||
*/
|
||||
export async function newPage(browser: Browser): Promise<Page> {
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
return context.newPage();
|
||||
}
|
||||
|
||||
/** Two users in one session, each on a page of their own. */
|
||||
export async function startPair(
|
||||
browser: Browser,
|
||||
name: string,
|
||||
): Promise<{ pages: [Page, Page]; users: [User, User]; roomId: string }> {
|
||||
const { users, roomId } = await createUsersAndRoom(name);
|
||||
const pages = await Promise.all([newPage(browser), newPage(browser)]);
|
||||
await Promise.all(
|
||||
pages.map(async (page, i) => startHarness(page, users[i], roomId)),
|
||||
);
|
||||
return { pages: pages as [Page, Page], users, roomId };
|
||||
}
|
||||
|
||||
/** The tile a page shows for a user. */
|
||||
export function tileOf(page: Page, user: User): Locator {
|
||||
return page.locator(`[data-testid="member"][data-user-id="${user.userId}"]`);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
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 "@playwright/test";
|
||||
|
||||
import { startPair, tileOf, waitForConnected } from "./harness.ts";
|
||||
|
||||
/**
|
||||
* Members coming and going: a member that leaves disappears for the peer, one
|
||||
* that comes back is shown again, and the harness is left in the state its
|
||||
* status line claims.
|
||||
*/
|
||||
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test("a member that leaves disappears for the peer", async ({ browser }) => {
|
||||
const {
|
||||
pages: [leaver, stayer],
|
||||
users: [leaving, staying],
|
||||
} = await startPair(browser, "sdkleave");
|
||||
await expect(stayer.getByTestId("member")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
await leaver.getByRole("button", { name: "Leave" }).click();
|
||||
await expect(leaver.getByTestId("status")).toHaveText("Left");
|
||||
await expect(leaver.getByTestId("member")).toHaveCount(0);
|
||||
|
||||
await expect(tileOf(stayer, leaving)).toHaveCount(0, { timeout: 60_000 });
|
||||
await expect(tileOf(stayer, staying)).toHaveCount(1);
|
||||
await expect(stayer.getByTestId("status")).toHaveText("connected");
|
||||
});
|
||||
|
||||
test("a member that reloads is shown again with its media", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const {
|
||||
pages: [reloader, watcher],
|
||||
users: [reloading],
|
||||
} = await startPair(browser, "sdkreload");
|
||||
await expect(watcher.getByTestId("member")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// The page comes back on the same device, so the new membership replaces
|
||||
// the old one rather than sitting next to it
|
||||
await reloader.reload();
|
||||
await reloader.getByRole("button", { name: "Start" }).click();
|
||||
await waitForConnected(reloader);
|
||||
|
||||
await expect(tileOf(watcher, reloading)).toHaveCount(1, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
const video = tileOf(watcher, reloading).locator("video");
|
||||
await expect
|
||||
.poll(async () => video.evaluate((v: HTMLVideoElement) => v.videoWidth), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
await expect(reloader.getByTestId("member")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
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, type Locator, type Page, test } from "@playwright/test";
|
||||
|
||||
import { startPair, tileOf, type User } from "./harness.ts";
|
||||
|
||||
/**
|
||||
* What a member's media looks like from the other side: it plays, it is
|
||||
* encrypted, it follows the mute switches, and the SFU sends only as much as
|
||||
* the tile on screen can show. Each test reads the labels the harness puts on
|
||||
* its media elements, which come straight from the SDK's track behaviors.
|
||||
*
|
||||
* One session serves every test here: logging two browsers in and connecting
|
||||
* them takes most of a minute, and none of the tests leaves the session in a
|
||||
* state the next cannot start from.
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: "serial", timeout: 300_000 });
|
||||
|
||||
let pages: [Page, Page];
|
||||
let users: [User, User];
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
({ pages, users } = await startPair(browser, "sdkmedia"));
|
||||
for (const page of pages)
|
||||
await expect(page.getByTestId("member")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
for (const page of pages ?? []) await page.context().close();
|
||||
});
|
||||
|
||||
test("the peer's camera and microphone play", async () => {
|
||||
const peer = remoteTile(1, 0);
|
||||
const video = peer.locator("video");
|
||||
await expect
|
||||
.poll(async () => video.evaluate((v: HTMLVideoElement) => v.videoWidth), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
await expect(video).toHaveJSProperty("paused", false);
|
||||
const audio = peer.locator("audio");
|
||||
await expect
|
||||
.poll(async () =>
|
||||
audio.evaluate((a: HTMLAudioElement) => a.srcObject !== null),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(audio).toHaveJSProperty("paused", false);
|
||||
});
|
||||
|
||||
test("the peer's tracks are encrypted", async () => {
|
||||
const peer = remoteTile(1, 0);
|
||||
await expect(peer.locator("video")).toHaveAttribute("data-encrypted", "true");
|
||||
await expect(peer.locator("audio")).toHaveAttribute("data-encrypted", "true");
|
||||
});
|
||||
|
||||
test("muting the microphone and the camera is seen by the peer", async () => {
|
||||
const [page] = pages;
|
||||
const peer = remoteTile(1, 0);
|
||||
const video = peer.locator("video");
|
||||
const audio = peer.locator("audio");
|
||||
await expect(video).toHaveAttribute("data-muted", "false");
|
||||
await expect(audio).toHaveAttribute("data-muted", "false");
|
||||
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
await expect(audio).toHaveAttribute("data-muted", "true", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(video).toHaveAttribute("data-muted", "false");
|
||||
|
||||
await page.getByRole("button", { name: "Camera" }).click();
|
||||
await expect(video).toHaveAttribute("data-muted", "true", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
await page.getByRole("button", { name: "Camera" }).click();
|
||||
await expect(audio).toHaveAttribute("data-muted", "false", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(video).toHaveAttribute("data-muted", "false", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("the peer's microphone is active while it sends sound, and not while muted", async () => {
|
||||
// Chromium's fake microphone never registers as a speaker at the SFU, and
|
||||
// Firefox's steady tone does so in about two runs out of three, with or
|
||||
// without the browser's audio processing. The pipeline is proven when it
|
||||
// passes; it needs a louder fake device or a lower SFU threshold to be
|
||||
// relied on.
|
||||
test.fixme(true, "the fake microphones do not reliably register at the SFU");
|
||||
const [page] = pages;
|
||||
const audio = remoteTile(1, 0).locator("audio");
|
||||
// The SFU only announces a change of speakers, so a tone that has been
|
||||
// playing since before the peer subscribed is not announced to it. Muting
|
||||
// and unmuting is the change that is.
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
await expect(audio).toHaveAttribute("data-muted", "true", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(audio).toHaveAttribute("data-active", "false");
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
await expect(audio).toHaveAttribute("data-active", "true", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
await expect(audio).toHaveAttribute("data-active", "false", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
});
|
||||
|
||||
test("a larger tile receives a higher resolution", async () => {
|
||||
const peer = remoteTile(1, 0);
|
||||
const video = peer.locator("video");
|
||||
// The tile starts small enough for the lowest simulcast layer
|
||||
const small = await receivedWidth(video, (width) => width > 0);
|
||||
await resize(peer, 1280);
|
||||
const large = await receivedWidth(video, (width) => width > small);
|
||||
expect(large).toBeGreaterThan(small);
|
||||
await resize(peer, 240);
|
||||
await receivedWidth(video, (width) => width < large);
|
||||
});
|
||||
|
||||
test("a hidden tile stops receiving video, and a shown one resumes", async () => {
|
||||
const peer = remoteTile(1, 0);
|
||||
const video = peer.locator("video");
|
||||
await expect(frames(video)).resolves.toBeGreaterThan(0);
|
||||
|
||||
await peer.evaluate((tile) => (tile.style.display = "none"));
|
||||
await expect
|
||||
.poll(async () => framesStill(video), { timeout: 60_000 })
|
||||
.toBe(true);
|
||||
|
||||
await peer.evaluate((tile) => (tile.style.display = ""));
|
||||
await expect
|
||||
.poll(async () => !(await framesStill(video)), { timeout: 60_000 })
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
/** The tile on one page for the user of the other page. */
|
||||
function remoteTile(viewer: 0 | 1, shown: 0 | 1): Locator {
|
||||
return tileOf(pages[viewer], users[shown]);
|
||||
}
|
||||
|
||||
async function resize(tile: Locator, width: number): Promise<void> {
|
||||
await tile.evaluate((t, w) => (t.style.width = `${w}px`), width);
|
||||
}
|
||||
|
||||
/** The width of the video as decoded, once it satisfies the condition. */
|
||||
async function receivedWidth(
|
||||
video: Locator,
|
||||
until: (width: number) => boolean,
|
||||
): Promise<number> {
|
||||
// Switching simulcast layers takes the SFU a few seconds
|
||||
let width = 0;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
width = await video.evaluate((v: HTMLVideoElement) => v.videoWidth);
|
||||
return until(width);
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
return width;
|
||||
}
|
||||
|
||||
async function frames(video: Locator): Promise<number> {
|
||||
return Number(await video.getAttribute("data-frames"));
|
||||
}
|
||||
|
||||
/** Whether no frame arrived over a couple of seconds. */
|
||||
async function framesStill(video: Locator): Promise<boolean> {
|
||||
const before = await frames(video);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||
return (await frames(video)) === before;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
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 "@playwright/test";
|
||||
|
||||
import { startPair, tileOf } from "./harness.ts";
|
||||
|
||||
/**
|
||||
* The MatrixRTC SDK driven through its harness in `sdk/dev`: no Element Call
|
||||
* on the page, only the SDK's public API.
|
||||
*/
|
||||
|
||||
// Two browsers each log in, set up crypto and sync before anything is on
|
||||
// screen, then wait for media to connect
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test("two browsers see each other in one session", async ({ browser }) => {
|
||||
const { pages, users } = await startPair(browser, "sdksmoke");
|
||||
|
||||
// Each page shows itself and the other, each tile named by its user
|
||||
for (const page of pages) {
|
||||
await expect(page.getByTestId("member")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
for (const user of users)
|
||||
await expect(tileOf(page, user)).toContainText(user.displayName);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,493 @@
|
||||
/*
|
||||
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, type Page, test } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers";
|
||||
import { type RtcMode } from "./widget/test-helpers.ts";
|
||||
|
||||
const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu";
|
||||
const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt";
|
||||
|
||||
const otherHomeserver = {
|
||||
base_url: "https://synapse.othersite.m.localhost",
|
||||
server_name: "synapse.othersite.m.localhost",
|
||||
};
|
||||
const otherSfuUrl = "wss://matrix-rtc.othersite.m.localhost/livekit/sfu";
|
||||
|
||||
interface Transport {
|
||||
type: "livekit";
|
||||
url?: string;
|
||||
livekit_service_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The transport shapes a homeserver may advertise while migrating to the
|
||||
* latest MSC4195. `legacy` is what the e2e Synapse advertises on its own.
|
||||
*/
|
||||
const shapes = {
|
||||
url: { type: "livekit", url: sfuUrl },
|
||||
both: { type: "livekit", url: sfuUrl, livekit_service_url: serviceUrl },
|
||||
legacy: null,
|
||||
} satisfies Record<string, Transport | null>;
|
||||
type Shape = keyof typeof shapes;
|
||||
|
||||
/** Which endpoint a page asks for its SFU tokens. */
|
||||
type Flow = "cs-api" | "service-legacy" | "service-default";
|
||||
|
||||
const cases: {
|
||||
hostShape: Shape;
|
||||
guestShape: Shape;
|
||||
/** The MatrixRTC mode both sides use; the default is compatibility mode. */
|
||||
mode?: RtcMode;
|
||||
hostFlows: Flow[];
|
||||
guestFlows: Flow[];
|
||||
}[] = [
|
||||
{
|
||||
hostShape: "url",
|
||||
guestShape: "url",
|
||||
hostFlows: ["cs-api"],
|
||||
guestFlows: ["cs-api"],
|
||||
},
|
||||
{
|
||||
// Publishers keep the legacy participant identity that clients predating
|
||||
// MSC4195 expect, which only the JWT service assigns.
|
||||
hostShape: "both",
|
||||
guestShape: "both",
|
||||
hostFlows: ["service-legacy"],
|
||||
guestFlows: ["service-legacy"],
|
||||
},
|
||||
{
|
||||
// The MatrixRTC mode has no bearing on the choice.
|
||||
hostShape: "both",
|
||||
guestShape: "both",
|
||||
mode: "2_0",
|
||||
hostFlows: ["service-legacy"],
|
||||
guestFlows: ["service-legacy"],
|
||||
},
|
||||
{
|
||||
hostShape: "url",
|
||||
guestShape: "legacy",
|
||||
hostFlows: ["cs-api", "service-default"],
|
||||
guestFlows: ["service-legacy", "cs-api"],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { hostShape, guestShape, mode, hostFlows, guestFlows } of cases) {
|
||||
test(`One to one call with ${hostShape} host and ${guestShape} guest transport${mode === "2_0" ? " in Matrix 2.0 mode" : ""}`, async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
skipOnFirefox(browserName);
|
||||
|
||||
// The host starts the call.
|
||||
await advertiseTransport(page, shapes[hostShape]);
|
||||
const hostRequests = recordAuthRequests(page);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Androl", "HelloCall", true, mode);
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(page);
|
||||
|
||||
// The guest joins.
|
||||
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const guestPage = await guestContext.newPage();
|
||||
await advertiseTransport(guestPage, shapes[guestShape]);
|
||||
const guestRequests = recordAuthRequests(guestPage);
|
||||
await SpaHelpers.joinCallFromInviteLink(
|
||||
guestPage,
|
||||
inviteLink,
|
||||
"Pevara",
|
||||
mode,
|
||||
);
|
||||
|
||||
// Wait for the call to connect and render.
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
// The host and guest ran through the expected flows.
|
||||
expect(flowsOf(hostRequests)).toEqual(new Set(hostFlows));
|
||||
expect(flowsOf(guestRequests)).toEqual(new Set(guestFlows));
|
||||
|
||||
// All requests succeeded and the SFU URL was set on C-S requests.
|
||||
for (const request of [...hostRequests, ...guestRequests]) {
|
||||
expect(request.status).toBe(200);
|
||||
if (request.flow === "cs-api") expect(request.sfuUrl).toBe(sfuUrl);
|
||||
// Clients predating MSC4195 match a publisher by the legacy identity,
|
||||
// which only the JWT service's legacy endpoint assigns. The other flows
|
||||
// hash it.
|
||||
expect(request.identity).toMatch(
|
||||
request.flow === "service-legacy" ? legacyIdentity : hashedIdentity,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("Subscriber falls back to the JWT service when the homeserver lacks MSC4195", async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
skipOnFirefox(browserName);
|
||||
|
||||
// The host starts the call with both transport shapes.
|
||||
await advertiseTransport(page, shapes.both);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(page);
|
||||
|
||||
// The guest joins but their C-S requests fail.
|
||||
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const guestPage = await guestContext.newPage();
|
||||
await rejectCsApiTokens(guestPage);
|
||||
const guestRequests = recordAuthRequests(guestPage);
|
||||
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara");
|
||||
|
||||
// Wait for the call to connect and render.
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
// The guest ran through all flows.
|
||||
expect(flowsOf(guestRequests)).toEqual(
|
||||
new Set<Flow>(["cs-api", "service-legacy", "service-default"]),
|
||||
);
|
||||
|
||||
// The C-S token requests failed.
|
||||
for (const request of guestRequests) {
|
||||
expect(request.status).toBe(request.flow === "cs-api" ? 404 : 200);
|
||||
}
|
||||
|
||||
// The C-S token requests preceeded the fallback request.
|
||||
const flows = guestRequests.map((r) => r.flow);
|
||||
expect(flows.indexOf("service-default")).toBeGreaterThan(
|
||||
flows.indexOf("cs-api"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Publisher shows an error when the homeserver lacks MSC4195", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
skipOnFirefox(browserName);
|
||||
|
||||
// The host tries to start a call with only the C-S transport shape available but
|
||||
// their C-S requests fail.
|
||||
await advertiseTransport(page, shapes.url);
|
||||
await rejectCsApiTokens(page);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
|
||||
|
||||
// We land on an error page.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Something went wrong" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"The authorization service for your media server (SFU) is out of date.",
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* Delegation follows the flow that authenticated the publisher: the homeserver
|
||||
* for a `url`-only transport, the JWT service wherever there is one.
|
||||
*/
|
||||
const delegationCases: { shape: Shape; via: Delegation["via"] }[] = [
|
||||
{ shape: "url", via: "homeserver" },
|
||||
{ shape: "legacy", via: "service" },
|
||||
{ shape: "both", via: "service" },
|
||||
];
|
||||
|
||||
for (const { shape, via } of delegationCases) {
|
||||
test(`Delayed leave event delegated through the ${via} for a ${shape} transport gets sent on disconnect`, async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
skipOnFirefox(browserName);
|
||||
|
||||
// The host starts the call.
|
||||
await advertiseTransport(page, shapes[shape]);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(page);
|
||||
|
||||
// The guest joins.
|
||||
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const guestPage = await guestContext.newPage();
|
||||
await advertiseTransport(guestPage, shapes[shape]);
|
||||
const delayedLeave = recordDelayedLeave(guestPage);
|
||||
const delegations = recordDelegations(guestPage);
|
||||
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara");
|
||||
|
||||
// Wait for the call to connect and render.
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
// The guest handed its delayed leave over, after probing for delegation
|
||||
// support with bodiless requests, which count as supported unless they
|
||||
// 404. The homeserver is always probed; the JWT service only when there
|
||||
// is one.
|
||||
await expect
|
||||
.poll(() => delegations.at(-1))
|
||||
.toEqual({
|
||||
via,
|
||||
status: 200,
|
||||
delayId: delayedLeave.delayId,
|
||||
...(via === "homeserver" && { sfuUrl }),
|
||||
});
|
||||
const probes = delegations.slice(0, -1);
|
||||
expect(probes.some((probe) => probe.via === via)).toBe(true);
|
||||
for (const probe of probes) {
|
||||
expect(probe.status).toBeGreaterThanOrEqual(400);
|
||||
expect(probe.status).not.toBe(404);
|
||||
}
|
||||
|
||||
// The delay is long enough that a leave within the window below cannot
|
||||
// come from the delayed event expiring on its own.
|
||||
expect(delayedLeave.delayMs).toBeGreaterThan(60_000);
|
||||
|
||||
// The guest drops without leaving.
|
||||
await guestPage.reload();
|
||||
|
||||
// The host eventually sees the guest go once the delayed event fires.
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("Subscribers get tokens for a remote homeserver's SFU over federation", async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
skipOnFirefox(browserName);
|
||||
|
||||
// The host lives on the other homeserver and publishes on its SFU.
|
||||
await useHomeserver(page, otherHomeserver);
|
||||
await advertiseTransport(page, { type: "livekit", url: otherSfuUrl });
|
||||
const hostRequests = recordAuthRequests(page);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(page);
|
||||
|
||||
// The guest lives on the default homeserver and joins over federation.
|
||||
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const guestPage = await guestContext.newPage();
|
||||
await advertiseTransport(guestPage, shapes.url);
|
||||
const guestRequests = recordAuthRequests(guestPage);
|
||||
await SpaHelpers.joinCallFromInviteLink(
|
||||
guestPage,
|
||||
`${inviteLink}&viaServers=${otherHomeserver.server_name}`,
|
||||
"Pevara",
|
||||
);
|
||||
|
||||
// Wait for the call to connect and render.
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
// Each side got a token for its own SFU and for the other side's SFU, all
|
||||
// through its own homeserver.
|
||||
for (const requests of [hostRequests, guestRequests]) {
|
||||
expect(flowsOf(requests)).toEqual(new Set<Flow>(["cs-api"]));
|
||||
expect(new Set(requests.map((r) => r.sfuUrl))).toEqual(
|
||||
new Set([sfuUrl, otherSfuUrl]),
|
||||
);
|
||||
for (const request of requests) expect(request.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
/** Skips the calling test if we're running on Firefox. */
|
||||
function skipOnFirefox(browserName: string): void {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
}
|
||||
|
||||
/** Points this page's Element Call at another homeserver. */
|
||||
async function useHomeserver(
|
||||
page: Page,
|
||||
homeserver: typeof otherHomeserver,
|
||||
): Promise<void> {
|
||||
await page.route("**/config.json", async (route) => {
|
||||
const response = await route.fetch();
|
||||
const config = (await response.json()) as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
response,
|
||||
json: {
|
||||
...config,
|
||||
default_server_config: { "m.homeserver": homeserver },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps recorded requests to a set of flows. Publisher and subscriber connections
|
||||
* start concurrently so using arrays for comparison isn't convenient.
|
||||
*/
|
||||
function flowsOf(requests: AuthRequest[]): Set<Flow> {
|
||||
return new Set(requests.map((r) => r.flow));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the transports the homeserver advertises to this page. The real
|
||||
* response is fetched first so its CORS headers carry over.
|
||||
*/
|
||||
async function advertiseTransport(
|
||||
page: Page,
|
||||
transport: Transport | null,
|
||||
): Promise<void> {
|
||||
if (transport === null) return;
|
||||
await page.route("**/org.matrix.msc4143/rtc/transports", async (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, json: { rtc_transports: [transport] } });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the homeserver look like one without MSC4195: its `get_token`
|
||||
* endpoint answers as Synapse does for an unknown route.
|
||||
*/
|
||||
async function rejectCsApiTokens(page: Page): Promise<void> {
|
||||
await page.route("**/rtc/livekit/get_token", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.continue();
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
json: { errcode: "M_UNRECOGNIZED", error: "Unrecognized request" },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** A recorded delayed leave event. */
|
||||
interface DelayedLeave {
|
||||
delayMs?: number;
|
||||
delayId?: string;
|
||||
}
|
||||
|
||||
/** Records the delayed leave event the page schedules on joining. */
|
||||
function recordDelayedLeave(page: Page): DelayedLeave {
|
||||
const delayedLeave: DelayedLeave = {};
|
||||
page.on("response", (response) => {
|
||||
const delay = new URL(response.request().url()).searchParams.get(
|
||||
"org.matrix.msc4140.delay",
|
||||
);
|
||||
if (delay === null || response.status() !== 200) return;
|
||||
delayedLeave.delayMs = Number(delay);
|
||||
response
|
||||
.json()
|
||||
.then((body: { delay_id: string }) => {
|
||||
delayedLeave.delayId = body.delay_id;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
return delayedLeave;
|
||||
}
|
||||
|
||||
/** A recorded delegation request for a delayed leave event. */
|
||||
interface Delegation {
|
||||
/** Who was asked to send the leave event on the guest's behalf. */
|
||||
via: "homeserver" | "service";
|
||||
status: number;
|
||||
sfuUrl?: string;
|
||||
delayId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records requests that delegate a delayed leave event, or probe for support:
|
||||
* the MSC4195 `delegate_delayed_leave` endpoint of the homeserver, and the JWT
|
||||
* service's `delegate_delayed_leave` probe and `/sfu/get` with delay fields.
|
||||
*/
|
||||
function recordDelegations(page: Page): Delegation[] {
|
||||
const delegations: Delegation[] = [];
|
||||
page.on("response", (response) => {
|
||||
const request = response.request();
|
||||
if (request.method() !== "POST") return;
|
||||
const url = request.url();
|
||||
const body = request.postDataJSON() as {
|
||||
url?: string;
|
||||
delay_id?: string;
|
||||
} | null;
|
||||
if (url.endsWith("/rtc/livekit/delegate_delayed_leave")) {
|
||||
delegations.push({
|
||||
via: "homeserver",
|
||||
status: response.status(),
|
||||
...(body && { sfuUrl: body.url, delayId: body.delay_id }),
|
||||
});
|
||||
} else if (url.endsWith("/livekit/jwt/delegate_delayed_leave")) {
|
||||
delegations.push({ via: "service", status: response.status() });
|
||||
} else if (url.endsWith("/livekit/jwt/sfu/get") && body?.delay_id) {
|
||||
delegations.push({
|
||||
via: "service",
|
||||
status: response.status(),
|
||||
delayId: body.delay_id,
|
||||
});
|
||||
}
|
||||
});
|
||||
return delegations;
|
||||
}
|
||||
|
||||
/** A recorded token request. */
|
||||
interface AuthRequest {
|
||||
flow: Flow;
|
||||
status: number;
|
||||
/** The SFU a request to the MSC4195 `get_token` endpoint asked for. */
|
||||
sfuUrl?: string;
|
||||
/** The participant identity the issued token carries. */
|
||||
identity?: string;
|
||||
}
|
||||
|
||||
/** The legacy `<user_id>:<device_id>` participant identity. */
|
||||
const legacyIdentity = /^@[^:]+:[^:]+:[A-Z]+$/;
|
||||
/** A hashed participant identity: unpadded base64 of a SHA-256. */
|
||||
const hashedIdentity = /^[A-Za-z0-9+/]{43}$/;
|
||||
|
||||
/** Reads the subject of a JWT without verifying it. */
|
||||
function jwtSubject(jwt: string): string {
|
||||
const [, payload] = jwt.split(".");
|
||||
return (
|
||||
JSON.parse(Buffer.from(payload, "base64url").toString()) as {
|
||||
sub: string;
|
||||
}
|
||||
).sub;
|
||||
}
|
||||
|
||||
/** Records token requests, including the identity each token carries. */
|
||||
function recordAuthRequests(page: Page): AuthRequest[] {
|
||||
const requests: AuthRequest[] = [];
|
||||
page.on("response", (response) => {
|
||||
const request = response.request();
|
||||
if (request.method() !== "POST") return;
|
||||
const url = request.url();
|
||||
let entry: AuthRequest;
|
||||
if (url.endsWith("/rtc/livekit/get_token")) {
|
||||
entry = {
|
||||
flow: "cs-api",
|
||||
status: response.status(),
|
||||
sfuUrl: (request.postDataJSON() as { url: string }).url,
|
||||
};
|
||||
} else if (url.endsWith("/livekit/jwt/sfu/get")) {
|
||||
entry = { flow: "service-legacy", status: response.status() };
|
||||
} else if (url.endsWith("/livekit/jwt/get_token")) {
|
||||
entry = { flow: "service-default", status: response.status() };
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
requests.push(entry);
|
||||
if (entry.status !== 200) return;
|
||||
response
|
||||
.json()
|
||||
.then((body: { jwt: string }) => {
|
||||
entry.identity = jwtSubject(body.jwt);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
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 Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Adds synthetic devices beside the browser's one fake microphone and speaker,
|
||||
* so the menu has a choice to show. They share one capture and `setSinkId` is
|
||||
* accepted but not honoured: routing still needs hardware and a manual check.
|
||||
* Call before the page navigates.
|
||||
*/
|
||||
export async function installFakeDevices(
|
||||
page: Page,
|
||||
{ microphones = 2, speakers = 2 } = {},
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ microphones, speakers }) => {
|
||||
const synthetic = (
|
||||
kind: MediaDeviceKind,
|
||||
count: number,
|
||||
name: string,
|
||||
): MediaDeviceInfo[] =>
|
||||
Array.from({ length: count }, (_, i) => {
|
||||
const info = {
|
||||
deviceId: `${kind}-${i + 1}`,
|
||||
groupId: `${kind}-group-${i + 1}`,
|
||||
kind,
|
||||
label: `${name} ${i + 1}`,
|
||||
};
|
||||
return { ...info, toJSON: () => info } as MediaDeviceInfo;
|
||||
});
|
||||
const ids = new Set(
|
||||
[
|
||||
...synthetic("audioinput", microphones, ""),
|
||||
...synthetic("audiooutput", speakers, ""),
|
||||
].map((d) => d.deviceId),
|
||||
);
|
||||
|
||||
const devices = navigator.mediaDevices;
|
||||
const enumerate = devices.enumerateDevices.bind(devices);
|
||||
devices.enumerateDevices = async (): Promise<MediaDeviceInfo[]> => [
|
||||
...(await enumerate()),
|
||||
...synthetic("audioinput", microphones, "Fake Microphone"),
|
||||
...synthetic("audiooutput", speakers, "Fake Speaker"),
|
||||
];
|
||||
|
||||
// Our ids name no hardware, so drop the exact-device constraint.
|
||||
const getUserMedia = devices.getUserMedia.bind(devices);
|
||||
devices.getUserMedia = async (
|
||||
constraints?: MediaStreamConstraints,
|
||||
): Promise<MediaStream> => {
|
||||
const audio = constraints?.audio;
|
||||
if (typeof audio === "object") {
|
||||
const requested = audio.deviceId;
|
||||
const id =
|
||||
typeof requested === "object" && requested !== null
|
||||
? ((requested as ConstrainDOMStringParameters).exact as string)
|
||||
: (requested as string | undefined);
|
||||
if (id !== undefined && ids.has(id))
|
||||
return getUserMedia({ ...constraints, audio: true });
|
||||
}
|
||||
return getUserMedia(constraints);
|
||||
};
|
||||
|
||||
// Accept routing to our ids on both media elements and the AudioContext,
|
||||
// which the app also routes.
|
||||
for (const proto of [
|
||||
HTMLMediaElement.prototype,
|
||||
AudioContext.prototype,
|
||||
]) {
|
||||
const sink = proto as { setSinkId?: (id: string) => Promise<void> };
|
||||
const setSinkId = sink.setSinkId;
|
||||
if (setSinkId === undefined) continue;
|
||||
sink.setSinkId = async function (id: string): Promise<void> {
|
||||
if (!ids.has(id)) await setSinkId.call(this, id);
|
||||
};
|
||||
}
|
||||
},
|
||||
{ microphones, speakers },
|
||||
);
|
||||
}
|
||||
@@ -37,8 +37,6 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
|
||||
|
||||
await TestHelpers.acceptRoomInvite(roomName, timo.page);
|
||||
|
||||
await florian.page.pause();
|
||||
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
florian.page,
|
||||
rtcMode1,
|
||||
@@ -74,8 +72,6 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
|
||||
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
}
|
||||
|
||||
// await florian.page.pause();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -76,9 +76,9 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
frame.locator('video[data-lk-source="screen_share"]'),
|
||||
).toHaveCount(1);
|
||||
await expect(frame.locator('video[data-source="screenShare"]')).toHaveCount(
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
// Alice should be in grid mode as she is local sharing
|
||||
@@ -117,9 +117,9 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
frame.locator('video[data-lk-source="screen_share"]'),
|
||||
).toHaveCount(2);
|
||||
await expect(frame.locator('video[data-source="screenShare"]')).toHaveCount(
|
||||
2,
|
||||
);
|
||||
|
||||
// Expect 2 indicators at the bottom
|
||||
await expect(frame.getByTestId("screenshare-indicator")).toHaveCount(2);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user