diff --git a/CLAUDE.md b/CLAUDE.md index 7ce4912..d5fd05d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +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`). - **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/packages/adapter-store/package.json b/packages/adapter-store/package.json index cfc0b00..080652a 100644 --- a/packages/adapter-store/package.json +++ b/packages/adapter-store/package.json @@ -13,6 +13,7 @@ "dist" ], "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", diff --git a/packages/cached-adapter-store/package.json b/packages/cached-adapter-store/package.json index 0f57e47..a3e196e 100644 --- a/packages/cached-adapter-store/package.json +++ b/packages/cached-adapter-store/package.json @@ -13,6 +13,7 @@ "dist" ], "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", diff --git a/packages/dialog/package.json b/packages/dialog/package.json index 89bc59c..6a09e2a 100644 --- a/packages/dialog/package.json +++ b/packages/dialog/package.json @@ -13,6 +13,7 @@ "dist" ], "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", diff --git a/packages/helpers/package.json b/packages/helpers/package.json index 76b4918..152cf67 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -13,6 +13,7 @@ "dist" ], "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", diff --git a/packages/http/package.json b/packages/http/package.json index 5364a78..d9d66ca 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -13,6 +13,7 @@ "dist" ], "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", diff --git a/packages/loading/package.json b/packages/loading/package.json index 8948601..61d807a 100644 --- a/packages/loading/package.json +++ b/packages/loading/package.json @@ -13,6 +13,7 @@ "dist" ], "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", diff --git a/packages/router/package.json b/packages/router/package.json index dde314f..654e50a 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -13,6 +13,7 @@ "dist" ], "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", diff --git a/packages/storage/package.json b/packages/storage/package.json index 9655be7..9c28c4b 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -13,6 +13,7 @@ "dist" ], "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", diff --git a/packages/theme/package.json b/packages/theme/package.json index a29ae49..6bb7791 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -13,6 +13,7 @@ "dist" ], "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", diff --git a/packages/toast/package.json b/packages/toast/package.json index b1c2540..e54d379 100644 --- a/packages/toast/package.json +++ b/packages/toast/package.json @@ -13,6 +13,7 @@ "dist" ], "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", diff --git a/packages/translation/package.json b/packages/translation/package.json index c84758e..b9e45a4 100644 --- a/packages/translation/package.json +++ b/packages/translation/package.json @@ -13,6 +13,7 @@ "dist" ], "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", diff --git a/scripts/lint-pkg.mjs b/scripts/lint-pkg.mjs index 1c1339f..b7828d0 100644 --- a/scripts/lint-pkg.mjs +++ b/scripts/lint-pkg.mjs @@ -4,13 +4,26 @@ // 1. publint + attw — treats publint suggestions/warnings/errors as fatal. // publint 0.3.18 CLI does not expose a flag to fail on suggestions // (--strict only promotes warnings → errors). This wrapper fills that gap: -// it runs publint per workspace, captures stdout, and fails the gate if any -// package emits a "Suggestions:", "Warnings:", or "Errors:" block. -// attw --pack runs after publint per package and preserves its own exit code. +// it runs publint per workspace, captures stdout, strips ANSI SGR codes, +// and fails the gate if any package emits a "Suggestions:", "Warnings:", or +// "Errors:" block. attw --pack runs after publint per package and preserves +// its own exit code. // Motivated by enforcement queue #33 and the PR #35 regression: publint // suggestions about the "git+" URL prefix silently re-drifted across 10 // packages because the gate tolerated them. // +// ANSI invariance (enforcement queue #63): publint colors its block headers +// when it detects a color-capable environment (TTY or FORCE_COLOR). In CI, +// publint emitted ANSI-wrapped headers (e.g. "\x1b[1m\x1b[34mSuggestions:\x1b[39m\x1b[22m"), +// so PUBLINT_BLOCK_RE — anchored on a bare "Suggestions:" line — never matched +// and the gate silently no-op'd (false-NEGATIVE: a real Warning/Error block +// would have sailed through CI undetected). Verified against raw CI logs: +// the gate had been a no-op in CI since publint 0.3.21 landed 2026-05-11, +// while locally (plain-text, non-TTY) the regex matched and the gate fired +// correctly. Fix: spawn publint with NO_COLOR=1 AND strip residual ANSI from +// captured stdout before the regex match — belt-and-suspenders so the verdict +// is identical in every color environment (plain, TTY, FORCE_COLOR=1). +// // 2. engines.node presence — closes enforcement queue #31 (drift-prevention // gate, deployed 2026-05-12). Every workspace package.json AND the root // package.json must declare a non-empty `engines.node` string. Value is NOT @@ -27,6 +40,15 @@ import {join} from 'node:path'; const PACKAGES_DIR = 'packages'; const ROOT_MANIFEST = 'package.json'; const PUBLINT_BLOCK_RE = /^(Suggestions|Warnings|Errors):$/m; +// SGR / ANSI escape sequences (CSI ... final-byte). publint wraps its block +// headers in these when color is enabled (CI default, FORCE_COLOR), which +// otherwise defeats PUBLINT_BLOCK_RE's bare-line anchors. See header note, +// enforcement queue #63. +const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[a-zA-Z]`, 'g'); + +function stripAnsi(text) { + return text.replace(ANSI_RE, ''); +} function listPackageDirs() { return readdirSync(PACKAGES_DIR) @@ -61,8 +83,14 @@ function checkEnginesNode(manifestPath, label) { return null; } -function runCaptured(cmd, args, cwd) { - const result = spawnSync(cmd, args, {cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', shell: false}); +function runCaptured(cmd, args, cwd, extraEnv) { + const result = spawnSync(cmd, args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + shell: false, + env: extraEnv ? {...process.env, ...extraEnv} : process.env, + }); const stdout = result.stdout ?? ''; const stderr = result.stderr ?? ''; process.stdout.write(stdout); @@ -95,8 +123,11 @@ function main() { process.stderr.write(` ${enginesFailure}\n`); } - const publint = runCaptured('npx', ['publint', 'run'], dir); - const publintBlock = PUBLINT_BLOCK_RE.exec(publint.stdout); + // 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). + const publint = runCaptured('npx', ['publint', 'run'], dir, {NO_COLOR: '1'}); + const publintBlock = PUBLINT_BLOCK_RE.exec(stripAnsi(publint.stdout)); if (publint.status !== 0) { failures.push(`${name}: publint exited ${publint.status}`); } else if (publintBlock) {