Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResponseMiddlewareFunc>[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
Expand Down
162 changes: 161 additions & 1 deletion scripts/lint-pkg.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <expr>` (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,
Expand Down Expand Up @@ -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).
Expand All @@ -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`,
);
}

Expand Down