fix: support static URL port and hash getters (#383)

- Lower URL.port and URL.hash through both native backends with Node-compatible values.
- Add differential and typed-project coverage, and update compatibility evidence and docs.

Fixes #372

Co-authored-by: Hagege Ruben <20857346+HagegeR@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-09-22 21:18:37 -05:00
committed by GitHub
co-authored by Hagege Ruben
parent 72ebac8659
commit e12856c1b4
20 changed files with 136 additions and 43 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ const results = await Promise.all(jobs); // number[] — compiles
- CommonJS module metadata (`module.id`, `filename`, `path`, `paths`, `loaded`, `isPreloading`, `parent`, `children`, `require.main`, and read-only `require.cache` lookup/enumeration) compiles natively. A compiled binary has a fixed module graph, so cache deletion/reloading, metadata writes, `module.paths` mutation, and `require.extensions` remain explicit refusals.
- The type checker sees the full standard library; only the supported surface compiles. Reaching declared-but-unlowered surface is SC2020 with the supported alternatives in the hint — e.g. parts of the regex API (`re.exec`), `Symbol`, `globalThis`, array/Map/Set methods beyond the lowered sets.
- `Date` values support zero-argument construction, one number/string argument, storage and passing, `getTime`/`valueOf`, `toISOString`, the local and UTC calendar getters, and `getTimezoneOffset`. `Date.parse(dateString)` accepts one string using the same bounded parser as `new Date(dateString).getTime()`. Date-armed unions, the year/month field constructor, setters, identity comparisons, throwing `Date` values, and locale/string formatters remain fenced; the supported string grammar is described below.
- `URL` supports construction from one absolute string and read-only `protocol`, `origin`, `username`, `pathname`, `href`, `host`, `hostname`, and `search` getters. `searchParams` remains a live view. Relative inputs with a base argument and setters remain fenced.
- `URL` supports construction from one absolute string and read-only `protocol`, `origin`, `username`, `pathname`, `href`, `host`, `hostname`, `port`, `search`, and `hash` getters. `searchParams` remains a live view. Relative inputs with a base argument and setters remain fenced.
- Numeric `process.exitCode = value` writes in statement position set the implicit exit status; `process.exit()` with no argument uses that status. Reading or resetting `process.exitCode`, and assignments used as values, remain fenced.
- Map keys and Set elements are strings and numbers; other key types are fenced.
@@ -2,6 +2,6 @@
"schemaVersion": 3,
"nodeVersion": "24.15.0",
"nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119",
"artifactVersion": "55356b7c091f54d5fd72",
"artifactVersion": "bbbe77bce52b68292e6d",
"rowCount": 3662
}
@@ -1178,9 +1178,9 @@
"apiEntries": 62,
"static": {
"supported": 0,
"partial": 3,
"partial": 5,
"refused": 0,
"not-implemented": 59,
"not-implemented": 57,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
@@ -82518,9 +82518,9 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"detail": "No static compiler lowering is registered for this API yet.",
"verification": "registry-gap"
"status": "partial",
"detail": "Implemented for the documented scriptc module-loader subset.",
"verification": "test-backed"
},
"dynamic": {
"status": "not-implemented",
@@ -82714,9 +82714,9 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"detail": "No static compiler lowering is registered for this API yet.",
"verification": "registry-gap"
"status": "partial",
"detail": "Implemented for the documented scriptc module-loader subset.",
"verification": "test-backed"
},
"dynamic": {
"status": "not-implemented",
@@ -8,8 +8,8 @@
"tiers": {
"static": {
"replace-refusal": 46,
"verify-gap": 2705,
"audit-partial": 461,
"verify-gap": 2703,
"audit-partial": 463,
"classify": 2,
"implement": 81
},
@@ -90987,12 +90987,14 @@
"priority": "high",
"tiers": {
"static": {
"status": "not-implemented",
"action": "verify-gap",
"verification": "registry-gap",
"confidence": "low",
"source": "compiler-unmatched:url",
"tests": []
"status": "partial",
"action": "audit-partial",
"verification": "test-backed",
"confidence": "high",
"source": "compiler-feature:url.hash",
"tests": [
"tests/corpus/2946-url-port-hash.ts"
]
},
"dynamic": {
"status": "not-implemented",
@@ -91218,12 +91220,14 @@
"priority": "high",
"tiers": {
"static": {
"status": "not-implemented",
"action": "verify-gap",
"verification": "registry-gap",
"confidence": "low",
"source": "compiler-unmatched:url",
"tests": []
"status": "partial",
"action": "audit-partial",
"verification": "test-backed",
"confidence": "high",
"source": "compiler-feature:url.port",
"tests": [
"tests/corpus/2946-url-port-hash.ts"
]
},
"dynamic": {
"status": "not-implemented",
@@ -1299,9 +1299,9 @@
"apiEntries": 62,
"static": {
"supported": 0,
"partial": 3,
"partial": 5,
"refused": 0,
"not-implemented": 59,
"not-implemented": 57,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
@@ -107770,7 +107770,8 @@
"status": "partial",
"evidence": "derived:descendants",
"tests": [
"tests/corpus/1794-searchparams-url-live.ts"
"tests/corpus/1794-searchparams-url-live.ts",
"tests/corpus/2946-url-port-hash.ts"
]
},
"dynamic": {
@@ -108091,8 +108092,11 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"evidence": "compiler-unmatched:url"
"status": "partial",
"evidence": "compiler-feature:url.hash",
"tests": [
"tests/corpus/2946-url-port-hash.ts"
]
},
"dynamic": {
"status": "not-implemented",
@@ -108280,8 +108284,11 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"evidence": "compiler-unmatched:url"
"status": "partial",
"evidence": "compiler-feature:url.port",
"tests": [
"tests/corpus/2946-url-port-hash.ts"
]
},
"dynamic": {
"status": "not-implemented",
@@ -109,6 +109,7 @@
{ "chapter": "stream", "signatures": ["`writable` Type: {boolean}"], "status": "partial", "evidence": ["tests/corpus/2893-child-stdin-duplex.ts", "tests/corpus/2895-child-stdin-write-after-end.ts"] },
{ "chapter": "stream", "signatures": ["Event: `'drain'`"], "status": "partial", "evidence": ["tests/corpus/2894-child-stdin-backpressure.ts"] },
{ "chapter": "webstreams", "signatures": ["`readableStream.values([options])`"], "status": "partial", "evidence": ["tests/corpus/2862-web-readable-async-iteration.ts"] },
{ "chapter": "url", "signatures": ["`hash` Type: {string}", "`port` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/2946-url-port-hash.ts"] },
{ "chapter": "util", "signatures": ["`util.promisify(original)`"], "status": "partial", "evidence": ["tests/corpus/1473-promisify-execfile.ts", "tests/corpus/1573-promisify-execfile-env-spread.ts", "tests/corpus/2912-promisify-fs-readfile.ts"] },
{ "chapter": "typescript", "symbols": ["type"], "status": "supported", "evidence": ["tests/corpus/2702-type-import-link-static/main.ts", "tests/corpus/2703-type-import-link-static-reexport/main.ts", "tests/corpus/2704-type-import-link-static-success/main.ts", "tests/corpus/2707-type-import-link-static-default-reexport/main.ts", "tests/harness/errors.test.ts"] }
],
+3 -1
View File
@@ -1370,7 +1370,7 @@ declare module "node:os" {
/* The WHATWG URL class (a Node global; the es2023 lib doesn't declare it),
* typed as exactly the supported surface: construction from ONE absolute-
* URL string (invalid input throws a catchable TypeError, like Node), the
* protocol/origin/username/pathname/href/host/hostname/search getters, searchParams (the
* protocol/origin/username/pathname/href/host/hostname/port/search/hash getters, searchParams (the
* LIVE query view — mutations through it re-serialize into the URL, so
* href reflects immediately; every read answers the same object, Node's
* caching), and toString() (the href serialization).
@@ -1388,7 +1388,9 @@ interface URL {
readonly href: string;
readonly host: string;
readonly hostname: string;
readonly port: string;
readonly search: string;
readonly hash: string;
readonly searchParams: URLSearchParams;
toString(): string;
}
+4
View File
@@ -4958,6 +4958,8 @@ function emitPathUrlLibCall(state: LibCallState): Temp {
return finish(`scr_url_host(${arg(0)})`);
case "url.hostname":
return finish(`scr_url_hostname(${arg(0)})`);
case "url.port":
return finish(`scr_url_port(${arg(0)})`);
case "url.pathname":
return finish(`scr_url_pathname(${arg(0)})`);
case "url.href":
@@ -4991,6 +4993,8 @@ function emitPathUrlLibCall(state: LibCallState): Temp {
return finish(`scr_url_search_params(${arg(0)})`);
case "url.search":
return finish(`scr_url_search(${arg(0)})`);
case "url.hash":
return finish(`scr_url_hash(${arg(0)})`);
case "sp.get": {
// `string | null` — the sym.desc pattern with a null arm: the
// runtime answers a +1 string or NULL.
@@ -328,9 +328,11 @@ export const LIB_FN_SYMS: Record<string, string> = {
"url.username": "scr_url_username",
"url.host": "scr_url_host",
"url.hostname": "scr_url_hostname",
"url.port": "scr_url_port",
"url.pathname": "scr_url_pathname",
"url.href": "scr_url_href",
"url.search": "scr_url_search",
"url.hash": "scr_url_hash",
"url.searchParams": "scr_url_search_params",
"url.fileURLToPathUrl": "scr_url_to_path",
"url.fileURLToPathStr": "scr_url_str_to_path",
@@ -3431,7 +3431,7 @@ function lowerPromiseThenPresence(
// a non-default port is present (scr_url_host — Node-exact,
// opaque-path URLs answer ""); `hostname` is the stored port-less
// host field verbatim, including IPv6 brackets.
if (name === "protocol" || name === "origin" || name === "username" || name === "pathname" || name === "href" || name === "host" || name === "hostname" || name === "search") {
if (name === "protocol" || name === "origin" || name === "username" || name === "pathname" || name === "href" || name === "host" || name === "hostname" || name === "port" || name === "search" || name === "hash") {
const receiver = lowerer.lowerExpr(expr.expression);
const fn =
name === "protocol"
@@ -3446,8 +3446,12 @@ function lowerPromiseThenPresence(
? "url.host"
: name === "hostname"
? "url.hostname"
: name === "port"
? "url.port"
: name === "search"
? "url.search"
: name === "hash"
? "url.hash"
: "url.href";
return { kind: "libCall", fn, args: [receiver], type: STRING, loc: locOf(expr) };
}
@@ -3464,7 +3468,7 @@ function lowerPromiseThenPresence(
lowerer.noLowering(
`URL.${name}`,
expr,
"protocol, origin, username, pathname, href, host, hostname, search, searchParams, and toString() are the supported URL members",
"protocol, origin, username, pathname, href, host, hostname, port, search, hash, searchParams, and toString() are the supported URL members",
lowerer.checker.getSymbolAtLocation(expr.name),
);
}
+2
View File
@@ -2259,6 +2259,7 @@ export type IrLibFn =
| "url.username"
| "url.host"
| "url.hostname"
| "url.port"
| "url.pathname"
| "url.href"
| "url.fileURLToPathUrl"
@@ -2293,6 +2294,7 @@ export type IrLibFn =
| "sp.with"
| "url.searchParams"
| "url.search"
| "url.hash"
| "sp.get"
| "sp.getAll"
| "sp.append"
+2
View File
@@ -294,6 +294,7 @@ export const LIB_FN_SIGS: Record<IrLibFn, { argTypes: (IrType | null)[]; result:
"url.username": { argTypes: [URL_T], result: STRING },
"url.host": { argTypes: [URL_T], result: STRING },
"url.hostname": { argTypes: [URL_T], result: STRING },
"url.port": { argTypes: [URL_T], result: STRING },
"url.pathname": { argTypes: [URL_T], result: STRING },
"url.href": { argTypes: [URL_T], result: STRING },
"url.fileURLToPathUrl": { argTypes: [URL_T], result: STRING },
@@ -309,6 +310,7 @@ export const LIB_FN_SIGS: Record<IrLibFn, { argTypes: (IrType | null)[]; result:
"sp.with": { argTypes: [SEARCH_PARAMS_T, STRING, STRING], result: SEARCH_PARAMS_T },
"url.searchParams": { argTypes: [URL_T], result: SEARCH_PARAMS_T },
"url.search": { argTypes: [URL_T], result: STRING },
"url.hash": { argTypes: [URL_T], result: STRING },
// Result is the interned `string | null` union — the libCall case
// checks the arms (the spawnRes.signal pattern).
"sp.get": { argTypes: [SEARCH_PARAMS_T, STRING], result: VOID },
@@ -6504,6 +6504,12 @@
],
"diags": []
},
"<repo>/tests/corpus/2946-url-port-hash.ts": {
"order": [
"<repo>/tests/corpus/2946-url-port-hash.ts"
],
"diags": []
},
"<repo>/tests/corpus/300-if-else.ts": {
"order": [
"<repo>/tests/corpus/300-if-else.ts"
@@ -9311,6 +9317,12 @@
"<repo>/tests/fixtures/node-types/stream-capture.ts"
],
"diags": []
},
"<repo>/tests/fixtures/node-types/url-getters.ts": {
"order": [
"<repo>/tests/fixtures/node-types/url-getters.ts"
],
"diags": []
}
}
}
+2
View File
@@ -2932,6 +2932,7 @@ ScrStr *scr_url_origin(ScrUrl *u); /* +1 "https://host" or "null" */
ScrStr *scr_url_username(ScrUrl *u); /* +1 encoded username, possibly empty */
ScrStr *scr_url_host(ScrUrl *u); /* +1 "host[:port]" (defaults stripped) */
ScrStr *scr_url_hostname(ScrUrl *u); /* +1 port-less host ("" when none) */
ScrStr *scr_url_port(ScrUrl *u); /* +1 "" or normalized non-default port */
ScrStr *scr_url_pathname(ScrUrl *u); /* +1 */
ScrStr *scr_url_href(ScrUrl *u); /* +1; also toString() */
ScrStr *scr_url_to_path(ScrUrl *u); /* +1, or throws */
@@ -2971,6 +2972,7 @@ void *scr_sp_retain_v(void *p);
void scr_sp_release_v(void *p);
ScrSearchParams *scr_url_search_params(ScrUrl *u); /* +1 live cached view */
ScrStr *scr_url_search(ScrUrl *u); /* +1 "?..." or "" */
ScrStr *scr_url_hash(ScrUrl *u); /* +1 "#..." or "" */
void scr_sp_append(ScrSearchParams *sp, ScrStr *name, ScrStr *value);
void scr_sp_set(ScrSearchParams *sp, ScrStr *name, ScrStr *value);
void scr_sp_delete(ScrSearchParams *sp, ScrStr *name);
+8
View File
@@ -653,6 +653,8 @@ ScrStr *scr_url_host(ScrUrl *u) {
* authority-less URLs); IPv6 literals retain their brackets. */
ScrStr *scr_url_hostname(ScrUrl *u) { return scr_str_retain(u->host); }
ScrStr *scr_url_port(ScrUrl *u) { return scr_str_retain(u->port); }
/* WHATWG origin is opaque for file and non-special schemes. A blob URL
* inherits the origin of an embedded http(s) URL when its path parses. */
ScrStr *scr_url_origin(ScrUrl *u) {
@@ -1032,3 +1034,9 @@ ScrStr *scr_url_search(ScrUrl *u) {
if (u->query->len <= 1) return scr_str_new("", 0);
return scr_str_retain(u->query);
}
/* A bare '#' remains in href but its hash getter is empty, like Node. */
ScrStr *scr_url_hash(ScrUrl *u) {
if (u->fragment->len <= 1) return scr_str_new("", 0);
return scr_str_retain(u->fragment);
}
+21
View File
@@ -0,0 +1,21 @@
const cases = [
"https://example.com:443/path#",
"https://example.com:8443/path#frag",
"http://example.com:00080/path",
"http://example.com:00081/a#x y",
"ws://example.com:80/path#x",
"wss://example.com:443/path#",
"ftp://example.com:21/file#f",
"file:///tmp/a#",
"data:text/plain,hi#a#b",
"foo://host:00042/p#%41",
"https://[::1]:8080/a#é",
];
for (const source of cases) {
const url = new URL(source);
console.log(`<${url.port}>`, `<${url.hash}>`, url.host, url.href);
}
const live = new URL("https://host.test:8443/path?q=1#frag");
live.searchParams.set("q", "2");
console.log(live.port, live.hash, live.search, live.href);
+4 -4
View File
@@ -39,12 +39,12 @@ import { win32 } from "path";
watchFile("x", () => {});
console.log(cpus().length);
console.log(win32.sep);
/* URL members beyond the supported getters (protocol/pathname/href/
* host/hostname/search/searchParams) fence member-qualified, with the
* supported list; searchParams and its method surface LOWER under
/* URL getters including port/hash lower under @types/node, but
* unimplemented members still fence by member name with the
* supported list; searchParams and its method surface lower under
* @types/node's declarations (provenance-mapped like URL itself). */
const u = new URL("https://example.com/x?a=1");
console.log(u.hash);
console.log(u.password);
u.searchParams.get("a");
/* The one-shot zlib/raw/gzip codecs lower for strings and Buffers;
* explicit options remain fenced, while Brotli remains a member-qualified
+4
View File
@@ -0,0 +1,4 @@
const nonDefault = new URL("https://example.com:8443/path#frag");
const defaultPort = new URL("https://example.com:443/path#");
console.log(nonDefault.port, nonDefault.hash);
console.log(`<${defaultPort.port}>`, `<${defaultPort.hash}>`, defaultPort.href);
@@ -75,18 +75,18 @@ fenced.ts:41:13 - error SC2020: 'PlatformPath.sep' is typed by @types/node but h
40 | console.log(cpus().length);
41 | console.log(win32.sep);
| ^~~~~~~~~
42 | /* URL members beyond the supported getters (protocol/pathname/href/
42 | /* URL getters including port/hash lower under @types/node, but
hint: the type checker sees everything @types/node declares, but only the supported surface compiles (https://scriptc.dev/limitations)
fenced.ts:47:13 - error SC2020: 'URL.hash' is typed by @types/node but has no scriptc lowering yet
fenced.ts:47:13 - error SC2020: 'URL.password' is typed by @types/node but has no scriptc lowering yet
46 | const u = new URL("https://example.com/x?a=1");
47 | console.log(u.hash);
| ^~~~~~
47 | console.log(u.password);
| ^~~~~~~~~~
48 | u.searchParams.get("a");
hint: protocol, origin, username, pathname, href, host, hostname, search, searchParams, and toString() are the supported URL members
hint: protocol, origin, username, pathname, href, host, hostname, port, search, hash, searchParams, and toString() are the supported URL members
fenced.ts:53:21 - error SC2020: 'deflateSync with explicit options' is part of the standard library types but has no scriptc lowering yet
+18
View File
@@ -88,6 +88,24 @@ test("node-types: the supported process surface lowers statically under @types/n
expect(stdout).toBe("2\nalpha\nbeta\nhi from env\nwritten without newline <- flushed in order\n");
});
test("node-types: URL port and hash getters lower statically under @types/node", async () => {
const outDir = outDirFor("node-url-getters");
const entry = join(nodeTypesDir, "url-getters.ts");
const result = await compile(entry, {
outPath: join(outDir, "url-getters"),
outDir,
sanitize,
});
expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true);
if (!result.ok) return;
const [native, node] = await Promise.all([
execFileAsync(result.binaryPath),
execFileAsync(process.execPath, [entry]),
]);
expect(native.stdout).toBe(node.stdout);
expect(native.stderr).toBe(node.stderr);
});
test("node-types: captured NodeJS.WritableStream values write through the procStream scalar", async () => {
const outDir = outDirFor("node-stream-capture");
const result = await compile(join(nodeTypesDir, "stream-capture.ts"), {