mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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.
99 lines
3.7 KiB
JavaScript
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);
|
|
},
|
|
};
|
|
},
|
|
};
|