mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* docs: add bug-detection improvements plan
Plan for mechanical bug detection alongside the agentic hunt: activate the 14
unused Go fuzz harnesses, the configured-but-never-run Stryker setup, and
browser-mode vitest; encode recurring bug classes as semgrep rules; add
model-based and fault-injected ordering tests; add a persistent seen-ledger
and sibling-sweep lens to the hunt.
All local-only and on demand - fuzz crashers are working reproducers, and this
repo is public.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* build: add make fuzz target and ignore mutation-test output
`go test ./...` runs each Fuzz* function against its committed seed corpus
only - one pass per seed, zero generated inputs - so the 17 fuzz harnesses in
Server/ have never actually fuzzed. `make fuzz` enumerates every target and
runs each with a time budget (Go fuzzes one target per package per
invocation, hence the loop). Local-only by design: a crasher is a working
reproducer and this repo is public.
Also gitignore Client/tauri-client/.stryker-tmp/ and reports/ - a Stryker run
left 200+ untracked files, and a surviving-mutant report maps exactly which
behaviour nothing tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(client): pin reconnect auth-frame and replay-dedup arming
Stryker found 14 surviving mutants across ws.ts:413/422/428 - the auth frame
built on reconnect. Every condition there could be flipped with all 4777
tests still green: the replay-dedup arming guard, the resume-vs-fresh-connect
ternary, and the conditional active_channel_id spread.
Seven tests through the public send/isReplaying surface, no new exports. Two
isolate each half of the `reconnectAttempt > 0 && lastSeq > 0` AND condition -
the combination no existing test reached, and the one an && -> || mutant
walked straight through.
Verified by flipping the line 413 guard to `if (true)`: 3 of 7 fail, revert
restores green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: record two fuzz corpus traps
Interrupting a fuzz run manufactures a false crasher: Go cannot distinguish a
worker that crashed on an input from one killed externally, so it saves the
in-flight input to testdata/fuzz/ as a suspect. It looks exactly like a real
security finding. Replay before believing it.
And committed seed corpus shares the testdata/fuzz/<Target>/ directory with
any false crasher, so clearing one by removing the directory deletes the
seeds too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(client): enforce three prose invariants as ESLint rules
CLAUDE.md documents the voice-supersession, E2EE staleness and dispatcher
invariants in English. English fails no build, and bug hunts keep rediscovering
the same classes. Five rules encode them as an inline flat-config plugin - no
new dependency, and `npx eslint src/` is already a blocking CI gate.
- no-leave-voice-when-superseded: a global leaveVoice() inside a branch that
already confirmed supersession tears down the newer live session
- e2ee-epoch-needs-keypair-check: a non-key-holder never bumps the epoch, so
an epoch-only staleness guard cannot see a restarted session
- e2ee-verified-status-literal: keeps "verified" tied to a hand-written call
site that earned it, never a computed status
- no-identity-scope-fallback: a `?? 0` placeholder scope mints a keypair under
the wrong account
- no-store-write-in-ws-on: page-local ws.on handlers may read stores, not
write them
Each rule proven to fire by reintroducing the historical bug shape and
reverting; RuleTester cases cover both the real shapes that must stay clean
and the bug shapes that must not.
A fourth candidate - await-then-stale-snapshot - was declined as not
AST-expressible: whether an await needs a guard, and whether the guard is
sufficient, is intent rather than shape, and the rule would flag most of the
already-correct guard code in livekitSession.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: correct dispatcher invariant, record Tier 2 as shipped
The client CLAUDE.md claimed ws.on(...) appears only in dispatcher.ts. Eight
handlers across main.ts, MainPage.ts and ChannelController.ts say otherwise -
page-local UI (ringing, overlays, slow-mode timers) legitimately subscribes.
The real invariant is narrower: dispatcher is the single path by which server
events WRITE to domain stores. That is what local/no-store-write-in-ws-on
enforces, and the doc now matches the code.
Also record that Tier 2 shipped as ESLint rules rather than semgrep, and why
the fourth candidate was declined.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): move the status-picker dot onto the avatar corner
The corner dot on the user bar avatar was a static hardcoded-green div —
never reflected real status and did nothing on click. Removed it and
relocated the actual StatusPicker trigger dot (real color, opens the
status dropdown) to that same corner instead of its own row. The
"Online"/"Idle"/... text label under the username is unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(client): return the saved password over IPC again
The remember-password box saved a password the client could never read
back. Hardening had put #[serde(skip)] on CredentialData::password, so
load_credential returned a record whose password was always absent and
the login form could not prefill it — the box appeared to work and
silently did nothing.
Drop the skip and carry the field through the TS wrapper, which now maps
a non-string password to undefined rather than trusting the payload.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(client): add an auto-connect checkbox to the login form
Auto-connect already existed end to end — ServerProfile.autoConnect,
setAutoLogin(), and the boot auto-login block with its cancel overlay —
but was only reachable through the zap button on a server card. This
surfaces the same state as a checkbox under Remember password, where
users look for it.
Ticking it forces Remember password on and disables it: boot auto-login
replays the stored token, which saveCredential only writes when the
password is remembered, so the two cannot be set independently without
producing a setting that silently does nothing.
Unticking is guarded. setAutoLogin(null) clears autoConnect on every
profile, so a bare toggle-off would wipe another server's setting; the
clear now only fires when this profile is the current holder. The guard
lives in ensureProfileExists, which all four auth paths already route
through.
Also consume the password restored in the previous commit, so selecting
a saved server prefills it instead of leaving the field blank.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* chore(release): bump client to 1.2.0-alpha.2
The client version is not derived from the tag — release.yml's
verify-versions job compares the tag against package.json and
tauri.conf.json and fails the release if they drift, so all five
manifests (both lockfiles included) move together.
Also refreshes the literal version in the README and docs build
examples, and closes the Unreleased changelog section as v1.2.0-alpha.2.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs(changelog): record the three bug-hunt sweeps in v1.2.0-alpha.2
PRs #1328, #1331 and #1332 merged to main after v1.2.0-alpha.1 was tagged
and closed 233 verified defects between them, but none of the three left
an entry in the curated changelog — the generated list covers commits,
this file covers behaviour, and nothing bridged the two.
Verified unreleased by ancestry rather than by date (none of the three
merge commits is an ancestor of v1.2.0-alpha.1), so all of it ships for
the first time in alpha.2.
Nine entries grouped by subsystem, leading with the changes an operator
or user would actually notice: the 24h-retention desync, the avatar-
deleting orphan sweep, the zero-byte restore truncation, the six hot-mic
paths, and the TOFU re-pin that would have warned every install at once.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* test(client): drop the e2e assertion for the removed user-bar status dot (#1334)
26b46cc removed the hardcoded-green `.status-dot` div from the user bar
avatar and relocated the real StatusPicker trigger dot into that corner,
adding "status picker dot sits on the avatar" to cover the new element.
The old "user bar has status dot" test was left behind and now fails on
an element that no longer exists by design.
The replacement test already asserts the corner dot is present and
visible, so removing the stale one loses no coverage.
Claude-Session: https://claude.ai/code/session_01Rkv9dVo5YEYArqrDRfW41w
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
409 lines
18 KiB
JavaScript
409 lines
18 KiB
JavaScript
// Custom ESLint rules that turn three of the invariants documented in prose in
|
|
// CLAUDE.md into enforced, test-covered lint rules. Each rule is scoped (via
|
|
// `files:` in eslint.config.js) to only the module(s) its invariant governs —
|
|
// see the per-rule `meta.docs.description` for the invariant it encodes and
|
|
// tests/unit/eslint-rules.test.ts for the real-code shapes it was proven
|
|
// against (both the shapes that must stay clean and the historical bug shapes
|
|
// it must catch).
|
|
//
|
|
// Plain JS, ESM, no build step — eslint.config.js imports this directly.
|
|
|
|
/** True when `node` is a `this.<methodName>(...)` call. */
|
|
function isThisMethodCall(node, methodName) {
|
|
return (
|
|
node !== null &&
|
|
node.type === "CallExpression" &&
|
|
node.callee.type === "MemberExpression" &&
|
|
node.callee.object.type === "ThisExpression" &&
|
|
!node.callee.computed &&
|
|
node.callee.property.type === "Identifier" &&
|
|
node.callee.property.name === methodName
|
|
);
|
|
}
|
|
|
|
/** True when `node` is a `this.<propertyName>` member access. */
|
|
function isThisMember(node, propertyName) {
|
|
return (
|
|
node !== null &&
|
|
node.type === "MemberExpression" &&
|
|
node.object.type === "ThisExpression" &&
|
|
!node.computed &&
|
|
node.property.type === "Identifier" &&
|
|
node.property.name === propertyName
|
|
);
|
|
}
|
|
|
|
function isFunctionNode(node) {
|
|
return (
|
|
node.type === "FunctionDeclaration" ||
|
|
node.type === "FunctionExpression" ||
|
|
node.type === "ArrowFunctionExpression"
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Rule: no-leave-voice-when-superseded
|
|
//
|
|
// Invariant (CLAUDE.md): "Voice sessions are superseded, not cancelled.
|
|
// LiveKitSession re-entry points check whether a newer attempt owns the
|
|
// shared state before tearing anything down, so cleanup in an aborted path
|
|
// must be scoped to that attempt's own room — a global leaveVoice() there
|
|
// kills the live session."
|
|
//
|
|
// livekitSession.ts encodes "this attempt was superseded" with exactly two
|
|
// guard predicates, always used the same way: `this.reconnectSuperseded(...)`
|
|
// (true = superseded) and `!this.isStateConnected(...)` (negated = true when
|
|
// superseded). Once either guard has confirmed supersession, the historical
|
|
// bug (see the fix that introduced disconnectSupersededLocalRoom /
|
|
// generation-guarded leaveVoice calls) was calling the global
|
|
// `this.leaveVoice()` inside that same branch, tearing down whichever session
|
|
// currently owns the shared state — which, once superseded, is a newer
|
|
// attempt's live session, not this one.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/** True when `test` (walking through &&/||) asserts "this attempt IS
|
|
* superseded" via one of the two named guards used throughout the file. */
|
|
function testSignalsSuperseded(test) {
|
|
if (test === null) return false;
|
|
if (test.type === "LogicalExpression") {
|
|
return testSignalsSuperseded(test.left) || testSignalsSuperseded(test.right);
|
|
}
|
|
if (isThisMethodCall(test, "reconnectSuperseded")) return true;
|
|
if (test.type === "UnaryExpression" && test.operator === "!") {
|
|
return isThisMethodCall(test.argument, "isStateConnected");
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const noLeaveVoiceWhenSuperseded = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: {
|
|
description:
|
|
"Disallow this.leaveVoice() inside a branch that already confirmed this connect/reconnect " +
|
|
"attempt was superseded. Voice sessions are superseded, not cancelled — once reconnectSuperseded() " +
|
|
"or !isStateConnected() is true, `_state` may already belong to a newer, live attempt, and " +
|
|
"leaveVoice() there tears that live session down instead of the aborted one.",
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
unsafeLeaveVoice:
|
|
"this.leaveVoice() must not run once this attempt is known to be superseded — it acts on " +
|
|
"whichever session currently owns `_state`, which may now be a newer, live attempt. Disconnect " +
|
|
"only this attempt's own room instead (e.g. disconnectSupersededLocalRoom(localRoom) / " +
|
|
"localRoom.disconnect()), or simply return without calling it.",
|
|
},
|
|
},
|
|
create(context) {
|
|
return {
|
|
CallExpression(node) {
|
|
if (!isThisMethodCall(node, "leaveVoice")) return;
|
|
let child = node;
|
|
let parent = node.parent;
|
|
while (parent) {
|
|
if (isFunctionNode(parent)) return; // left the enclosing method — stop
|
|
if (
|
|
parent.type === "IfStatement" &&
|
|
child === parent.consequent &&
|
|
testSignalsSuperseded(parent.test)
|
|
) {
|
|
context.report({ node, messageId: "unsafeLeaveVoice" });
|
|
return;
|
|
}
|
|
child = parent;
|
|
parent = parent.parent;
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Rule: e2ee-epoch-needs-keypair-check
|
|
//
|
|
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts ... must preserve
|
|
// the epoch/keypair staleness guards."
|
|
//
|
|
// Every async E2EE operation that resumes after an await re-checks it is
|
|
// still the current attempt before writing shared state. The historical bug
|
|
// (see the fix for handleOfferInner / handleAnnounceInner) compared only
|
|
// `this._e2eeEpoch !== epochBefore` — insufficient, because a non-key-holder
|
|
// never bumps the epoch, so a torn-down-then-restarted session can resume
|
|
// with the epoch unchanged in both the old and new session. The fix requires
|
|
// ALSO comparing keypair identity (`this._ecdhKeyPair !== keypair`). This
|
|
// rule requires both checks to appear together in the same guard.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/** True when `test` (walking through &&/||) contains `this.<prop> !== X`
|
|
* (in either operand order). */
|
|
function containsStrictInequality(test, prop) {
|
|
if (test === null) return false;
|
|
if (test.type === "LogicalExpression") {
|
|
return containsStrictInequality(test.left, prop) || containsStrictInequality(test.right, prop);
|
|
}
|
|
if (test.type === "BinaryExpression" && test.operator === "!==") {
|
|
return isThisMember(test.left, prop) || isThisMember(test.right, prop);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const e2eeEpochNeedsKeypairCheck = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: {
|
|
description:
|
|
"Require this._ecdhKeyPair identity checks alongside this._e2eeEpoch staleness checks. A " +
|
|
"non-key-holder session never bumps the epoch, so an epoch-only comparison cannot detect a " +
|
|
"torn-down-then-restarted session resuming after an await — only the keypair identity can.",
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
missingKeypairCheck:
|
|
"This staleness check compares this._e2eeEpoch but not this._ecdhKeyPair. A non-key-holder " +
|
|
"session never advances the epoch, so this guard alone cannot detect a torn-down-then-restarted " +
|
|
"session — add `|| this._ecdhKeyPair !== <the keypair captured before the await>` to the condition.",
|
|
},
|
|
},
|
|
create(context) {
|
|
return {
|
|
IfStatement(node) {
|
|
if (
|
|
containsStrictInequality(node.test, "_e2eeEpoch") &&
|
|
!containsStrictInequality(node.test, "_ecdhKeyPair")
|
|
) {
|
|
context.report({ node: node.test, messageId: "missingKeypairCheck" });
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Rule: e2ee-verified-status-literal
|
|
//
|
|
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts ... must never
|
|
// report an unverified peer as verified."
|
|
//
|
|
// verifyPeerAnnounce's every write of peer-verification state goes through
|
|
// setPeerVerification/setPeerVerificationIfCurrent, and "verified" is reached
|
|
// exactly once, only after a real signature check. This rule keeps that
|
|
// structurally true: the `status` field at every call site must be a literal
|
|
// the author typed by hand at that call site, never a variable/expression —
|
|
// which would let a status be computed (and potentially manipulated) instead
|
|
// of asserted at the one audited call site that earned it.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
function getCalleeName(node) {
|
|
if (node.callee.type === "Identifier") return node.callee.name;
|
|
if (
|
|
node.callee.type === "MemberExpression" &&
|
|
!node.callee.computed &&
|
|
node.callee.property.type === "Identifier"
|
|
) {
|
|
return node.callee.property.name;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const VERIFICATION_SETTERS = new Set(["setPeerVerification", "setPeerVerificationIfCurrent"]);
|
|
|
|
const e2eeVerifiedStatusLiteral = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: {
|
|
description:
|
|
"Require the `status` field passed to setPeerVerification/setPeerVerificationIfCurrent to be a " +
|
|
"string literal. A peer must never be reported verified via a computed/derived status — each " +
|
|
"verification outcome is a distinct, hand-written call site that earned its status inline.",
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
dynamicStatus:
|
|
"The `status` passed here must be a string literal ('verified' | 'unverified' | 'mismatch' | " +
|
|
"'unknown'), not a computed expression. Add a new literal call site for this outcome instead of " +
|
|
"deriving the status dynamically — that is what keeps 'verified' provably tied to a real signature check.",
|
|
},
|
|
},
|
|
create(context) {
|
|
return {
|
|
CallExpression(node) {
|
|
const name = getCalleeName(node);
|
|
if (name === null || !VERIFICATION_SETTERS.has(name)) return;
|
|
const objArg = node.arguments[node.arguments.length - 1];
|
|
if (objArg === undefined || objArg.type !== "ObjectExpression") return;
|
|
const statusProp = objArg.properties.find(
|
|
(p) =>
|
|
p.type === "Property" &&
|
|
!p.computed &&
|
|
p.key.type === "Identifier" &&
|
|
p.key.name === "status",
|
|
);
|
|
if (statusProp === undefined) return;
|
|
const value = statusProp.value;
|
|
if (value.type !== "Literal" || typeof value.value !== "string") {
|
|
context.report({ node: statusProp, messageId: "dynamicStatus" });
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Rule: no-identity-scope-fallback
|
|
//
|
|
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts or identity.ts
|
|
// must preserve the epoch/keypair staleness guards." (Identity-scoping
|
|
// analogue: a documented, previously-real bug — see identity.ts's
|
|
// `identityKeyPairCache` comment — where a missing user id fell back to a
|
|
// placeholder scope like `?? 0`, silently minting/adopting a keypair under
|
|
// the wrong account and permanently desyncing the published key from the
|
|
// announce-signing key for every peer.)
|
|
//
|
|
// getOrCreateIdentityKeyPair's userId argument must come from a value that
|
|
// was already checked for `undefined` (the pattern both call sites use), not
|
|
// a `??`/`||` fallback that would substitute a placeholder id.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
const noIdentityScopeFallback = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: {
|
|
description:
|
|
"Disallow a ??/|| placeholder fallback as the userId argument to getOrCreateIdentityKeyPair. A " +
|
|
"missing user id must abort (see the `userId === undefined` guards at both call sites), never " +
|
|
"substitute a placeholder scope — that mints or adopts a keypair under the wrong account and " +
|
|
"permanently desyncs the published key from the announce-signing key.",
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
placeholderFallback:
|
|
"Do not fall back with ??/|| when passing the user id to getOrCreateIdentityKeyPair — a missing " +
|
|
"id must abort instead (check `=== undefined` and return, as both existing call sites do). A " +
|
|
"placeholder id mints/adopts a keypair under the wrong account and desyncs it from the signing key.",
|
|
},
|
|
},
|
|
create(context) {
|
|
return {
|
|
CallExpression(node) {
|
|
if (
|
|
node.callee.type !== "Identifier" ||
|
|
node.callee.name !== "getOrCreateIdentityKeyPair"
|
|
) {
|
|
return;
|
|
}
|
|
const userIdArg = node.arguments[1];
|
|
if (userIdArg === undefined) return;
|
|
if (
|
|
userIdArg.type === "LogicalExpression" &&
|
|
(userIdArg.operator === "??" || userIdArg.operator === "||")
|
|
) {
|
|
context.report({ node: userIdArg, messageId: "placeholderFallback" });
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Rule: no-store-write-in-ws-on
|
|
//
|
|
// Invariant (CLAUDE.md): "src/lib/dispatcher.ts is the single WS-event entry
|
|
// point: server events reach the stores only through a ws.on(...)
|
|
// subscription registered there."
|
|
//
|
|
// Other modules DO register their own ws.on(...) handlers (page-local UI:
|
|
// slow-mode timers, the connected overlay, incoming-call ringing) — that
|
|
// itself is not the violation. What must never happen outside dispatcher.ts
|
|
// is one of those handlers writing to a domain store directly, bypassing the
|
|
// dispatcher. Store *reads* (`fooStore.getState()`) are unaffected; this only
|
|
// flags calls to an imported store-mutator function (set/add/update/... from
|
|
// a `*/stores/*` module) reached from inside a `ws.on(...)` callback.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
const STORE_MUTATOR_PREFIX =
|
|
/^(set|add|remove|update|increment|clear|toggle|open|close|join|leave|mark|confirm|bulk|rollback|reset|prepend|reattach|invalidate|load)[A-Z_]/;
|
|
|
|
function isStoreModuleSource(source) {
|
|
// Matches both the "@stores/..." alias and relative "../stores/..." paths.
|
|
return typeof source === "string" && /(?:^|\/)@?stores\//.test(source);
|
|
}
|
|
|
|
function isWsOnCall(node) {
|
|
return (
|
|
node !== null &&
|
|
node !== undefined &&
|
|
node.type === "CallExpression" &&
|
|
node.callee.type === "MemberExpression" &&
|
|
!node.callee.computed &&
|
|
node.callee.object.type === "Identifier" &&
|
|
node.callee.object.name === "ws" &&
|
|
node.callee.property.type === "Identifier" &&
|
|
node.callee.property.name === "on" &&
|
|
node.arguments.length >= 2
|
|
);
|
|
}
|
|
|
|
const noStoreWriteInWsOn = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: {
|
|
description:
|
|
"Disallow calling an imported store-mutator (set*/add*/update*/... from a stores/ module) from " +
|
|
"inside a ws.on(...) callback outside dispatcher.ts. dispatcher.ts is the single place server " +
|
|
"events are allowed to write into domain stores; a page-local ws.on(...) handler may read store " +
|
|
"state and drive its own local UI, but must not mutate a domain store itself.",
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
storeWriteOutsideDispatcher:
|
|
"'{{name}}' is a store mutator called from a ws.on(...) handler outside dispatcher.ts. " +
|
|
"dispatcher.ts is the single WS-event entry point that may write to stores — move this update " +
|
|
"into a dispatcher.ts handler for this message type, or have this handler read the store instead " +
|
|
"of writing it.",
|
|
},
|
|
},
|
|
create(context) {
|
|
const storeMutatorImports = new Set();
|
|
|
|
return {
|
|
ImportDeclaration(node) {
|
|
if (!isStoreModuleSource(node.source.value)) return;
|
|
for (const spec of node.specifiers) {
|
|
if (spec.type === "ImportSpecifier" && STORE_MUTATOR_PREFIX.test(spec.local.name)) {
|
|
storeMutatorImports.add(spec.local.name);
|
|
}
|
|
}
|
|
},
|
|
CallExpression(node) {
|
|
if (node.callee.type !== "Identifier" || !storeMutatorImports.has(node.callee.name)) return;
|
|
let parent = node.parent;
|
|
while (parent) {
|
|
if (
|
|
isFunctionNode(parent) &&
|
|
isWsOnCall(parent.parent) &&
|
|
parent.parent.arguments[1] === parent
|
|
) {
|
|
context.report({
|
|
node,
|
|
messageId: "storeWriteOutsideDispatcher",
|
|
data: { name: node.callee.name },
|
|
});
|
|
return;
|
|
}
|
|
parent = parent.parent;
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
export default {
|
|
rules: {
|
|
"no-leave-voice-when-superseded": noLeaveVoiceWhenSuperseded,
|
|
"e2ee-epoch-needs-keypair-check": e2eeEpochNeedsKeypairCheck,
|
|
"e2ee-verified-status-literal": e2eeVerifiedStatusLiteral,
|
|
"no-identity-scope-fallback": noIdentityScopeFallback,
|
|
"no-store-write-in-ws-on": noStoreWriteInWsOn,
|
|
},
|
|
};
|