super-productivity/eslint-local-rules/rules/no-multi-entity-effect.js
Johannes Millan 42e6626b76 docs(sync): consolidate sync docs + enforce the contributor model
Collapse the sprawling, partly-stale docs/sync-and-op-log/ tree into a
small authoritative set and make the sync-correctness invariant
partly lint-enforced instead of convention-only.

Docs:
- Delete superseded/duplicate/provably-stale design, plan, and
  background-research docs (quick-reference, the architecture-diagrams
  monolith, the "Hybrid Manifest" docs describing code that does not
  exist, completed long-term plans, LLM-synthesis analyses).
- Salvage load-bearing decision history into the surviving docs before
  deletion: rejected-alternatives rationale -> operation-log-architecture
  ("Why this architecture"); vector-clock pruning incident history ->
  vector-clocks.md; archive-payload optimization -> architecture E.7.
- Add contributor-sync-model.md as the single-invariant entry point
  (one user intent = one op; replayed/remote ops must not re-trigger
  effects), with a decision table mapping to the enforcing linters.
- Repoint external/internal cross-refs; add CONTRIBUTING.md + CLAUDE.md
  pointers; record the migration in a dated docs/plans/ design doc.

Enforcement (new eslint-local-rules):
- no-actions-in-effects (error): effects must inject LOCAL_ACTIONS /
  ALL_ACTIONS, never the raw @ngrx/effects Actions stream.
- no-multi-entity-effect (warn, heuristic): flags a literal returned
  array of >=2 action-creator calls; docstring + valid-case specs pin
  exactly which shapes are and are not detected.
- run-specs.js runner wired into `npm run lint` via test:lint-rules;
  refuses to run under test-framework globals and counts RuleTester.run
  invocations so a spec that asserts nothing fails instead of passing.
- Correct the ALL_ACTIONS JSDoc in local-actions.token.ts to match
  reality (archive-operation-handler uses LOCAL_ACTIONS).

Reviewed via parallel multi-agent review; findings W1/W2/W4 and a
dangling doc anchor addressed.
2026-05-15 16:51:50 +02:00

99 lines
3.7 KiB
JavaScript

/**
* ESLint rule: no-multi-entity-effect (heuristic, warn)
*
* Flags ONE narrow syntactic shape: an effect whose dispatch arm returns a
* *literal array* of >=2 action-creator calls directly as an arrow body or a
* `return` argument, e.g. `map(() => [updateProject(), updateTask()])`. One
* user intent should become exactly ONE operation; a multi-entity change must
* live in a meta-reducer (src/app/root-store/meta/task-shared-meta-reducers/),
* not an effect that emits N follow-up actions — an effect fan-out produces N
* ops for one intent and re-runs on replay.
*
* Deliberately NOT detected (covered by the contributor doc + code review;
* pinned as `valid` cases in the spec so the boundary is explicit and a future
* change that starts catching them trips the spec):
* - varargs `of(a(), b())` — not an ArrayExpression
* - `from([a(), b()])` — array wrapped in a call
* - ternary / conditionally returned arrays
* - imperative `store.dispatch(a()); store.dispatch(b())`
*
* It is a `warn` heuristic, not a guarantee: a clean run does NOT prove an
* effect is single-op. If a flagged multi-dispatch is intentionally not synced
* state, disable the line with a justification comment.
*
* See docs/sync-and-op-log/contributor-sync-model.md (the atomicity rule).
*/
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Effects should not dispatch a multi-action fan-out; multi-entity changes belong in a meta-reducer',
category: 'Best Practices',
recommended: false,
},
messages: {
multiEntityEffect:
'This effect returns a fan-out of {{count}} actions for one trigger. One user intent = one operation: move multi-entity changes into a meta-reducer (src/app/root-store/meta/task-shared-meta-reducers/). If this is intentionally not synced state, disable with a justification. See docs/sync-and-op-log/contributor-sync-model.md.',
},
schema: [],
},
create(context) {
const reported = new Set();
/**
* An array of >=2 call expressions returned from an effect stream is the
* fan-out smell (e.g. `map(() => [actionA(), actionB()])`).
*/
const checkArray = (arrNode) => {
if (!arrNode || arrNode.type !== 'ArrayExpression') return;
const calls = arrNode.elements.filter((el) => el && el.type === 'CallExpression');
if (calls.length >= 2 && !reported.has(arrNode)) {
reported.add(arrNode);
context.report({
node: arrNode,
messageId: 'multiEntityEffect',
data: { count: String(calls.length) },
});
}
};
return {
CallExpression(node) {
if (node.callee.type !== 'Identifier' || node.callee.name !== 'createEffect') {
return;
}
const seen = new Set();
const walk = (n) => {
if (!n || typeof n.type !== 'string' || seen.has(n)) return;
seen.add(n);
if (
n.type === 'ArrowFunctionExpression' &&
n.body &&
n.body.type === 'ArrayExpression'
) {
checkArray(n.body);
}
if (
n.type === 'ReturnStatement' &&
n.argument &&
n.argument.type === 'ArrayExpression'
) {
checkArray(n.argument);
}
for (const key of Object.keys(n)) {
if (key === 'parent') continue;
const child = n[key];
if (Array.isArray(child)) {
child.forEach(walk);
} else if (child && typeof child.type === 'string') {
walk(child);
}
}
};
walk(node);
},
};
},
};