super-productivity/eslint-local-rules/rules/no-actions-in-effects.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

70 lines
2.6 KiB
JavaScript

/**
* ESLint rule: no-actions-in-effects
*
* Effects must not use the raw `@ngrx/effects` `Actions` stream. They must
* inject `LOCAL_ACTIONS` (the default — remote/replayed ops filtered out) or,
* only for the op-log/archive effects that handle `isRemote` themselves,
* `ALL_ACTIONS`.
*
* This enforces Boundary 1 of the single sync invariant: replayed and remote
* operations must never re-trigger effects (duplicate side effects + phantom
* operations). See docs/sync-and-op-log/contributor-sync-model.md.
*
* Scoped to every `.effects.ts` file via eslint.config.js. The codebase is
* already 100% compliant — this is a regression guard, not a migration.
*
* Flags:
* - `import { Actions } from '@ngrx/effects'` (including aliased imports)
* - `inject(Actions)` (including the aliased local binding)
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Effects must inject LOCAL_ACTIONS/ALL_ACTIONS, never the raw @ngrx/effects Actions stream',
category: 'Possible Errors',
recommended: true,
},
messages: {
noActionsImport:
"Do not import `Actions` from '@ngrx/effects' in an effect. Inject `LOCAL_ACTIONS` (default) — or `ALL_ACTIONS` only for op-log/archive effects. See docs/sync-and-op-log/contributor-sync-model.md.",
noActionsInject:
'Do not `inject(Actions)` in an effect. Use `inject(LOCAL_ACTIONS)` (default) — or `inject(ALL_ACTIONS)` only for op-log/archive effects. See docs/sync-and-op-log/contributor-sync-model.md.',
},
schema: [],
},
create(context) {
// Local binding name(s) that `Actions` from '@ngrx/effects' was imported as.
const actionsLocalNames = new Set();
return {
ImportDeclaration(node) {
if (node.source.value !== '@ngrx/effects') return;
for (const spec of node.specifiers) {
if (
spec.type === 'ImportSpecifier' &&
spec.imported &&
spec.imported.name === 'Actions'
) {
actionsLocalNames.add(spec.local.name);
context.report({ node: spec, messageId: 'noActionsImport' });
}
}
},
CallExpression(node) {
if (node.callee.type !== 'Identifier' || node.callee.name !== 'inject') {
return;
}
const arg = node.arguments[0];
if (!arg || arg.type !== 'Identifier') return;
// Catch the literal name and any aliased binding of @ngrx/effects Actions.
if (arg.name === 'Actions' || actionsLocalNames.has(arg.name)) {
context.report({ node, messageId: 'noActionsInject' });
}
},
};
},
};