mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
f771585486
* fix(world-vercel,world-local): hold process-wide state on globalThis Both packages are bundled into the host application's server build, and a bundler keys module identity on (resource, layer) — Next.js alone builds `instrument`, app-route, `ssr` and `edge` layers, so one process holds one copy of each of these modules per layer. Every module-scope `const`/`let` in them was therefore per-copy state wearing the costume of a process singleton. vercel/workflow#3493 made `@workflow/world-vercel` bundled rather than external and the events WebSocket transport regressed to HTTP for exactly this reason: the queue consumer registered its channel in the `instrument` copy's `Map` and the write path looked it up in the route copy's empty one. A deterministic miss, for the life of the process. `@workflow/world-local` had the same exposure all along — including `runFileLocks`, where a duplicated mutex simply stops mutually excluding. Add `globalSingleton()` to `@workflow/utils` (the primitive `@workflow/core` already hand-rolls for its World cache) and route every mutable module-scope binding in both worlds through it. Regression cover, in three layers: - `global-singleton.test.ts` pins the primitive's semantics. - `ws-transport-module-copies.test.ts` imports the module twice in one process and asserts a transport registered by one copy is found by the other — it fails on a plain module-scope `Map`, which is the shipped bug. - `scripts/lint/module-scope-state.mjs` fails the class: an AST rule banning mutable module-scope state in these packages, with `// per-copy-ok: <why>` as the deliberate escape. Wired into both packages' `vitest run src`, with fixture self-tests so it cannot rot into a no-op. * test(world-postgres): pin the module-scope-state rule for the postgres world It is deduped today only because `getRuntimeRequire()` loads it — a property of how it is loaded, not how it is written, and exactly what changed for world-vercel in #3493. The package is already clean; this keeps it that way. * docs(worlds): codify "a world must not hold mutable module state" A world package is loaded one of two ways, and only one of them gives it a single module instance: a runtime `require()` (deduped by Node) or the host's bundler (one copy per layer). Which one you get is a property of how the world is loaded, not of how it is written, and it changed under `world-vercel` in #3493 — so the rule has to be "never rely on module scope", not "rely on it until someone flips a config". Written down in the four places someone can meet it: - `docs/content/worlds/{v4,v5}/building-a-world.mdx` — a "Process-wide state" section for custom-world authors, with the loading modes spelled out and a nudge to prefer World-instance state over a global. - `packages/world/README.md` — the same constraint on the contract package. - `CLAUDE.md` — so the next contributor working in these packages sees it. - `packages/core/src/runtime/world.ts` — at the two static imports, which is where the difference between a bundled world and a required one originates. The rule's own error message now teaches it too, rather than naming a helper. Consolidates the guard while here: `@workflow/utils` owns the rule and its fixture self-tests, and sweeps every *published* `packages/world-*` discovered at runtime, so a world package added later is covered without anyone remembering. Each world keeps a one-assertion mirror for locality. * style: drop prose em dashes from this branch's new text #3704 landed a repo-wide writing pass hours after this branch was written and took `world-vercel/src` from 406 em dashes to 130 (`ws-transport.ts` alone went 35 to 1). This branch's docs section, README, comments and lint messages were written before that and would have put 36 of them straight back into the files that were just cleaned. Rewritten sentence by sentence rather than by substitution: an em dash becomes a colon, a comma, a full stop or a parenthetical depending on what it was doing. Also fixes a real defect the sweep surfaced: `world-postgres`'s guard test was generated through a shell heredoc and had literal backslash-backticks in its doc comment. * Update .changeset/world-module-scope-state.md Co-authored-by: Peter Wielander <mittgfu@gmail.com> Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> * fix(core): build the entrypoint's queue handler from getWorld() Adopted from #3666 by @MintedKenny, which implements #3665 and could not run CI as a fork PR. One line of behavior: `workflowEntrypoint`'s lazy handler init calls `getWorld()` rather than `getWorldHandlers()`. `getWorldHandlers()` owns a second, build-time-safe cache, so calling it from the runtime route built a *second* World in the same process. That costs a stateful World duplicate resources on every instance — world-postgres eagerly constructs a `pg.Pool` (default `max: 10`) and a nested world-local World in `createWorld()`, so self-hosted users have been paying for two of each — and, for a bundled world package, the two Worlds are built by two different module copies, which is the mechanism behind the WS transport regression the rest of this branch contains. The public `getWorldHandlers()` and its separate build-time cache are unchanged; only the runtime route stops using it. Kept from the original: the regression test asserting the factory runs exactly once, and the api-reference wording (re-applied over #3704's list punctuation). Not taken: renaming the `workflow.route.get_world_handlers` span. It is a distinct span from the per-request `workflow.route.get_world` at the top of the flow route, and reusing that name would collide with it in traces and in `runtime-trace-mode.test.ts`; a comment records why the name outlived the call. Co-authored-by: Kenneth <kenneth@standardforensics.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address AI review on the module-scope work Two blocking findings, both real: - **Cross-version state sharing** (`ws-transport.ts`). A process can hold two *published versions* of `@workflow/world-vercel` (a transitive dependency pinning an older `@workflow/core`, which depends on this package by exact version). Both wrote to the same unversioned `Symbol.for` key, so one version's write path could be handed a `WsEventsTransport` built by the other's class and frame against a protocol it may not share — with no version negotiation on the socket to catch it. `shapeVersion` cannot express this: the container is stable, the hazard is its contents. The registry and the events dispatcher recycler are now keyed by package version. The plain connection pools stay unversioned; sharing those across copies is the point. - **The documented pattern failed the rule this PR adds.** The custom-world docs teach `store[StateKey] ??= …`, which the rule flagged as a field write. It now recognizes state rooted at `globalThis`, following one alias hop, which is also what `core/private.ts:23` and `next/src/index.ts:58` are already doing correctly (core drops 26 findings to 22, next 7 to 6). The docs also now say outright that `globalSingleton()` is the same thing, since AGENTS.md prescribes it and the page did not mention it. Rule precision, from the review's probes: - `.mts`/`.cts` are scanned. `@workflow/world-testing` is authored in `.mts`, so its entry in the sweep was passing vacuously — with the walk fixed it reports a real finding, now annotated (it is a standalone `serve()` entry). - Mutations in top-level statements no longer count. A table filled at module evaluation is identical in every copy; divergence needs a later write. - `static` class fields are collected, attributed to the class name. - An *exported* binding initialized to an empty collection is a finding on its own, which approximates the cross-file case the walk cannot resolve. Six fixtures pin the new behavior. The rule's header now states what it does not see, and AGENTS.md states where the sweep stops and why core is not gated yet. Also tags `resetGlobalSingletonForTest` `@internal`. * fix(lint): attribute a static-field write to the field, not the class The static-field support added in the previous commit keyed `declared` on the class name, so a class carrying more than one mutable static reported one finding instead of one per field, and labelled the survivor with whichever mutation was seen first. On a two-static fixture it reported `static Registry.latch (`.set()`)`: the name of one field, the reason belonging to the other, pointing the reader at the wrong line. Key static fields `Class.field` and resolve a write to the same shape, via a new `memberPath()` that takes the first two segments of a member chain and tries that key before the bare root identifier. Two follow-ons fall out of having the path: - `this.field` inside a `static` member resolves to the class, which is the ordinary way to write the mutation. `staticClassOf()` returns nothing for an instance member, where `this` is an instance and the state is per-instance rather than per-copy, and nothing inside a nested `function`, which rebinds `this`. - `state.count++` is now a finding, like the `state.count += 1` that `assignment()` already reported. Fixtures pin all four, including the instance-field case that must stay clean. The four world packages still report zero, and the extracted `recordMutation()` keeps the file at its previous two Biome complexity warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: make module duplication inert across every bundled package `@workflow/core` is bundled into the host server build the same way the worlds are, and always has been — the original repro measured three live copies in every arm, including the pre-#3493 external one. One instance is not reachable: layers cannot share a module, and core cannot be external because it *is* workflow code (`runtime/start.ts:253` and nine methods in `runtime/run.ts` are `'use step'`), so it must go through the SWC loader. The Next integration already encodes that rule by removing workflow-bearing packages from `serverExternalPackages`. So the duplication stays and the hazard is removed instead, everywhere the duplication can happen. `@workflow/core` (22 findings to 0): warn-once latches in `constants.ts`, `start.ts` and `telemetry.ts`; the source-map tracer cache; the VM script cache; the QuickJS compiled-assets and baseline caches; the dev-server port cache (its own comment already said "per process"); the text codecs; the zstd browser decoder; and the `useStep` closure brand, where a function marked by one copy was invisible to another. The one with teeth was `step-single-flight.ts`: a per-copy map is not single-flight. Two invocations reaching it through different layers would each believe they were alone in the process and both run the step body, silently degrading in-process dedup to the cross-process residual its own doc scopes out to the ownership lease. Also `@workflow/world` (a warn-once set, hand-rolled onto `globalThis` to keep that package dependency-free), `@workflow/ai` (the lazy OTel API), and `@workflow/nest` (bootstrap config in a module-level `let` and two static class fields — configure one copy, read another, and the controller is unconfigured for the life of the process). Five sites are deliberately per-copy and now say why: state keyed on objects that never cross copies (the barrier safety-net `WeakSet`, the QuickJS pending byte `WeakMap`), the synchronously-scoped guest-code sink, and the OTel diagnostic that reports what *this* copy sees. The sweep now covers all of it. Packages with a single module graph stay out (build-time code, the CLI, the o11y UI, the test runner) and AGENTS.md records which and why. Found while doing this: two static fields on one class collapsed into a single entry in the rule, so `WorkflowModule.options` was invisible behind `WorkflowModule.outDir`. Statics are now keyed `Class.field`. * fix(world): suppress noAssignInExpressions on the globalThis idiom The hand-rolled form trips Biome, as it does in `packages/core/src/private.ts`, which carries the same suppression. Restructuring it into a helper function instead would hide the state behind a call the module-scope rule cannot follow, so the binding would stop being recognized as off-module and the package would report a finding for correct code. * fix: sweep every bundled package, and mark utils side-effect free @shalabhc asked on review whether `@workflow/utils` needs this too. It does, and so do three others: `utils`, `errors`, `serde` and `workflow` all end up in the host application's server build and none were in the sweep. All four report zero today, which is exactly the state `world-testing` appeared to be in before the `.mts` walk was fixed and it turned out to have a real finding. Being clean and being *checked* are different properties, and only the second one survives the next contributor. `sideEffects: false` on `@workflow/utils`: verified that every module in the package only declares (no import-time work), so a bundler can now drop the unused parts of the barrel instead of keeping all ~64 KB of it because three packages import one 476-byte function. --------- Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> Co-authored-by: Peter Wielander <mittgfu@gmail.com> Co-authored-by: Kenneth <kenneth@standardforensics.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
545 lines
18 KiB
JavaScript
545 lines
18 KiB
JavaScript
/**
|
|
* Finds module-scope state that changes at runtime.
|
|
*
|
|
* `@workflow/world-vercel` and `@workflow/world-local` are *bundled* into the
|
|
* host application's server build (see `VERCEL_WORLD_DEPENDENCY_PACKAGES` in
|
|
* `packages/next/src/index.ts`). A bundler keys module identity on
|
|
* (resource, layer), so one process holds one copy of each of these modules
|
|
* *per layer*. Next.js alone builds `instrument`, app-route, `ssr` and `edge`
|
|
* layers, and code registered from `instrumentation.ts` therefore does not
|
|
* share module scope with code that runs in a route handler.
|
|
*
|
|
* That makes every mutable module-scope binding a per-copy variable rather
|
|
* than the process-wide singleton its author assumed. vercel/workflow#3493
|
|
* turned these packages from external into bundled and the WebSocket events
|
|
* transport silently regressed to HTTP for exactly this reason: the queue
|
|
* consumer registered its channel in the `instrument` copy's `Map` and the
|
|
* write path looked it up in the route copy's empty one.
|
|
*
|
|
* The fix is `globalSingleton()` from `@workflow/utils`, which parks the state
|
|
* on `globalThis` under a `Symbol.for()` key so every copy shares one object.
|
|
* This rule fails the build on anything that reintroduces the pattern.
|
|
*
|
|
* Two escapes:
|
|
* - initialize the binding from `globalSingleton(...)` or from `globalThis`
|
|
* directly, the fix itself;
|
|
* - annotate it `// per-copy-ok: <why per-copy is correct here>` when the
|
|
* state is deliberately per module instance (a diagnostic describing what
|
|
* *this* copy sees, for example).
|
|
*
|
|
* What it sees: `const`/`let` statements and `static` class fields, mutated
|
|
* from inside a function body. Writes in top-level statements are ignored,
|
|
* because they run identically in every copy at module evaluation, so a
|
|
* precomputed lookup table is not a finding. An *exported* binding initialized
|
|
* to an empty collection is a finding on its own, since the code that fills it
|
|
* is often in another file.
|
|
*
|
|
* What it does not see: a write to an imported binding, resolved across files.
|
|
* That needs whole-package resolution. The exported-empty-collection rule above
|
|
* is the cheap approximation, and it is why exporting a mutable registry is
|
|
* reported even when this file never writes to it.
|
|
*
|
|
* Usage: node scripts/lint/module-scope-state.mjs <packageDir> [...]
|
|
*/
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import ts from 'typescript';
|
|
|
|
/** Methods that mutate the receiver in place. */
|
|
const MUTATORS = new Set([
|
|
'set',
|
|
'delete',
|
|
'clear',
|
|
'add',
|
|
'push',
|
|
'pop',
|
|
'shift',
|
|
'unshift',
|
|
'splice',
|
|
'sort',
|
|
'reverse',
|
|
'fill',
|
|
'copyWithin',
|
|
]);
|
|
|
|
const SINGLETON_HELPER = 'globalSingleton';
|
|
const PRAGMA = /(?:^|\s)per-copy-ok:\s*(\S.*)$/;
|
|
|
|
function walkSourceFiles(dir, out = []) {
|
|
if (!fs.existsSync(dir)) return out;
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walkSourceFiles(full, out);
|
|
continue;
|
|
}
|
|
// `.mts`/`.cts` as well as `.ts`: `@workflow/world-testing` is authored in
|
|
// `.mts`, and skipping those extensions made its sweep pass vacuously.
|
|
if (!/\.(ts|mts|cts)$/.test(entry.name)) continue;
|
|
if (/\.(test|spec)\.(ts|mts|cts)$/.test(entry.name)) continue;
|
|
if (/\.d\.(ts|mts|cts)$/.test(entry.name)) continue;
|
|
out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** `globalSingleton(...)`, including a namespaced `utils.globalSingleton(...)`. */
|
|
function isGlobalSingletonCall(node) {
|
|
if (!node) return false;
|
|
if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
|
|
return isGlobalSingletonCall(node.expression);
|
|
}
|
|
if (!ts.isCallExpression(node)) return false;
|
|
const callee = node.expression;
|
|
if (ts.isIdentifier(callee)) return callee.text === SINGLETON_HELPER;
|
|
if (ts.isPropertyAccessExpression(callee)) {
|
|
return callee.name.text === SINGLETON_HELPER;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Whether an initializer reaches `globalThis`, so the hand-rolled
|
|
* `const store = globalThis as …` / `const x = (globalThis[Key] ??= …)` shape is
|
|
* accepted alongside `globalSingleton()`. Both park the state off-module, which
|
|
* is the property this rule is actually checking for; `packages/core`'s step
|
|
* registry and the pattern documented for custom world authors in
|
|
* `docs/content/worlds/*\/building-a-world.mdx` are both written this way.
|
|
*/
|
|
function isGlobalThisBacked(node, aliases = new Set()) {
|
|
if (!node) return false;
|
|
if (ts.isIdentifier(node)) {
|
|
return node.text === 'globalThis' || aliases.has(node.text);
|
|
}
|
|
if (
|
|
ts.isAsExpression(node) ||
|
|
ts.isTypeAssertionExpression(node) ||
|
|
ts.isNonNullExpression(node) ||
|
|
ts.isParenthesizedExpression(node) ||
|
|
ts.isPropertyAccessExpression(node) ||
|
|
ts.isElementAccessExpression(node)
|
|
) {
|
|
return isGlobalThisBacked(node.expression, aliases);
|
|
}
|
|
if (ts.isBinaryExpression(node)) {
|
|
// `globalThis[Key] ??= {…}` and friends.
|
|
return (
|
|
isGlobalThisBacked(node.left, aliases) ||
|
|
isGlobalThisBacked(node.right, aliases)
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Names in this file that are themselves globalThis-backed, so a binding
|
|
* derived from one is too. The documented pattern takes two statements: an
|
|
* alias for `globalThis`, then the state read off it.
|
|
*/
|
|
function globalThisAliases(declared) {
|
|
const aliases = new Set();
|
|
for (const binding of declared.values()) {
|
|
if (isGlobalThisBacked(binding.declaration.initializer, aliases)) {
|
|
aliases.add(binding.name);
|
|
}
|
|
}
|
|
return aliases;
|
|
}
|
|
|
|
/**
|
|
* The identifier a member chain is rooted at, so `state.pools.set(…)` is
|
|
* recognized as a mutation of `state`.
|
|
*/
|
|
function rootIdentifier(node) {
|
|
let current = node;
|
|
while (
|
|
ts.isPropertyAccessExpression(current) ||
|
|
ts.isElementAccessExpression(current) ||
|
|
ts.isNonNullExpression(current) ||
|
|
ts.isParenthesizedExpression(current)
|
|
) {
|
|
current = current.expression;
|
|
}
|
|
return ts.isIdentifier(current) ? current.text : undefined;
|
|
}
|
|
|
|
/** A `// per-copy-ok: <reason>` comment directly above the declaration. */
|
|
function perCopyReason(statement, text) {
|
|
const ranges = ts.getLeadingCommentRanges(text, statement.getFullStart());
|
|
if (!ranges) return undefined;
|
|
for (const range of ranges) {
|
|
const match = PRAGMA.exec(text.slice(range.pos, range.end).trim());
|
|
if (match) return match[1].trim();
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Module-scope bindings in `source`, keyed by the name a mutation would be
|
|
* attributed to.
|
|
*
|
|
* Covers `const`/`let` statements and `static` class fields. A static field is
|
|
* module-scope state wearing a class as its namespace: `Registry.transports`
|
|
* duplicates per copy exactly like a top-level `const` would. Static fields are
|
|
* keyed `Class.field`, so a class with several of them yields one entry each and
|
|
* every finding names the field that is actually written.
|
|
*/
|
|
function collectDeclarations(source) {
|
|
const declared = new Map();
|
|
for (const statement of source.statements) {
|
|
if (ts.isVariableStatement(statement)) {
|
|
const isConst =
|
|
(statement.declarationList.flags & ts.NodeFlags.Const) !== 0;
|
|
for (const declaration of statement.declarationList.declarations) {
|
|
if (!ts.isIdentifier(declaration.name)) continue;
|
|
declared.set(declaration.name.text, {
|
|
name: declaration.name.text,
|
|
isConst,
|
|
declaration,
|
|
statement,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
if (!ts.isClassDeclaration(statement) || !statement.name) continue;
|
|
for (const member of statement.members) {
|
|
if (!ts.isPropertyDeclaration(member) || !ts.isIdentifier(member.name)) {
|
|
continue;
|
|
}
|
|
const isStatic = member.modifiers?.some(
|
|
(m) => m.kind === ts.SyntaxKind.StaticKeyword
|
|
);
|
|
if (!isStatic) continue;
|
|
// Keyed `Class.field`, which is what `memberPath` reads off a write like
|
|
// `Registry.transports.set(…)`. Keying on the bare class name would let a
|
|
// second static field overwrite the first, and would then attach one
|
|
// field's mutation to the other field's declaration.
|
|
const key = `${statement.name.text}.${member.name.text}`;
|
|
declared.set(key, {
|
|
name: key,
|
|
isConst: false,
|
|
declaration: member,
|
|
statement,
|
|
keyword: 'static',
|
|
});
|
|
}
|
|
}
|
|
return declared;
|
|
}
|
|
|
|
/**
|
|
* The class `this` refers to, when `this` *is* the class: inside a `static`
|
|
* member. Undefined inside an instance member, where `this` is an instance and
|
|
* the state it holds is per-instance rather than per-copy, and undefined inside
|
|
* a nested `function`, which rebinds `this`.
|
|
*/
|
|
function staticClassOf(node) {
|
|
for (let n = node.parent; n; n = n.parent) {
|
|
if (ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n)) {
|
|
return undefined;
|
|
}
|
|
const isMember =
|
|
ts.isMethodDeclaration(n) ||
|
|
ts.isPropertyDeclaration(n) ||
|
|
ts.isGetAccessorDeclaration(n) ||
|
|
ts.isSetAccessorDeclaration(n) ||
|
|
ts.isClassStaticBlockDeclaration(n);
|
|
if (!isMember) continue;
|
|
const isStatic =
|
|
ts.isClassStaticBlockDeclaration(n) ||
|
|
n.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword);
|
|
if (!isStatic) return undefined;
|
|
return ts.isClassDeclaration(n.parent) && n.parent.name
|
|
? n.parent.name.text
|
|
: undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* The `Root.field` prefix of a member chain, or undefined when there is no named
|
|
* first property. Lets a write to `Registry.transports.set(…)` be attributed to
|
|
* the static field `Registry.transports`, which `rootIdentifier` alone cannot
|
|
* distinguish from a write to any other static on the same class. `this.field`
|
|
* inside a static member resolves to the class, where `this` is the class.
|
|
*/
|
|
function memberPath(node) {
|
|
const segments = [];
|
|
let current = node;
|
|
while (
|
|
ts.isPropertyAccessExpression(current) ||
|
|
ts.isElementAccessExpression(current) ||
|
|
ts.isNonNullExpression(current) ||
|
|
ts.isParenthesizedExpression(current)
|
|
) {
|
|
if (ts.isPropertyAccessExpression(current)) {
|
|
segments.unshift(current.name.text);
|
|
} else if (ts.isElementAccessExpression(current)) {
|
|
// A computed key names no field, so the chain stops being addressable.
|
|
segments.unshift(undefined);
|
|
}
|
|
current = current.expression;
|
|
}
|
|
const root = ts.isIdentifier(current)
|
|
? current.text
|
|
: current.kind === ts.SyntaxKind.ThisKeyword
|
|
? staticClassOf(current)
|
|
: undefined;
|
|
if (!root || segments[0] === undefined) return undefined;
|
|
return `${root}.${segments[0]}`;
|
|
}
|
|
|
|
/** `x = …`, `x.field = …`, `x += …`. */
|
|
function assignment(node) {
|
|
if (
|
|
!ts.isBinaryExpression(node) ||
|
|
node.operatorToken.kind < ts.SyntaxKind.FirstAssignment ||
|
|
node.operatorToken.kind > ts.SyntaxKind.LastAssignment
|
|
) {
|
|
return undefined;
|
|
}
|
|
if (ts.isIdentifier(node.left)) {
|
|
return { name: node.left.text, reason: 'reassigned' };
|
|
}
|
|
if (
|
|
ts.isPropertyAccessExpression(node.left) ||
|
|
ts.isElementAccessExpression(node.left)
|
|
) {
|
|
return {
|
|
name: rootIdentifier(node.left),
|
|
target: node.left,
|
|
reason: 'field written',
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** `x++`, `--x`, `x.field++`. */
|
|
function increment(node) {
|
|
if (!ts.isPrefixUnaryExpression(node) && !ts.isPostfixUnaryExpression(node)) {
|
|
return undefined;
|
|
}
|
|
if (
|
|
node.operator !== ts.SyntaxKind.PlusPlusToken &&
|
|
node.operator !== ts.SyntaxKind.MinusMinusToken
|
|
) {
|
|
return undefined;
|
|
}
|
|
if (ts.isIdentifier(node.operand)) {
|
|
return { name: node.operand.text, reason: 'reassigned' };
|
|
}
|
|
// `state.count++` mutates just as much as `state.count += 1`, which
|
|
// `assignment` already reports.
|
|
if (
|
|
ts.isPropertyAccessExpression(node.operand) ||
|
|
ts.isElementAccessExpression(node.operand)
|
|
) {
|
|
return {
|
|
name: rootIdentifier(node.operand),
|
|
target: node.operand,
|
|
reason: 'field written',
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** `x.set(…)`, `x.items.push(…)`: a call that mutates its receiver. */
|
|
function mutatingCall(node) {
|
|
if (
|
|
!ts.isCallExpression(node) ||
|
|
!ts.isPropertyAccessExpression(node.expression) ||
|
|
!MUTATORS.has(node.expression.name.text)
|
|
) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
name: rootIdentifier(node.expression.expression),
|
|
target: node.expression.expression,
|
|
reason: `\`.${node.expression.name.text}()\``,
|
|
};
|
|
}
|
|
|
|
/** `delete x.field`. */
|
|
function deletion(node) {
|
|
if (
|
|
!ts.isDeleteExpression(node) ||
|
|
(!ts.isPropertyAccessExpression(node.expression) &&
|
|
!ts.isElementAccessExpression(node.expression))
|
|
) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
name: rootIdentifier(node.expression),
|
|
target: node.expression,
|
|
reason: 'field deleted',
|
|
};
|
|
}
|
|
|
|
/** How `node` changes a binding, if it changes one at all. */
|
|
function mutationIn(node) {
|
|
return (
|
|
assignment(node) ?? increment(node) ?? mutatingCall(node) ?? deletion(node)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* An empty collection literal: `new Map()`, `new Set()`, `[]`. A module-scope
|
|
* binding initialized to one and *exported* is a registry something fills, and
|
|
* the filling is often in another file, which this single-file walk cannot see.
|
|
* That is the shipped bug's exact shape, so the emptiness plus the export is
|
|
* treated as the signal. A non-empty initializer is a lookup table and is left
|
|
* alone.
|
|
*/
|
|
function isEmptyCollection(node) {
|
|
if (!node) return false;
|
|
if (ts.isArrayLiteralExpression(node)) return node.elements.length === 0;
|
|
if (!ts.isNewExpression(node) || !ts.isIdentifier(node.expression)) {
|
|
return false;
|
|
}
|
|
const collections = new Set(['Map', 'Set', 'WeakMap', 'WeakSet']);
|
|
if (!collections.has(node.expression.text)) return false;
|
|
return !node.arguments || node.arguments.length === 0;
|
|
}
|
|
|
|
function isExported(statement) {
|
|
return Boolean(
|
|
statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
|
|
);
|
|
}
|
|
|
|
const FUNCTION_LIKE = new Set([
|
|
ts.SyntaxKind.FunctionDeclaration,
|
|
ts.SyntaxKind.FunctionExpression,
|
|
ts.SyntaxKind.ArrowFunction,
|
|
ts.SyntaxKind.MethodDeclaration,
|
|
ts.SyntaxKind.Constructor,
|
|
ts.SyntaxKind.GetAccessor,
|
|
ts.SyntaxKind.SetAccessor,
|
|
]);
|
|
|
|
/**
|
|
* Record how `mutation` changes a declared binding, most specific key first:
|
|
* `Registry.transports` before `Registry`, so a class carrying several static
|
|
* fields attributes each write to the field that actually took it. Only the
|
|
* first sighting of a binding is kept, which is the one the finding cites.
|
|
*/
|
|
function recordMutation(mutation, declared, mutations) {
|
|
if (!mutation) return;
|
|
const path = mutation.target ? memberPath(mutation.target) : undefined;
|
|
for (const key of [path, mutation.name]) {
|
|
if (!key || !declared.has(key)) continue;
|
|
if (!mutations.has(key)) mutations.set(key, mutation.reason);
|
|
return;
|
|
}
|
|
}
|
|
|
|
function scanFile(file, repoRoot) {
|
|
const text = fs.readFileSync(file, 'utf8');
|
|
const source = ts.createSourceFile(
|
|
file,
|
|
text,
|
|
ts.ScriptTarget.ESNext,
|
|
/* setParentNodes */ true
|
|
);
|
|
|
|
const declared = collectDeclarations(source);
|
|
if (declared.size === 0) return [];
|
|
const aliases = globalThisAliases(declared);
|
|
|
|
/** key -> how it was first seen changing. */
|
|
const mutations = new Map();
|
|
// Only mutations inside a function body count. A write in a top-level
|
|
// statement runs once per copy at module evaluation and produces the same
|
|
// value in each, so a precomputed lookup table is not the hazard this rule
|
|
// is looking for; divergence needs a write that happens later, per request.
|
|
const visit = (node, inFunction) => {
|
|
if (inFunction) recordMutation(mutationIn(node), declared, mutations);
|
|
const nowInFunction = inFunction || FUNCTION_LIKE.has(node.kind);
|
|
ts.forEachChild(node, (child) => visit(child, nowInFunction));
|
|
};
|
|
visit(source, false);
|
|
|
|
const findings = [];
|
|
for (const [key, binding] of declared) {
|
|
const initializer = binding.declaration.initializer;
|
|
let how = mutations.get(key);
|
|
if (
|
|
!how &&
|
|
isExported(binding.statement) &&
|
|
isEmptyCollection(initializer)
|
|
) {
|
|
how = 'exported empty collection';
|
|
}
|
|
if (!how) continue; // never changes: one copy per layer is harmless
|
|
if (isGlobalSingletonCall(initializer)) continue;
|
|
if (isGlobalThisBacked(initializer, aliases)) continue;
|
|
if (perCopyReason(binding.statement, text)) continue;
|
|
|
|
const { line } = source.getLineAndCharacterOfPosition(
|
|
binding.declaration.getStart(source)
|
|
);
|
|
findings.push({
|
|
file: path.relative(repoRoot, file),
|
|
line: line + 1,
|
|
name: binding.name,
|
|
keyword: binding.keyword ?? (binding.isConst ? 'const' : 'let'),
|
|
reason: how,
|
|
});
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
/** Scan one package directory (the one holding its `package.json`). */
|
|
export function scanPackage(packageDir, repoRoot = process.cwd()) {
|
|
const findings = [];
|
|
for (const file of walkSourceFiles(path.join(packageDir, 'src'))) {
|
|
findings.push(...scanFile(file, repoRoot));
|
|
}
|
|
return findings.sort((a, b) =>
|
|
a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file)
|
|
);
|
|
}
|
|
|
|
export function formatFindings(findings) {
|
|
return findings
|
|
.map(
|
|
(f) =>
|
|
`${f.file}:${f.line} ${f.keyword} ${f.name} (${f.reason})\n` +
|
|
' A bundler keys module identity on (resource, layer), so once this\n' +
|
|
' package is bundled one process holds one copy of this module per\n' +
|
|
' layer. Next.js alone builds instrument, app-route, ssr and edge.\n' +
|
|
' This binding is therefore per-copy state, not a process singleton.\n' +
|
|
'\n' +
|
|
' Hold it on the World instance if it is per-World, or on globalThis\n' +
|
|
' via globalSingleton() from @workflow/utils if it is process-wide.\n' +
|
|
' If per-copy is what you want, say why:\n' +
|
|
' // per-copy-ok: <reason>\n' +
|
|
'\n' +
|
|
' Background: packages/utils/src/global-singleton.ts, and\n' +
|
|
' docs/content/worlds/v5/building-a-world.mdx#process-wide-state.'
|
|
)
|
|
.join('\n\n');
|
|
}
|
|
|
|
const invokedDirectly =
|
|
process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
|
|
|
if (invokedDirectly) {
|
|
const packages = process.argv.slice(2);
|
|
if (packages.length === 0) {
|
|
console.error(
|
|
'usage: node scripts/lint/module-scope-state.mjs <packageDir> [...]'
|
|
);
|
|
process.exit(2);
|
|
}
|
|
let total = 0;
|
|
for (const pkg of packages) {
|
|
const findings = scanPackage(pkg);
|
|
total += findings.length;
|
|
console.log(`\n${pkg}: ${findings.length}`);
|
|
if (findings.length > 0) console.log(formatFindings(findings));
|
|
}
|
|
console.log(`\nTOTAL ${total}`);
|
|
process.exit(total === 0 ? 0 : 1);
|
|
}
|