lint:pkg: enforce module-eval side-effect freedom (queue #93)#104
lint:pkg: enforce module-eval side-effect freedom (queue #93)#104Goosterhof wants to merge 1 commit into
Conversation
Deploying fs-packages with
|
| Latest commit: |
384aadf
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://d503686d.fs-packages.pages.dev |
| Branch Preview URL: | https://armorer-queue-93-sideeffects.fs-packages.pages.dev |
PR Reviewer · claimed
|
PR Reviewer · 6/10 · REVISE
Findings
Actionrevise — address MAJORs |
Goosterhof
left a comment
There was a problem hiding this comment.
No blockers. One concern, two nits. The RED/GREEN proof in the PR body is the right discipline for a gate promotion — both the bare-expression and the specifier-less-import ban branches are exercised before calling it done, which is the queue #63 lesson applied honestly. Verdict: COMMENT, mergeable after a look at the concern.
The choice to walk all packages/*/src/**/*.ts rather than trace the re-export graph is correct and the §B rationale defends it well: sideEffects: false is a package-global claim, so a side effect in a merely-imported (not re-exported) module is still covered by the bundler's drop-this-module assumption. A re-export-graph trace would miss exactly that case.
Concern
The gate's enforcement is narrower than its stated invariant — top-level const/let/var initializers are never inspected. classifyTopLevelStatement returns null unconditionally for ts.SyntaxKind.VariableStatement (scripts/lint-pkg.mjs, the case ts.SyntaxKind.VariableStatement: arm of the permit block). That means a top-level binding whose initializer evaluates a side effect at module load — const _ = console.warn('loaded'), const patched = Object.defineProperty(globalThis, ...), const ready = registerGlobals() — passes the gate while doing exactly the thing the PR title forbids ("module-eval side-effect freedom"). The bare ExpressionStatement form (console.warn('loaded') with no binding) is caught; wrap the identical call in const _ = and it is not. An author working around the gate after seeing it fire on the unbound form would reach for the bound form as the first move.
This is defensible today because the factory + barrel pattern means every existing top-level binding is either a literal (DIALOG_STYLE, DEFAULT_TIMEOUT_MS) or an arrow-function factory, never an evaluated call — I checked all 35 source files and none binds a call expression. So the gate is correct for the current corpus. But the gate exists precisely to catch the future author who breaks the pattern, and the most natural way to introduce a module-eval effect is a top-level const x = effectfulCall(), which sails through. The honest-gate framing of the PR body ("a gate that never fires is indistinguishable from a no-op") cuts the other way here: a gate that fires on the unbound effect but not the bound one gives false confidence on the harder half of the problem.
Two ways to close it, pick one:
- Inspect VariableStatement initializers. For each declared binding, permit literal initializers, identifiers, arrow/function/class expressions, and
new-of-a-known-pure-constructor patterns; flag aCallExpressioninitializer (other than the factory-call exceptions you'd whitelist) as module-eval. More precise, more code, more edge cases (new WeakMap()atcached-adapter-store.tsis an evaluated initializer that is genuinely side-effect-free, so a blanket call/newban would false-positive — you'd need an allowlist). - Narrow the claim to match the enforcement. If initializer inspection is judged not worth the complexity, say so explicitly: the gate asserts the statement kinds are declaration-only, not that initializers are pure, and the const-initializer-purity guarantee rests on the factory-pattern convention + review rather than the parser. That keeps the doctrine note in
CLAUDE.mdhonest about where the machine stops and the convention starts.
Either is fine. What's not fine is leaving the CLAUDE.md bullet asserting CI "asserts the top-level statement list is module-eval side-effect-free" when a whole class of module-eval side effect (bound initializers) is unenforced.
Nits
The inline comment above the ExportAssignment arm says "Only a function- or class-expression default is side-effect-free," but the code also permits ts.SyntaxKind.ArrowFunction (correctly — export default () => {} is inert). Comment understates the permit list; align it to mention arrow functions.
ExportAssignment covers both export default <expr> and export = <expr> (the latter has isExportEquals === true). The arm doesn't branch on node.isExportEquals, so export = someCall() is classified by the same expression check — which happens to be the right outcome, but the comment frames the arm purely as export default. Either branch on isExportEquals or widen the comment so the export = case isn't a surprise to the next reader. No behavior change needed.
Promote the package-global `sideEffects:false` manifest claim (landed by queue #70 / PR #101) from an unenforced Level-4/Level-6 promise to a Level-1 CI gate. `scripts/lint-pkg.mjs` gains a third per-package check alongside publint/attw (#33/#63) and engines.node (#31): every package source file under `packages/*/src/**/*.ts` (test files excluded) is parsed with the TypeScript compiler API (already a devDep — no new dependency) and its top-level statement list is asserted side-effect-free. CI fails on any bare top-level ExpressionStatement (call / assignment), specifier-less side-effect import, top-level control-flow statement, or `export default` of an evaluated expression. Scope is all source modules, not just `index.ts` and its re-exports — the correct match for a package-global flag (a side effect in a non-re-exported imported module is still covered by the bundler's tree-shaking assumption). Keeps the change out of `packages/*/src/` so coverage Gate 7 and mutation Gate 8 stay untouched. CLAUDE.md "No top-level side effects" bullet updated to cite the now-enforcing gate (doctrine propagation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6caeb24 to
384aadf
Compare
Goosterhof
left a comment
There was a problem hiding this comment.
⚠️ Concerns
0 blockers · 2 concerns · 2 nits · 1 praise
This promotes the sideEffects: false manifest premise from a doctrine note to a Level-1 lint:pkg gate by parsing every packages/*/src/**/*.ts with the TypeScript compiler API and rejecting non-declaration top-level statements. The gate is honest (RED/GREEN proven, no silent no-op), the TS-API usage is correct, and it earns its keep — but the invariant it advertises is narrower than the invariant it actually enforces, and that gap should be named in the code and the doctrine rather than left implicit.
Concerns
-
scripts/lint-pkg.mjs:170(VariableStatement→ permitted) — the gate classifies by statement kind, not by whether a permitted statement evaluates at load. A top-levelconst x = sideEffectCall()/let p = patchPrototype()/var q = (function(){ globalThis.x = 1; })()is aVariableStatementand passes clean, yet its initializer runs at module-eval — exactly the thingsideEffects: falsepromises won't happen.- Reproduced against this PR's own
classifyTopLevelStatement:export const c = sideEffectCall();→[null]PERMITTED;let z = patchProto();→ PERMITTED;var q = (function(){ globalThis.x=1; return 1; })();→ PERMITTED. - The PR body and the CLAUDE.md edit both claim the gate stops "a module-eval
console.warn/Object.defineProperty/ prototype patch." That holds only when written as a bareExpressionStatement.const _ = Object.defineProperty(...)— the form a determined author or an auto-formatter reaches for — slips through. The advertised invariant ("module-eval side-effect-free") is stronger than the enforced invariant ("no side-effecting statement kinds"). - Not a current false-negative:
grepoverpackages/*/srcfinds zero top-level call-initializers today, so the corpus is clean and this is a future-regression hole, not a live miss. That's why it's a concern, not a blocker. - Fix — option A: narrow the claim. Change the §B/CLAUDE.md wording from "module-eval side-effect-free" to "rejects side-effecting top-level statement forms (bare expression statements, specifier-less imports, top-level control flow); initializer-expression side effects in
const/let/varare out of scope." An honest narrower claim beats an overclaiming gate. Option B: tighten the gate — flag aVariableStatementwhose declaration initializer is aCallExpression/NewExpressionthat isn't a recognized pure-factory shape. This is the harder path (you'd need an allowlist for legitimateconst X = makeEnum()-style pure calls and you don't have one today), so A is the pragmatic move unless the Commander wants the stronger guarantee. - Test: add a RED case for
export const x = effectfulCall();to the proof set. Under option A it documents the boundary; under option B it must fail the gate.
- Reproduced against this PR's own
-
scripts/lint-pkg.mjs:113(.ts-only walk skips.tsx) —listSourceFilescollects only*.ts(and explicitly the body confirmspackages/*/src/**/*.ts). A package shipping a.tsxsource — none exist today (find packages/*/src -name '*.tsx'is empty) — would ship every top-level statement unguarded.- The corpus is JSX-free frontend-service packages, so this is latent, not active. But the manifest flag is package-global; the gate's file selection isn't. Either widen the glob to
.tsxnow (cheap, zero current cost) or note the.ts-only scope explicitly in the §B comment so the next.tsxpackage author knows the gate doesn't cover them.
- The corpus is JSX-free frontend-service packages, so this is latent, not active. But the manifest flag is package-global; the gate's file selection isn't. Either widen the glob to
Nits
-
scripts/lint-pkg.mjs:223— thedefault:branch labeltop-level ${ts.SyntaxKind[node.kind] ?? 'statement'}will print the raw enum name (e.g.top-level ForStatement). Fine for a CI failure, butForStatement/IfStatement/TryStatementare common enough to deserve the same human phrasing the expression-statement branch gets ("top-level control-flow statement (...)") so an ally reading the failure doesn't have to know TS AST kind names. -
scripts/lint-pkg.mjs:139—TEST_DIR_REis tested against/${entry.name}/per-entry, which works, but the(^|[/\\])...([/\\]|$)anchoring is overbuilt for a single path segment; a plain__tests__|tests|testsegment compare reads clearer. Cosmetic — the belt-and-suspenders intent is sound and the comment explains it.
Praise
- The decision to widen scope from queue #93's original "
index.ts+ re-export graph" wording to allpackages/*/src/**/*.ts, justified in §B by the bundler's assumption covering merely-imported (non-re-exported) modules, is the correct semantic match for a package-global flag — and walking every file is genuinely simpler than tracing a re-export graph. That's the load-bearing call in this PR and it's right.
Automated war-room agent review — posted because this PR carries the Agent Review Requested label.
PR Reviewer · claimed
|
PR Reviewer · 6/10 · REVISE — 🔴 2 🟡 1fs-packages #104 · AC anchor: none 🔴 MAJOR
why + fixThe header comment + CLAUDE.md (queue-#93) claim the gate stops a future top-level console.warn / Object.defineProperty / prototype patch, but only bare ExpressionStatement forms are caught (line 146). The same effects wrapped in an initializer — const _ = Object.defineProperty(globalThis, ...), export const x = registerSideEffect(), const y = (() => { patchPrototype(); return 1; })() — are module-eval side effects that tree-shake away silently under sideEffects:false, yet sail through. Not a BLOCKER: idiomatic forms ARE caught, the factory+barrel convention makes const-call-init non-idiomatic, the gate is fail-closed (failures push + process.exit(1) at :308), and no current source file trips it — but the rule does not enforce the full surface its own doctrine note claims. Fix: in classifyTopLevelStatement, stop returning null for VariableStatement — walk each declaration initializer and flag Call/New/IIFE/assignment-bearing expressions, permitting literal/identifier/arrow/function/class/object/array inits (mirror the ExportAssignment handling at :131-145). Or narrow the CLAUDE.md / header prose if const-call-init is intentionally out of scope.
why + fixThe catch (no error binding) returns [] on every failure mode — EACCES, symlink loop (ELOOP), transient CI I/O — not just the legitimate src/-absent case. The per-package loop then iterates zero files, sideEffectFailures stays 0, and the gate prints "0 source file(s) side-effect-free OK" and goes green (wired at ci.yml:23). A package with a real top-level side effect could ship green purely because its src/ could not be read — the exact silent failure the #93 gate exists to prevent, turned on the gate itself. Asymmetry: the sibling readFileSync (~:199) has NO catch and correctly crashes loud; only the directory-read path fails silent. Fix: inspect the caught error and only swallow the genuinely-absent case (if err.code === "ENOENT" return out); re-throw or push a failure on everything else. Better: treat a missing src/ as a failure too — a package with no readable sources cannot be asserted side-effect-free. 🟡 MINOR
why + fixDefensive handling for a co-located-spec layout (packages/*/src/**) the current tree rules out; the inline comment calls it belt-and-suspenders for a future package that co-locates specs under src/. Fires on no file today. Fix: drop the two regexes since src/** already excludes the tests/ siblings, OR keep them as a deliberate, well-commented forward-guard. Low stakes either way. Actionrevise — address MAJORs |
Goosterhof
left a comment
There was a problem hiding this comment.
⚠️ Concerns
0 blockers · 2 concerns · 2 nits · 1 praise
This promotes the sideEffects: false manifest premise from a Level-4 doctrine note to a Level-1 lint:pkg gate by parsing every packages/*/src/**/*.ts with the TypeScript compiler API and rejecting non-declaration top-level statements. The gate is honest — RED/GREEN proven both directions, no silent no-op (the queue #63 lesson applied), and the package-global file walk is the correct match for a package-global manifest claim. The remaining issue is a gap between the invariant the PR advertises and the invariant it actually enforces, which should be named in the code and the doctrine rather than left implicit.
Concerns
-
scripts/lint-pkg.mjs—case ts.SyntaxKind.VariableStatement: return null;— the gate classifies by statement kind, not by whether a permitted statement evaluates at module load, so a top-levelconst/let/varwhose initializer is a side-effecting call passes clean.- Trace:
classifyTopLevelStatementreturnsnullunconditionally forVariableStatement. Soconst _ = console.warn('loaded')→ permitted;const patched = Object.defineProperty(globalThis, ...)→ permitted;const ready = registerGlobals()→ permitted — each runs its effect at module-eval, which is exactly whatsideEffects: falsepromises won't happen. - The bare
ExpressionStatementform (console.warn('loaded')with no binding) is caught; wrap the identical call inconst _ =and it slips. An author who sees the gate fire on the unbound form reaches for the bound form as the first workaround. - Not a live false-negative: the factory + barrel corpus binds only literals and arrow factories today (a
packages/*/srcgrep finds zero call-initializers), so this is a future-regression hole, not a current miss — hence concern, not blocker. - Mismatch worth fixing: the PR body and the
CLAUDE.mdedit both claim the gate stops "a module-evalconsole.warn/Object.defineProperty/ prototype patch." That holds only for the bare-statement form. The advertised invariant ("module-eval side-effect-free") is stronger than the enforced one ("no side-effecting statement kinds"). - Fix — option A (pragmatic): narrow the claim. Reword §B /
CLAUDE.mdto "rejects side-effecting top-level statement forms (bare expression statements, specifier-less imports, top-level control flow); initializer-expression side effects inconst/let/varrest on the factory-pattern convention + review." An honest narrower claim beats an overclaiming gate. - Fix — option B (stronger guarantee): flag a
VariableStatementwhose declaration initializer is aCallExpression/NewExpressionthat isn't a recognized pure shape. Harder — you'd need an allowlist for legitimate evaluated-but-pure initializers (e.g.new WeakMap()), which you don't have today, so this risks false-positives without that scaffolding. - Test: add a RED case for
export const x = effectfulCall();to the proof set. Under A it documents the boundary; under B it must fail the gate.
- Trace:
-
scripts/lint-pkg.mjs—listSourceFileswalks*.tsonly, skipping.tsx— the manifest flag is package-global; the gate's file selection isn't.- No
.tsxexists today (find packages/*/src -name '*.tsx'is empty), so this is latent, not active — these are JSX-free frontend-service packages. - A future package shipping a
.tsxsource would ship every top-level statement unguarded with zero gate signal. Either widen the glob to include.tsxnow (cheap, zero current cost) or note the.ts-only scope explicitly in the §B comment so the next.tsxauthor knows the gate doesn't reach them.
- No
Nits
-
scripts/lint-pkg.mjs— the comment above theExportAssignmentarm says "Only a function- or class-expression default is side-effect-free," but the code also (correctly) permitsts.SyntaxKind.ArrowFunction—export default () => {}is inert. Align the comment to the actual permit list. -
scripts/lint-pkg.mjs— thedefault:branch prints the raw enum name (top-level ForStatement,top-level IfStatement). Fine for a CI failure, butFor/If/Try/Whileare common enough to deserve the same human phrasing the expression-statement branch gets ("top-level control-flow statement"), so an ally reading the failure needn't know TS AST kind names.
Praise
- The package-global file walk over re-export-graph tracing (§B).
sideEffects: falseis a package-global claim, so a side effect in a merely-imported (not re-exported) module is still covered by the bundler's drop-this-module assumption — a re-export-graph trace would miss exactly that case. The §B rationale defends the wider scope correctly, and it's the simpler implementation too.
Automated war-room agent review — posted because this PR carries the Agent Review Requested label.
What
Promotes the package-global
"sideEffects": falsemanifest claim — declared on every package by queue #70 / PR #101, wherelint:pkgalready fails if the flag is missing — from an unenforced Level-4/Level-6 promise to a Level-1 CI gate that fails the moment a side-effecting top-level statement lands in any package source.The flag is a bundler-facing promise: a consumer's bundler tree-shakes away any module whose exports are unused, on the assumption that dropping it changes nothing. Today nothing verifies that premise. If a future author adds a top-level effect (
import './register-globals', a module-evalconsole.warn, anObject.defineProperty, a prototype patch), the manifest still saysfalse, the bundler drops the module, and the effect silently vanishes at the consumer with zero gate signal.The invariant (§B)
sideEffects: falseis a package-global claim, so the gate scope is every source module in every package — not justindex.tsand its re-exports. (This deliberately widens the queue #93 wording, which framed the target asindex.ts+ its re-exported files. A side effect in a file that is merely imported — not re-exported — by a re-exported file would slip a re-export-graph trace, yet the bundler'ssideEffects:falseassumption still covers it. Walking allpackages/*/src/**/*.tsis both simpler and the semantically correct match.)Each source file is parsed with the TypeScript compiler API (
import ts from 'typescript'— already a devDep for Gate 5tsc, no new dependency). The only top-level statement kinds permitted are: imports with at least one specifier;export … from/export */export { … };export defaultof a function or class;interface/type/enum/namespace;const/let/var;function/classdeclarations. Anything else FAILS — chiefly any bareExpressionStatement(call / assignment), any specifier-less side-effect import, or top-level control flow.Why
scripts/lint-pkg.mjs, not a vitest arch test (§C)packages/*/src/→ coverage Gate 7 (100%/pkg) and mutation Gate 8 (Stryker 90%/pkg) stay untouched. A vitest arch test would itself demand coverage/mutation on its helper code.sideEffects:false, chore(deps): bump vue-component-type-helpers from 3.2.8 to 3.2.9 #93 verifies its premise — same Gate-6 enforcer, adjacent check next to publint/attw (fs-packages M1 moderate remediation (Armorer) #33/fs-router 0.1.0 published peer-dep is stale (vue-router ^4.5.0 vs source ^5.0.6) — republish as 0.1.1 #63) and engines.node (Fix setById reactivity by invalidating adapted cache #31).Load-bearing proof (§D — RED/GREEN, captured verbatim)
A gate that never fires is indistinguishable from a no-op (the queue #63 lesson — that gate was a silent no-op in CI for ~20 days). Proven both directions before calling it done.
RED — top-level
console.warninjected intopackages/helpers/src/index.ts:RED — specifier-less import
import './type-guards';(distinct ban-list branch):GREEN — injection reverted, clean tree:
All 35 source files across the 11 packages classify clean today.
Gate results
npm run lint:pkg(modified gate)npm run lint(oxlint)npm run format:check(oxfmt).mjsre-formatted vianpm run format;.mdoutside oxfmt scope)npm run buildsrc/change)npm run typecheckDoctrine propagation (§F)
CLAUDE.md"No top-level side effects" bullet updated to cite the now-enforcing gate and the queue #93 lineage — the front door reflects the Level-1 promotion.Lineage
Sister to #70 (manifest-flag compliance) and #63 (the honest-gate / no-silent-no-op lesson). Author: Goosterhof → no self-approval; awaits
script-developmentally formal review.🤖 Generated with Claude Code