Enforce package boundaries #132

Merged
codinget merged 8 commits from t3code/avoid-internals-imports into main 2026-07-26 18:04:35 +02:00
Owner

Closes #107

Summary

  • replace cross-package _internals imports with owning-package public APIs
  • move shared length-prefixed wire encoding into @webnet/utils for SSH and SFTP
  • replace wildcard barrels with explicit named/type exports
  • remove cross-package re-exports across the workspace and import symbols from their owning packages
  • expose the loopback transport half as a public type instead of reconstructing it in consumers
  • enforce boundaries for static imports, dynamic imports, package re-exports, and relative source escapes with tested ESLint rules
  • document the _internals semver/test policy and add SSH public-surface typetests
  • keep malformed-auth wire coverage package-local to SSH while preserving the SFTP transport-cleanup regression test through public APIs
  • move shared path-helper coverage to VFS and leave protocol-specific coverage with FTP

Validation

  • node --test eslint.config.test.js
  • npm run lint
  • npm run format:check
  • affected-package tests and typechecks for utils, SSH, SFTP, transport, and HTTP
  • npm run typetest --workspace=packages/ssh
  • npm test --workspace=packages/ftp --workspace=packages/vfs
  • npm run build --workspace=packages/example-app

Review

  • addressed the Opus 5 review suggestions: shared cursor primitives, public loopback type, explicit example-app dependencies, VFS-owned path tests, removal of the empty CI retry commit, broader _internals enforcement, and normalized, invocation-independent relative package-escape enforcement, dedicated boundary-test CI reporting, and root config lint/format coverage
Closes #107 ## Summary - replace cross-package `_internals` imports with owning-package public APIs - move shared length-prefixed wire encoding into `@webnet/utils` for SSH and SFTP - replace wildcard barrels with explicit named/type exports - remove cross-package re-exports across the workspace and import symbols from their owning packages - expose the loopback transport half as a public type instead of reconstructing it in consumers - enforce boundaries for static imports, dynamic imports, package re-exports, and relative source escapes with tested ESLint rules - document the `_internals` semver/test policy and add SSH public-surface typetests - keep malformed-auth wire coverage package-local to SSH while preserving the SFTP transport-cleanup regression test through public APIs - move shared path-helper coverage to VFS and leave protocol-specific coverage with FTP ## Validation - `node --test eslint.config.test.js` - `npm run lint` - `npm run format:check` - affected-package tests and typechecks for utils, SSH, SFTP, transport, and HTTP - `npm run typetest --workspace=packages/ssh` - `npm test --workspace=packages/ftp --workspace=packages/vfs` - `npm run build --workspace=packages/example-app` ## Review - addressed the Opus 5 review suggestions: shared cursor primitives, public loopback type, explicit example-app dependencies, VFS-owned path tests, removal of the empty CI retry commit, broader `_internals` enforcement, and normalized, invocation-independent relative package-escape enforcement, dedicated boundary-test CI reporting, and root config lint/format coverage
codinget added 1 commit 2026-07-26 15:13:17 +02:00
refactor: enforce package boundaries
CI / lint (pull_request) Successful in 1m42s
CI / format (pull_request) Successful in 1m45s
CI / install (pull_request) Successful in 5m12s
CI / typetest (pull_request) Successful in 1m27s
CI / node-tests (pull_request) Failing after 1m44s
CI / typecheck (pull_request) Successful in 1m52s
CI / browser-tests (pull_request) Successful in 3m7s
997f4849ab
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
codinget added the
Agent
gpt-5.6-sol
4
Agentic
labels 2026-07-26 15:13:32 +02:00
codinget reviewed 2026-07-26 15:42:30 +02:00
codinget left a comment
Author
Owner

Autonomous review — package boundary enforcement

Reviewed the full diff against origin/main (3 commits). The mechanical part of this refactor holds up well, and CI is green on all seven jobs. No correctness issues found; everything below is a nit or a follow-up suggestion.

