Skip to content

feat(cli): say so when a port is read as something other than what the text says (#12674) - #12692

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-12674-port-text-read-notice
Aug 27, 2026
Merged

feat(cli): say so when a port is read as something other than what the text says (#12674)#12692
os-litant merged 1 commit into
mainfrom
claude/issue-12674-port-text-read-notice

Conversation

@os-litant

@os-litantos-litant commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes#12674

os serve reads its port with parseInt, and parseInt's tolerance changes the
answer, not merely the spelling: --port 3e3 binds port 3, --port 0x0BB8
binds 3000, --port 3000abc binds 3000. The boot succeeds, on a port the operator
never named, and nothing anywhere says so. That silence is what this repairs.

The accept set is untouched. Option C, per the ruling on this card: narrowing a
published CLI's accepted input is a contract decision and is deliberately left open;
serve-port-validation.test.ts's anti-narrowing table stays green, byte for byte, and
no changeset states a FROM/TO. Nothing is refused here, and nothing binds differently.

 ⚠ PORT="3e3" was read as port 3.
That text is not a plain decimal number, and the reader that accepts it
is tolerant: it honours a leading 0x as hexadecimal and discards
everything from the first character that cannot continue the number.
Nothing downstream reads it again — 3 is the port this server asked
for, whatever the text looks like.
If that is not the port you meant, correct PORT in this process's
environment (for example PORT=3000), or override it with --port 3000.

The boundary, re-measured here

The notice fires on a difference, so what counts as agreement is the whole
precision of the card. Re-measured on this checkout with node -e, Node v22.22.2 —
parsed is parseInt(raw), unchanged:

inputparseIntstrict reading of the trimmed textnotice?
300030003000no
" 3000"30003000no
"3000 "30003000no
+300030003000no
0808080808080no
3e33yes
1e101yes
0x0BB83000yes
3000.03000yes
3000abc3000yes
0b1110yes
0o170yes
1_0001yes

Every number above agrees with the numbers on the card. Three of those rows were not
on it and are new here: 0b111 and 0o17 each select port 0 — a kernel-assigned
port, from text a reader would call 7 and 15 — and 1_000 selects port 1.

The line is drawn on the trimmed text, and that half is load-bearing in the noise
direction: " 3000" is the shape an ordinary production PORT has, and a boundary
that counted whitespace would print a notice at every boot of every such deployment.
A leading + and leading zeros are not differences either. Everything else is,
because it means the port was not read off the digits.

⛔ The boundary is notNumber(), which is the near-miss worth naming: it agrees
with parseInt on 0x0BB8 (both 3000 — measured), so a boundary built on it would be
blind to a hex literal, one of the two coercions this exists to see.

Three-way discrimination (serve-port-text-read-notice.test.ts, 10 arms)

  1. mismatch → a notice naming both the text and the port it selected;
  2. agreementportTextReadNotice() returns null, at runtime, at the same seam
    — so an implementation that printed unconditionally cannot pass. The decision lives
    inside the exported function for exactly that reason, and the call site is one
    if, pinned lexically;
  3. not a port at all (abc, 99999, "", -1, 65536) → [finding] os serve --port abc is never validated — parseInt yields NaN and the boot dies on a raw ERR_SOCKET_BAD_PORT #12662's refusal
    owns it, and the guard exits the process above this call site, so the two can never
    both fire. Pinned both ways (parseRequestedPort returns null; the exit is
    lexically upstream of the call site).

Plus mutual exclusion against all three sibling notices in both directions, with every
pattern exercised as a live instrument — including the two that are
PORT_TAKEN_PATTERNS in test/helpers/serve-process.ts, where a false match would
report a healthy boot as a lost port race. Zero sockets, zero spawns.

Ablation — three mutations, each anchored, each proven on disk by anchored counts
plus a git hash-object comparison before the verdict was read, each restored with
git checkout HEAD -- naming an ABSOLUTE path, under a trap … EXIT INT TERM, and
proven back by blob hash and an empty git diff HEAD:

mutationresult
delete the agreement early-out (notice becomes unconditional)1 failed / 9 passed
drop the .trim()2 failed / 8 passed
swap the decimal boundary for Number()2 failed / 8 passed

The subject is imported relatively (./serve.js → the sibling TypeScript source, not a
package exports path), so no dist/ sits in the resolution path and no rebuild leg
applies; the mutations going red without one is itself the evidence that the source is
what runs.

Reuse, and what is deliberately not done

Verification

All of the below ran on 8d6f70499, the branch head, through
scripts/pm/os-verify-lock.sh; exit codes captured before any pipe.

  • vitest run src/ (@objectstack/cli, the whole non-spawner unit surface) — 98 files, 1144 tests passed
  • vitest run test/serve-stdio-stdout-purity.e2e.test.ts test/serve-port-drift-notice.e2e.test.ts test/serve-port-readback.e2e.test.ts test/serve-port-bind-probe.test.ts4 files, 25 tests passed
  • pnpm --filter @objectstack/cli typecheck — clean, and tsc --listFiles confirms
    both edited files are in the program (this package excludes no tests)
  • pnpm lint (eslint . --no-inline-config, whole repo) — exit 0
  • The dispatch-gates.mjs families for this change set, all green:
    check:nul-bytes, check:changeset-gate-self-tests, check:cross-package-test-inputs,
    check:i18n, check:i18n-coverage (OK (12 config(s), 602 baselined untranslated string(s), none new)), check:objectql-double-limit, check:objectui-changeset,
    check:page-declaration-shape, check:pm-half-states, check:published-files,
    check:route-envelope, check:slot-lookup, check:test-source-alias,
    check:type-source-resolution, check:query-options-erasure,
    check:type-check-coverage, check:type-check-debt
    (--re-measure: OK — 31 ledger entr(ies) … none above its recorded number),
    check:engine-double-contract, check:where-matcher, check-adr-0087-registration,
    check-changeset-no-major, check-empty-changeset, check-ci-filter-parity,
    check-comment-mask-adoption, check-plugin-teardown-shape,
    release-rehearsal-clone --self-test, docs-audit/check-affected-docs
  • scripts/pm/check-half-states.mjsexit 3, NOT MEASURED (no GitHub credential in
    the gate's environment; its own words: "Nothing was swept … it is no reading at
    all"
    ). Not a red.

Changeset: minor for @objectstack/cli — new operator-facing output on a published
package, the grade #12620 and #12662 each took for the same shape.

Generated by Claude Code

…e text says (#12674)
`os serve` reads its port with `parseInt`, whose tolerance changes the answer
rather than the spelling: `--port 3e3` binds port 3, `--port 0x0BB8` binds 3000,
`--port 3000abc` binds 3000. The boot succeeds on a port the operator never
named and nothing says so.
The accept set is untouched — narrowing a published CLI's accepted input is a
contract decision, left open on purpose. Only the silence is repaired: a notice
naming the text that was set and the port it selected, printed when a strict
reading of the trimmed text does not name that port.
Whitespace, a leading `+` and leading zeros are not differences (`" 3000"` is
what production PORT values look like); an exponent, a radix prefix, a fraction,
a separator and trailing text are. The source spelling is shared with #12662's
refusal so one input is named one way, and the notice goes to stderr like every
other `os serve` diagnostic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 5 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 07e64656593afb7c1915f5c97ea2e1587ef3b003.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 07e64656593afb7c1915f5c97ea2e1587ef3b003packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6a85b65b942669e607107884ebcbcb58e9c9f5a8 — the merge of head 8d6f70499f9fbbcfb2f852d7d4796b4e7952d312 into base 07e64656593afb7c1915f5c97ea2e1587ef3b003, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6a85b65b942669e607107884ebcbcb58e9c9f5a8 && git checkout 6a85b65b942669e607107884ebcbcb58e9c9f5a8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 07e64656593afb7c1915f5c97ea2e1587ef3b003 8d6f70499f9fbbcfb2f852d7d4796b4e7952d312 && git checkout -B drift-repro 07e64656593afb7c1915f5c97ea2e1587ef3b003 && git merge --no-ff 8d6f70499f9fbbcfb2f852d7d4796b4e7952d312
node scripts/docs-audit/affected-docs.mjs --json 07e64656593afb7c1915f5c97ea2e1587ef3b003

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 07e64656593afb7c1915f5c97ea2e1587ef3b003 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 27, 2026
@os-litant
os-litant marked this pull request as ready for review August 27, 2026 09:54
@os-litant
os-litant added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 2805e52Aug 27, 2026
37 checks passed
@os-litant
os-litant deleted the claude/issue-12674-port-text-read-notice branch August 27, 2026 10:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] os serve --port 3e3 silently binds port 3 — parseInt's tolerance means a port can be accepted as a value the operator never named

2 participants

@os-litant@claude