diff --git a/CLAUDE.md b/CLAUDE.md index d5fd05d..facf3f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ Consumer territories must apply per-call timeouts at instantiation OR rely on th - **Test environment:** Browser-dependent tests use `// @vitest-environment happy-dom` file-level comments. - **Identical build config:** All packages share the same `tsdown.config.ts` structure. - **No direct axios imports in dependent packages.** Route `AxiosResponse` / `AxiosRequestConfig` / sibling types through `fs-http`'s re-exports (e.g. `Parameters[0]` for response types). Direct `import type {AxiosResponse} from 'axios'` breaks rolldown's `d.cts` emission on dual-bundle packages — caught during `fs-cached-adapter-store` scaffold 2026-05-13. -- **No top-level side effects.** Every published package declares `"sideEffects": false` in its `package.json` so bundlers can tree-shake under deep imports. The factory + barrel pattern ensures this is structurally true — every package's `src/index.ts` is either a pure re-export or a file whose top-level statements are imports, type declarations, and `const`/`function` factory declarations. The manifest entry makes it explicit and bundler-actionable. Closes enforcement queue #70 (publint 0.3.21 Suggestion, fatal-promoted by `scripts/lint-pkg.mjs`). +- **No top-level side effects.** Every published package declares `"sideEffects": false` in its `package.json` so bundlers can tree-shake under deep imports. The factory + barrel pattern ensures this is structurally true — every package's `src/index.ts` is either a pure re-export or a file whose top-level statements are imports, type declarations, and `const`/`function` factory declarations. The manifest entry makes it explicit and bundler-actionable. Two enforcement layers: queue #70 (the `"sideEffects": false` flag itself — a publint 0.3.21 Suggestion, fatal-promoted by `scripts/lint-pkg.mjs`) and **queue #93** (the flag's *premise* — `scripts/lint-pkg.mjs` / `npm run lint:pkg` parses every package source file (`packages/*/src/**/*.ts`, test files excluded) with the TypeScript compiler API and asserts the top-level statement list is module-eval side-effect-free; CI fails on any bare top-level expression statement, specifier-less side-effect import, top-level control-flow statement, or `export default` of an evaluated expression). The flag is a bundler promise that unused modules can be dropped without observable consequence — #93 makes that promise enforced rather than a Level-4 doctrine note, so a future top-level `console.warn` / `Object.defineProperty` / prototype patch cannot land and then silently vanish at a consumer when its module is tree-shaken away. - **Transport-surface discipline.** Every `fs-http` transport method must inherit option-honoring from the `axios.create()` instance. Adding a new transport path that uses native `fetch` (or any non-axios transport) requires a deliberate audit against the full `HttpServiceOptions` matrix — `headers`, `withCredentials`, `withXSRFToken`, `smartCredentials`, `timeout`, plus the per-call `AxiosRequestConfig` override surface. The Library-Config-Honor Surface Audit (Sapper M3 + Surveyor M3, 2026-05-15) is the standing checklist. The pre-1.0 `streamRequest` function violated this rule on four axes (queue #22 streamRequest portion + queue #64 XSRF + Surveyor M3 F-1 headers + F-2 timeout) and was removed in 0.4.0 with zero realized consumer impact. If a future streaming use case emerges, the right design is either axios's `responseType: 'stream'` mode via the standard methods (inherits all options for free) or a deliberate `createStreamHttpService` factory designed against the option-honoring matrix from the start — not a re-add of an axios-bypassing transport. ### Internal Dependency Coordination diff --git a/scripts/lint-pkg.mjs b/scripts/lint-pkg.mjs index 0d9b6f5..c1b53fc 100644 --- a/scripts/lint-pkg.mjs +++ b/scripts/lint-pkg.mjs @@ -32,10 +32,31 @@ // corpus is a separate doctrine question). The declarations themselves // landed 2026-04-22 via commit 0605d99 — this gate prevents regression on // new packages and on edits that strip the field. +// +// 3. module-eval side-effect freedom — closes enforcement queue #93 (promotes +// the `sideEffects:false` manifest claim landed by queue #70 / PR #101 from +// an unenforced Level-4/Level-6 promise to a Level-1 gate). `sideEffects: +// false` is a PACKAGE-GLOBAL bundler promise: a consumer's bundler assumes +// NO module in the package has load-time side effects and may tree-shake any +// module whose exports are unused. If a future author adds a top-level effect +// (a bare `import './register'`, a module-eval `console.warn(...)`, an +// `Object.defineProperty(...)`, a prototype patch), the manifest still says +// `false`, the bundler drops the module, and the effect SILENTLY VANISHES at +// the consumer with zero gate signal. This check parses every package source +// module with the TypeScript compiler API (already a devDep — Gate 5 `tsc`, +// no new dependency) and asserts the top-level statement list contains only +// side-effect-free declaration kinds (imports WITH specifiers, re-exports, +// type/const/function/class declarations, and `export default` of a function +// or class). Any bare ExpressionStatement (call, assignment), specifier-less +// import, or top-level control-flow statement FAILS the gate. Scope is all +// `packages/*/src/**/*.ts` excluding test files — 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 assumption). import {spawnSync} from 'node:child_process'; import {readdirSync, readFileSync, statSync} from 'node:fs'; import path from 'node:path'; +import ts from 'typescript'; const PACKAGES_DIR = 'packages'; const ROOT_MANIFEST = 'package.json'; @@ -83,6 +104,129 @@ function checkEnginesNode(manifestPath, label) { return null; } +// --- queue #93: module-eval side-effect freedom --------------------------- + +// Test files are excluded — they legitimately contain top-level +// `describe`/`expect`/`vi.mock` expression statements and never ship to the +// registry. In this tree tests live in `packages/*/tests/` (siblings of +// `src/`), so the `src/**` walk already excludes them; the suffix/dir guards +// below are belt-and-suspenders in case a future package co-locates specs +// under `src/`. +const TEST_FILE_RE = /\.(spec|test)\.ts$/; +const TEST_DIR_RE = /(^|[/\\])(__tests__|tests?)([/\\]|$)/; + +// Recursively collect `.ts` source files under a package's `src/` dir, +// skipping declaration files and test files/dirs. +function listSourceFiles(srcDir) { + const out = []; + let entries; + try { + entries = readdirSync(srcDir, {withFileTypes: true}); + } catch { + return out; + } + for (const entry of entries) { + const full = path.join(srcDir, entry.name); + if (entry.isDirectory()) { + if (TEST_DIR_RE.test(`/${entry.name}/`)) { + continue; + } + out.push(...listSourceFiles(full)); + } else if (entry.isFile()) { + if (!entry.name.endsWith('.ts')) { + continue; + } + if (entry.name.endsWith('.d.ts')) { + continue; + } + if (TEST_FILE_RE.test(entry.name)) { + continue; + } + out.push(full); + } + } + return out; +} + +// Classify a top-level statement. Returns null if the statement is +// side-effect-free (permitted), or a short human-readable description of the +// offending construct if it is a module-eval side effect. +// +// Permitted at module top level: +// - import declarations WITH at least one specifier binding +// - export ... from / export * / export { ... } re-export declarations +// - export default of a function or class declaration +// - interface / type alias / enum / namespace (module) declarations +// - const / let / var variable declarations +// - function / class declarations +// Everything else — chiefly a bare ExpressionStatement (call / assignment) or +// top-level control flow (if/for/while/try/labeled) — is a side effect. +function classifyTopLevelStatement(node) { + switch (node.kind) { + case ts.SyntaxKind.ImportDeclaration: { + // A specifier-less import (`import './side-effect'`) has no + // importClause and exists solely for its load-time effect. + if (node.importClause === undefined) { + return "specifier-less side-effect import (`import '...'`)"; + } + return null; + } + case ts.SyntaxKind.ImportEqualsDeclaration: + case ts.SyntaxKind.ExportDeclaration: + case ts.SyntaxKind.InterfaceDeclaration: + case ts.SyntaxKind.TypeAliasDeclaration: + case ts.SyntaxKind.EnumDeclaration: + case ts.SyntaxKind.ModuleDeclaration: + case ts.SyntaxKind.VariableStatement: + case ts.SyntaxKind.FunctionDeclaration: + case ts.SyntaxKind.ClassDeclaration: + return null; + case ts.SyntaxKind.ExportAssignment: { + // `export default ` (ExportAssignment, isExportEquals=false). + // Only a function- or class-expression default is side-effect-free; + // `export default someCall()` evaluates at module load. + const expr = node.expression; + if ( + expr !== undefined && + (expr.kind === ts.SyntaxKind.FunctionExpression || + expr.kind === ts.SyntaxKind.ArrowFunction || + expr.kind === ts.SyntaxKind.ClassExpression) + ) { + return null; + } + return 'export default of an evaluated expression'; + } + case ts.SyntaxKind.ExpressionStatement: + return 'top-level expression statement (call / assignment evaluates at module load)'; + default: + return `top-level ${ts.SyntaxKind[node.kind] ?? 'statement'} (not a side-effect-free declaration)`; + } +} + +// Parse one source file and return an array of failure strings (one per +// offending top-level statement), or [] if the file is side-effect-free. +function checkSideEffectFreedom(filePath, label) { + const src = readFileSync(filePath, 'utf8'); + const sourceFile = ts.createSourceFile( + filePath, + src, + ts.ScriptTarget.Latest, + /* setParentNodes */ false, + ts.ScriptKind.TS, + ); + const fileFailures = []; + for (const statement of sourceFile.statements) { + const offense = classifyTopLevelStatement(statement); + if (offense !== null) { + const {line} = sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile)); + fileFailures.push( + `${label}: ${filePath}:${line + 1} — ${offense} (queue #93 — sideEffects:false requires module-eval side-effect freedom)`, + ); + } + } + return fileFailures; +} + function runCaptured(cmd, args, cwd, extraEnv) { const result = spawnSync(cmd, args, { cwd, @@ -123,6 +267,22 @@ function main() { process.stderr.write(` ${enginesFailure}\n`); } + // Module-eval side-effect freedom across every source file (queue #93). + const srcDir = path.join(dir, 'src'); + const sourceFiles = listSourceFiles(srcDir); + let sideEffectFailures = 0; + for (const filePath of sourceFiles) { + const fileFailures = checkSideEffectFreedom(filePath, name); + for (const f of fileFailures) { + failures.push(f); + process.stderr.write(` ${f}\n`); + sideEffectFailures += 1; + } + } + if (sideEffectFailures === 0) { + process.stdout.write(` ${name}: ${sourceFiles.length} source file(s) side-effect-free OK\n`); + } + // NO_COLOR=1 keeps publint's output plain regardless of runner color // settings; stripAnsi defends against any residual SGR codes so the // PUBLINT_BLOCK_RE verdict is identical in every environment (queue #63). @@ -149,7 +309,7 @@ function main() { } process.stdout.write( - `\nlint:pkg gate PASS — ${dirs.length} packages + root clean (engines.node present; publint suggestions/warnings/errors all treated as fatal).\n`, + `\nlint:pkg gate PASS — ${dirs.length} packages + root clean (engines.node present; publint suggestions/warnings/errors all treated as fatal; every package source module asserted module-eval side-effect-free per sideEffects:false, queue #93).\n`, ); }