What I verified

  • No cross-package _internals imports or cross-package re-exports remain anywhere under packages/*/src, and no export * survives — so the two new ESLint rules genuinely have nothing left to catch.
  • The explicit barrels are faithful to the wildcards they replaced. I diffed every top-level export in packages/ssh/src/*.ts, packages/smb2/src/protocol/*.ts, crypto/*.ts and auth/*.ts against the new explicit lists in packages/ssh/src/_internals.ts, packages/smb2/src/protocol/index.ts and packages/smb2/src/_internals.ts: nothing was silently dropped.
  • packages/sftp/src/sftp/cursor.ts is semantically identical to the SSH cursor it replaces for the subset SFTP uses: same BinaryWriter/BinaryReader base, same big-endian config, same u32-length-prefixed string()/utf8(). The bounds checks the SFTP parsers rely on (Reader overrun RangeError) still come from BinaryReader#check, so malformed-packet behaviour is unchanged.
  • The auth-failure leak coverage really is preserved. The rewritten packages/sftp/src/server/server.test.ts test drives Session.run() through the connection === undefined branch of its finally, which is the same this.#transport.close() path the old malformed-parse test exercised, and it now asserts serverTransport.closed directly rather than inferring it from a rejected read. The malformed-parse case is preserved package-locally in packages/ssh/src/auth.test.ts.
  • Removed public re-exports break no in-repo consumer and no documented example: nothing imports RawTransport/RawListener/RawDialer/ReadBufferOptions from @webnet/http, nothing imports VFSError/AsyncVFS/Stat/VFSErrorCode from @webnet/sftp/@webnet/ftp/@webnet/drive/@webnet/smb2, and deleting packages/http/src/common/buffer.ts touches no entry in the exports map.

Suggestions

  1. packages/sftp/src/sftp/cursor.ts — duplicated wire cursor, silent drift risk. Writer.string, Reader.string and Reader.utf8 are now byte-for-byte copies of packages/ssh/src/cursor.ts. The two sides encode the same wire convention (u32 length prefix, over the same channel), so a future fix to one copy won't reach the other. Both packages already depend on @webnet/utils, which already owns BinaryReader/BinaryWriter — hosting the length-prefixed string/utf8 pair there would remove the duplication without reintroducing a cross-package _internals edge.

  2. packages/http/src/client/pool.test.ts:12ReturnType alias as a workaround. type LoopbackTransportHalf = ReturnType<typeof loopbackTransportPair>[number] works, but it exists only because the class is exported from @webnet/transport/loopback/_internals and not from the public ./loopback entry. Since ./loopback is deliberately cross-package test infrastructure, exporting LoopbackTransportHalf as a type from it and importing it normally would be more honest than reconstructing it structurally.

  3. packages/example-app imports undeclared workspace deps. src/components/Http.tsx now imports @webnet/transport, which isn't in packages/example-app/package.json (@webnet/http was already undeclared there before this PR). It resolves via workspace hoisting, so nothing breaks — but a PR whose whole point is making package boundaries explicit is the natural place to add both.

  4. Path-helper test table now lives in the wrong package. The table deleted from packages/sftp/src/sftp/paths.test.ts is byte-identical to the one still in packages/ftp/src/common/paths.test.ts, which after this change tests @webnet/vfs's resolvePath/parentPath/baseName from a consumer package. Net coverage is unchanged, but several traversal-clamping cases (resolvePath("/x", "//b//c"), resolvePath("/", ".."), empty-arg, trailing slash) exist only in packages/ftp and are missing from packages/vfs/src/path.test.ts. Folding those cases into the vfs suite and leaving only quotePath in the FTP file would put the coverage where the code lives.

  5. 8ae0d4b "ci: retry flaky node tests" is an empty commit. It changes no files, so either the intended CI change was lost or it was a rerun trigger with a misleading message. Either way, it'd be good to name which node test was flaky — retrying a flake tends to outlive the reason for it, and the test churn in this PR is in exactly the area (loopback SSH sessions, connection teardown) where a real race would hide.

  6. ESLint rule scope, optional. The two no-restricted-imports patterns enumerate one and two path segments; @webnet/*/**/_internals would cover arbitrary depth in one pattern. Separately, neither rule stops a deep relative escape (../../ssh/src/cursor.js) — there are none today, and adding a pattern for it would close the last hole in the invariant the README now documents.

Nothing here blocks merging.

## Autonomous review — package boundary enforcement Reviewed the full diff against `origin/main` (3 commits). The mechanical part of this refactor holds up well, and CI is green on all seven jobs. No correctness issues found; everything below is a nit or a follow-up suggestion. ### What I verified - **No cross-package `_internals` imports or cross-package re-exports remain** anywhere under `packages/*/src`, and no `export *` survives — so the two new ESLint rules genuinely have nothing left to catch. - **The explicit barrels are faithful to the wildcards they replaced.** I diffed every top-level `export` in `packages/ssh/src/*.ts`, `packages/smb2/src/protocol/*.ts`, `crypto/*.ts` and `auth/*.ts` against the new explicit lists in `packages/ssh/src/_internals.ts`, `packages/smb2/src/protocol/index.ts` and `packages/smb2/src/_internals.ts`: nothing was silently dropped. - **`packages/sftp/src/sftp/cursor.ts` is semantically identical to the SSH cursor it replaces** for the subset SFTP uses: same `BinaryWriter`/`BinaryReader` base, same big-endian config, same `u32`-length-prefixed `string()`/`utf8()`. The bounds checks the SFTP parsers rely on (`Reader overrun` `RangeError`) still come from `BinaryReader#check`, so malformed-packet behaviour is unchanged. - **The auth-failure leak coverage really is preserved.** The rewritten `packages/sftp/src/server/server.test.ts` test drives `Session.run()` through the `connection === undefined` branch of its `finally`, which is the same `this.#transport.close()` path the old malformed-parse test exercised, and it now asserts `serverTransport.closed` directly rather than inferring it from a rejected read. The malformed-parse case is preserved package-locally in `packages/ssh/src/auth.test.ts`. - **Removed public re-exports break no in-repo consumer and no documented example**: nothing imports `RawTransport`/`RawListener`/`RawDialer`/`ReadBufferOptions` from `@webnet/http`, nothing imports `VFSError`/`AsyncVFS`/`Stat`/`VFSErrorCode` from `@webnet/sftp`/`@webnet/ftp`/`@webnet/drive`/`@webnet/smb2`, and deleting `packages/http/src/common/buffer.ts` touches no entry in the `exports` map. ### Suggestions 1. **`packages/sftp/src/sftp/cursor.ts` — duplicated wire cursor, silent drift risk.** `Writer.string`, `Reader.string` and `Reader.utf8` are now byte-for-byte copies of `packages/ssh/src/cursor.ts`. The two sides encode the *same* wire convention (u32 length prefix, over the same channel), so a future fix to one copy won't reach the other. Both packages already depend on `@webnet/utils`, which already owns `BinaryReader`/`BinaryWriter` — hosting the length-prefixed `string`/`utf8` pair there would remove the duplication without reintroducing a cross-package `_internals` edge. 2. **`packages/http/src/client/pool.test.ts:12` — `ReturnType` alias as a workaround.** `type LoopbackTransportHalf = ReturnType<typeof loopbackTransportPair>[number]` works, but it exists only because the class is exported from `@webnet/transport/loopback/_internals` and not from the public `./loopback` entry. Since `./loopback` is deliberately cross-package test infrastructure, exporting `LoopbackTransportHalf` as a type from it and importing it normally would be more honest than reconstructing it structurally. 3. **`packages/example-app` imports undeclared workspace deps.** `src/components/Http.tsx` now imports `@webnet/transport`, which isn't in `packages/example-app/package.json` (`@webnet/http` was already undeclared there before this PR). It resolves via workspace hoisting, so nothing breaks — but a PR whose whole point is making package boundaries explicit is the natural place to add both. 4. **Path-helper test table now lives in the wrong package.** The table deleted from `packages/sftp/src/sftp/paths.test.ts` is byte-identical to the one still in `packages/ftp/src/common/paths.test.ts`, which after this change tests `@webnet/vfs`'s `resolvePath`/`parentPath`/`baseName` from a consumer package. Net coverage is unchanged, but several traversal-clamping cases (`resolvePath("/x", "//b//c")`, `resolvePath("/", "..")`, empty-arg, trailing slash) exist *only* in `packages/ftp` and are missing from `packages/vfs/src/path.test.ts`. Folding those cases into the vfs suite and leaving only `quotePath` in the FTP file would put the coverage where the code lives. 5. **`8ae0d4b "ci: retry flaky node tests"` is an empty commit.** It changes no files, so either the intended CI change was lost or it was a rerun trigger with a misleading message. Either way, it'd be good to name which node test was flaky — retrying a flake tends to outlive the reason for it, and the test churn in this PR is in exactly the area (loopback SSH sessions, connection teardown) where a real race would hide. 6. **ESLint rule scope, optional.** The two `no-restricted-imports` patterns enumerate one and two path segments; `@webnet/*/**/_internals` would cover arbitrary depth in one pattern. Separately, neither rule stops a deep *relative* escape (`../../ssh/src/cursor.js`) — there are none today, and adding a pattern for it would close the last hole in the invariant the README now documents. Nothing here blocks merging.
codinget marked the pull request as ready for review 2026-07-26 15:58:59 +02:00
codinget marked the pull request as work in progress 2026-07-26 16:13:21 +02:00
codinget added 4 commits 2026-07-26 16:13:25 +02:00
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
refactor: address package-boundary review
CI / format (pull_request) Successful in 1m38s
CI / lint (pull_request) Successful in 1m39s
CI / typecheck (pull_request) Canceled after 0s
CI / typetest (pull_request) Canceled after 0s
CI / node-tests (pull_request) Canceled after 0s
CI / browser-tests (pull_request) Canceled after 0s
CI / install (pull_request) Canceled after 4m25s
8451205d62
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
codinget force-pushed t3code/avoid-internals-imports from 6c3b499f38 to 8451205d62 2026-07-26 16:13:25 +02:00 Compare
codinget added 1 commit 2026-07-26 16:17:36 +02:00
fix: normalize relative package escapes
CI / format (pull_request) Successful in 1m47s
CI / lint (pull_request) Successful in 1m50s
CI / typecheck (pull_request) Canceled after 0s
CI / typetest (pull_request) Canceled after 0s
CI / node-tests (pull_request) Canceled after 0s
CI / browser-tests (pull_request) Canceled after 0s
CI / install (pull_request) Canceled after 2m20s
df27509467
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
codinget added 1 commit 2026-07-26 16:19:47 +02:00
fix: anchor package linting to repository
CI / lint (pull_request) Successful in 2m2s
CI / format (pull_request) Successful in 2m3s
CI / install (pull_request) Successful in 5m23s
CI / typecheck (pull_request) Successful in 1m26s
CI / typetest (pull_request) Successful in 1m38s
CI / node-tests (pull_request) Successful in 1m48s
CI / browser-tests (pull_request) Successful in 3m11s
668ae52dd0
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
codinget marked the pull request as ready for review 2026-07-26 16:28:35 +02:00
codinget reviewed 2026-07-26 17:14:15 +02:00
codinget left a comment
Author
Owner

Follow-up review — all six items addressed

Re-checked the six commits now on the branch against my earlier review. Every item is addressed correctly, CI is green on run 393, and I found no new defects. Details of what I verified independently of CI:

  • Cursor consolidation (item 1)LengthPrefixedBinaryWriter.string / LengthPrefixedBinaryReader.string/utf8 in packages/utils/src/binary.ts are byte-identical in behaviour to both copies they replace (u32 length prefix then bytes; bytes() still routes through BinaryReader#check, so the overrun RangeError the SSH and SFTP parsers depend on is intact). Writer.mpint/nameList in packages/ssh/src/cursor.ts now inherit string() and the chained return this.u32(...).bytes(...) is equivalent to the old two-statement form. Nice side effect: packages/utils/src/binary.test.ts had no test script and was never executed before this PR — it now runs under turbo, with new coverage for the length-prefixed pair including the overrun case.
  • LoopbackTransportHalf (item 2) — exported as a type from the public ./loopback entry and imported normally in pool.test.ts; the structural ReturnType alias is gone.
  • Undeclared deps (item 3)@webnet/http and @webnet/transport added to packages/example-app, in alphabetical position, and the lockfile is updated to match (CI npm ci passes).
  • Path coverage relocation (item 4) — checked case-by-case: packages/vfs/src/path.test.ts now carries the full 14-case resolvePath table plus the three assertions vfs already had, and the merged parentPath/baseName cases are a strict superset of both old files. Nothing was dropped in the move, and packages/ftp/src/common/paths.test.ts is correctly reduced to quotePath.
  • Empty commit (item 5) — gone after the rewrite; all seven commits now carry changes.
  • Lint rule scope (item 6) — I checked the globstar semantics against ESLint's actual matcher (no-restricted-imports feeds group to the ignore package): @webnet/**/_internals matches @webnet/ssh/_internals, @webnet/transport/loopback/_internals and deeper, and correctly does not match @webnet/ssh or @webnet/ssh/internals. The custom package-boundary/no-relative-source-escapes rule resolves the specifier before comparing owners, so ../../ssh/./src/… and ../../ssh/other/../src/… are caught too, and the switch from process.cwd() to a config-relative repositoryRoot in 668ae52 is the right fix — the added test that lints with cwd set to packages/sftp pins it. Testing the ESLint config with the real ESLint API is a good call, and npm test does run it in the node-tests job.

Remaining nits (non-blocking)

  1. eslint.config.test.js:7 — the top-level const filePath = "packages/sftp/src/package-boundary-check.ts" is dead; it's shadowed by the identically-valued default parameter on messages. Worth deleting. Note that root-level files are covered by neither npm run lint (eslint packages/*/src) nor npm run format:check (packages/*/src/**), so nothing will flag this or keep the file formatted.
  2. "test": "node --test eslint.config.test.js && turbo run test" — a failing config test now suppresses every package test result in the node-tests job. Running the config test after turbo run test, or as its own CI step, would keep the two signals independent.
  3. The config test's internals loop only exercises the one-segment depth (@webnet/ssh/_internals). Adding @webnet/transport/loopback/_internals would pin the globstar behaviour I verified by hand, which is the part of the pattern change most likely to regress silently.
  4. no-relative-source-escapes keys on parts[2] === "src", so a relative escape into another package's non-src directory (../../ssh/dist/index.js) isn't reported. There are none today, and dist isn't checked in, so this is theoretical.

Looks ready to me.

## Follow-up review — all six items addressed Re-checked the six commits now on the branch against my earlier review. Every item is addressed correctly, CI is green on run 393, and I found no new defects. Details of what I verified independently of CI: - **Cursor consolidation (item 1)** — `LengthPrefixedBinaryWriter.string` / `LengthPrefixedBinaryReader.string`/`utf8` in `packages/utils/src/binary.ts` are byte-identical in behaviour to both copies they replace (`u32` length prefix then bytes; `bytes()` still routes through `BinaryReader#check`, so the overrun `RangeError` the SSH and SFTP parsers depend on is intact). `Writer.mpint`/`nameList` in `packages/ssh/src/cursor.ts` now inherit `string()` and the chained `return this.u32(...).bytes(...)` is equivalent to the old two-statement form. Nice side effect: `packages/utils/src/binary.test.ts` had no `test` script and was never executed before this PR — it now runs under turbo, with new coverage for the length-prefixed pair including the overrun case. - **`LoopbackTransportHalf` (item 2)** — exported as a type from the public `./loopback` entry and imported normally in `pool.test.ts`; the structural `ReturnType` alias is gone. - **Undeclared deps (item 3)** — `@webnet/http` and `@webnet/transport` added to `packages/example-app`, in alphabetical position, and the lockfile is updated to match (CI `npm ci` passes). - **Path coverage relocation (item 4)** — checked case-by-case: `packages/vfs/src/path.test.ts` now carries the full 14-case `resolvePath` table plus the three assertions vfs already had, and the merged `parentPath`/`baseName` cases are a strict superset of both old files. Nothing was dropped in the move, and `packages/ftp/src/common/paths.test.ts` is correctly reduced to `quotePath`. - **Empty commit (item 5)** — gone after the rewrite; all seven commits now carry changes. - **Lint rule scope (item 6)** — I checked the globstar semantics against ESLint's actual matcher (`no-restricted-imports` feeds `group` to the `ignore` package): `@webnet/**/_internals` matches `@webnet/ssh/_internals`, `@webnet/transport/loopback/_internals` and deeper, and correctly does *not* match `@webnet/ssh` or `@webnet/ssh/internals`. The custom `package-boundary/no-relative-source-escapes` rule resolves the specifier before comparing owners, so `../../ssh/./src/…` and `../../ssh/other/../src/…` are caught too, and the switch from `process.cwd()` to a config-relative `repositoryRoot` in `668ae52` is the right fix — the added test that lints with `cwd` set to `packages/sftp` pins it. Testing the ESLint config with the real ESLint API is a good call, and `npm test` does run it in the `node-tests` job. ### Remaining nits (non-blocking) 1. `eslint.config.test.js:7` — the top-level `const filePath = "packages/sftp/src/package-boundary-check.ts"` is dead; it's shadowed by the identically-valued default parameter on `messages`. Worth deleting. Note that root-level files are covered by neither `npm run lint` (`eslint packages/*/src`) nor `npm run format:check` (`packages/*/src/**`), so nothing will flag this or keep the file formatted. 2. `"test": "node --test eslint.config.test.js && turbo run test"` — a failing config test now suppresses every package test result in the `node-tests` job. Running the config test after `turbo run test`, or as its own CI step, would keep the two signals independent. 3. The config test's internals loop only exercises the one-segment depth (`@webnet/ssh/_internals`). Adding `@webnet/transport/loopback/_internals` would pin the globstar behaviour I verified by hand, which is the part of the pattern change most likely to regress silently. 4. `no-relative-source-escapes` keys on `parts[2] === "src"`, so a relative escape into another package's non-`src` directory (`../../ssh/dist/index.js`) isn't reported. There are none today, and `dist` isn't checked in, so this is theoretical. Looks ready to me.
codinget marked the pull request as work in progress 2026-07-26 17:49:29 +02:00
codinget added 1 commit 2026-07-26 17:50:40 +02:00
test: tighten package-boundary checks
CI / format (pull_request) Successful in 1m58s
CI / lint (pull_request) Successful in 2m0s
CI / install (pull_request) Successful in 5m26s
CI / typetest (pull_request) Successful in 1m27s
CI / node-tests (pull_request) Successful in 1m48s
CI / typecheck (pull_request) Successful in 1m49s
CI / browser-tests (pull_request) Successful in 3m10s
2cabbf50f1
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
codinget marked the pull request as ready for review 2026-07-26 18:00:36 +02:00
codinget merged commit 2cabbf50f1 into main 2026-07-26 18:04:35 +02:00
codinget deleted branch t3code/avoid-internals-imports 2026-07-26 18:04:35 +02:00
Sign in to join this conversation.