NEO.K / MSSP 開源專案考古014-urlsearchparams
專案WHATWG URL Standard (URLSearchParams)
授權MIT (Node implementation); CC-BY (the standard)
檢視版本measured at run time
日期2026-08-14
來源upstream ↗

014 — URLSearchParams:兩個「單值」讀法,各留一端,都不說話

原專案 WHATWG URL Standard,在 Node 上的實作(MIT)。版本由執行時量出來。 這一則沒有重製任何原始碼,只對平台自己的 URLSearchParams 量行為。

node src/main.mjs            # 讀法、宣告驗證、mutator
node src/main.mjs --strict   # 宣告跟行為不一致就 exit 1
node src/island_test.mjs     # 26 項檢查,全部直接跑平台

為什麼選它

同日的範例 014 主張 arity 是契約的條款,不是 accessor 隨手決定的事。那就要問:這個決定在真實世界是在哪裡做的? 答案是每一個 JavaScript web 應用的請求路徑上,而且做決定的不是作者。

原專案的結構地圖

一個鍵三個值——瀏覽器在兩個 checkbox 同名時就會這樣送:

  tag=a&tag=b&tag=c&q=x

    get("tag")             "a"
    getAll("tag")          ["a","b","c"]
    Object.fromEntries     "c"
    has("tag")             true
    size                   4
    spread length          4

get 留第一個。Object.fromEntries 留最後一個。兩個都不說。

而且它們是同一個物件的兩個單值讀法,是最常被寫出來的兩種形狀。介面沒有任何地方可以問「你剛剛丟掉了什麼」——has() 說有、size 數的是 pair 不是 key,兩個都不碰多值這件事。

accessor 宣告 幾個活下來 留下什麼
get one 1 / 3 第一個
from_entries one 1 / 3 最後一個
get_all all 3 / 3 全部
set one 1 把所有值塌成一個
append all 4 加一個,不動其他

MSSP 重切

集合 裡面是什麼
FMS 五個 accessor、各自讓幾個值通過,以及 units 對照
SCL 這個部署拿哪一個 accessor 讀「它當成單值」的欄位
SMS 依 id 解析、用跑的量存活數,以及跑平台的探針
TMS 一個 accessor 一個檔——各自宣告留下什麼、幾個活下來,且不 import 任何東西
DMS 讀數、宣告對照行為的結果,以及看不到的部分

重切加的只有一件事:每個 accessor 宣告自己讓幾個值通過,而那份宣告用跑的驗。URLSearchParams 沒有這個——getObject.fromEntries 都是單值讀,形狀一樣、留的那一端相反,而介面不提供任何辦法區分。

什麼不適合拆

WHATWG 沒有做錯,不要「修」它。 一個 query string 本來就允許重複,所以一個單值 accessor 非選不可,而 get 就叫 get——它誠實地說了自己回傳一個值。

缺陷不在 get 回傳什麼,在於呼叫端把它讀成在回答另一個問題。這跟考古 011 的形狀相同:那裡也是同一個 API 兩種語義,而唯一分得出來的觀察沒有人會去看。

setappend 也不適合合併。 它們在呼叫端長得一模一樣、arity 相同,而在一個已經有兩個值的鍵上,set 留一個、append 留三個。那是兩個不同的操作,不是一個操作的兩種寫法。

這次沒有解決什麼

我自己的檢查先錯了一次,而且是這個實驗室追了兩週的那一族。 第一版把「all」的期望值寫死成 3,於是 append 被標成不一致——它保留了全部三個,然後又加了一個,所以是 4。錯的是檢查不是 accessor。改成從樣本量出基線之後五個都過,而且get 故意標成 all 仍然會紅!! get: declared all, measured 1),所以修法沒有把檢查弄鬆。

量得到但這次沒量: 重複的鍵有多常是意外而不是設計;有多少框架預設把 params 轉成普通物件(那一步就是 Object.fromEntries)。

這一則量不到: 任何一位呼叫端當初以為 get 是什麼意思。它量的是介面讓什麼通過,不是誰誤解了什麼。

重切原始碼

FMS

FMS/architecture.json
{
  "name": "014-urlsearchparams",
  "upstream": "WHATWG URL Standard, as implemented in Node",
  "examined_version": "measured at run time",
  "license": "MIT (Node); the standard itself is CC-BY",
  "what_it_is": "The object every web request passes through, and what each of its accessors does when one key carries more than one value.",

  "why_this_one": "Example 014 the same day argues that arity is a term of the contract rather than something an accessor decides. This is where that decision is actually made in practice, in the request path of essentially every JavaScript web application.",

  "accessors": {
    "get": {"survives": "one", "keeps": "the first value"},
    "get_all": {"survives": "all", "keeps": "every value"},
    "from_entries": {"survives": "one", "keeps": "the last value"},
    "set": {"survives": "one", "keeps": "collapses every value to one"},
    "append": {"survives": "all", "keeps": "adds without disturbing the others"}
  },

  "the_finding": "get and Object.fromEntries are both one-value reads of the same object and they keep OPPOSITE ends — get the first, fromEntries the last. Neither reports that anything was discarded, and the interface offers no way to ask. They are not two implementations of one rule; they are two rules with the same call shape.",

  "the_second_finding": "set and append look like siblings and are not. On a key already holding two values, set leaves one and append leaves three. Both return undefined, as do delete and a get for a key that is not there — so no operation here answers differently depending on how it went.",

  "sets": {
    "FMS": "this file: the accessors and how many values each lets through",
    "SCL": "which accessor this deployment uses for a field it treats as single-valued",
    "SMS": "accessor resolution by id, the survivor count measured by running it, and the upstream probes",
    "TMS": "one file per accessor — each declares what it keeps and how many survive, and imports nothing",
    "DMS": "the readings, the declaration checked against behaviour, and the gaps"
  },

  "units": {"TMS/accessors": ["append.mjs", "from_entries.mjs", "get.mjs", "get_all.mjs", "set.mjs"]},

  "non_goals": [
    "Saying the WHATWG design is wrong. A query string genuinely permits repetition, so a one-value accessor must choose, and `get` is named honestly for what it returns.",
    "Reimplementing URL parsing. Every number here comes from the platform's own URLSearchParams.",
    "Any claim about how often this bites anyone in production."
  ]
}

SCL

SCL/policy.json
{
  "accessor_used_for_a_single_value_field": "get",
  "a_one_value_accessor_on_a_multi_value_key_is_fatal": true,
  "_note": "main.mjs measures every accessor regardless of what this says."
}
SCL/policy.mjs
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const here = path.dirname(fileURLToPath(import.meta.url));
const config = JSON.parse(fs.readFileSync(path.join(here, "policy.json"), "utf8"));

export const accessor = () => config.accessor_used_for_a_single_value_field;
export const silentDiscardIsFatal = () => Boolean(config.a_one_value_accessor_on_a_multi_value_key_is_fatal);

SMS

SMS/accessors.mjs
// The re-cut: every accessor declares how many values survive it.
//
// URLSearchParams has no such declaration. `get` and `Object.fromEntries` are
// both one-value reads, they disagree about WHICH value, and the interface
// offers no way to ask either of them what it just discarded.
const NAMES = ["get", "get_all", "from_entries", "set", "append"];

export async function load() {
  const loaded = {};
  const problems = [];
  for (const name of NAMES) {
    const module = await import(`../TMS/accessors/${name}.mjs`);
    for (const attribute of ["ACCESSOR", "KEEPS", "HOW_MANY_SURVIVE"]) {
      if (!module[attribute]) problems.push(`${name} does not declare ${attribute}`);
    }
    if (!["one", "all"].includes(module.HOW_MANY_SURVIVE)) {
      problems.push(`${name}: HOW_MANY_SURVIVE must be one or all, not ${module.HOW_MANY_SURVIVE}`);
    }
    loaded[module.ACCESSOR] = module;
  }
  return { loaded, problems };
}

export function resolve(name, loaded) {
  const module = loaded[name];
  if (!module) {
    return { problem: `accessor "${name}" has no unit - fail closed (known: ${Object.keys(loaded).sort().join(", ")})` };
  }
  return { module };
}

// The declaration, checked by running it rather than by reading it.
export function survivorCount(name, query) {
  const params = new URLSearchParams(query);
  if (name === "get") return params.get("tag") === null ? 0 : 1;
  if (name === "get_all") return params.getAll("tag").length;
  if (name === "from_entries") return Object.fromEntries(params).tag === undefined ? 0 : 1;
  if (name === "set") { params.set("tag", "z"); return params.getAll("tag").length; }
  if (name === "append") { params.append("tag", "z"); return params.getAll("tag").length; }
  return null;
}
SMS/upstream.mjs
// What URLSearchParams actually does, measured against the running Node.
//
// Nothing here is exotic: a repeated key is what a browser sends when two
// checkboxes share a name, and every one of these accessors appears in ordinary
// request-handling code.
export const SAMPLE = "tag=a&tag=b&tag=c&q=x";

export function readings(query = SAMPLE) {
  const params = new URLSearchParams(query);
  return {
    "get(\"tag\")": params.get("tag"),
    "getAll(\"tag\")": params.getAll("tag"),
    "Object.fromEntries": Object.fromEntries(params).tag,
    "has(\"tag\")": params.has("tag"),
    size: params.size,
    "spread length": [...params].length,
  };
}

export function mutations(query = "tag=a&tag=b") {
  const withSet = new URLSearchParams(query);
  withSet.set("tag", "z");
  const withAppend = new URLSearchParams(query);
  withAppend.append("tag", "z");
  return { before: query, afterSet: withSet.toString(), afterAppend: withAppend.toString() };
}

export function returnValues() {
  const params = new URLSearchParams("k=v");
  return {
    "set(k,v)": new URLSearchParams().set("k", "v"),
    "append(k,v)": new URLSearchParams().append("k", "v"),
    "delete(k)": params.delete("k"),
    "get(missing)": new URLSearchParams().get("nope"),
  };
}

export function version() {
  return process.version;
}

TMS

TMS/accessors/append.mjs
// `append` — adds without disturbing the others.
export const ACCESSOR = "append";
export const KEEPS = "adds without disturbing the others";
export const HOW_MANY_SURVIVE = "all";
TMS/accessors/from_entries.mjs
// `from_entries` — the last value.
export const ACCESSOR = "from_entries";
export const KEEPS = "the last value";
export const HOW_MANY_SURVIVE = "one";
TMS/accessors/get.mjs
// `get` — the first value.
export const ACCESSOR = "get";
export const KEEPS = "the first value";
export const HOW_MANY_SURVIVE = "one";
TMS/accessors/get_all.mjs
// `get_all` — every value.
export const ACCESSOR = "get_all";
export const KEEPS = "every value";
export const HOW_MANY_SURVIVE = "all";
TMS/accessors/set.mjs
// `set` — collapses every value to one.
export const ACCESSOR = "set";
export const KEEPS = "collapses every value to one";
export const HOW_MANY_SURVIVE = "one";

DMS

DMS/report.mjs
export function readings(values, out) {
  out("\n  one key with three values, read every way ordinary code reads it:");
  for (const [label, value] of Object.entries(values)) {
    out(`    ${label.padEnd(22)} ${JSON.stringify(value)}`);
  }
}

// `original` is measured from the sample rather than written here. The first
// version hardcoded 3 for "all" and flagged `append` as a mismatch — append
// keeps all three AND adds one, so the check was wrong and the accessor was
// not. A hardcoded expectation is the defect this lab keeps filing.
export function declarations(loaded, measured, original, out) {
  out(`\n  ${"accessor".padEnd(14)} ${"declared".padEnd(10)} ${"survivors".padEnd(11)} keeps`);
  for (const name of Object.keys(loaded).sort()) {
    const module = loaded[name];
    const holds = module.HOW_MANY_SURVIVE === "one"
      ? measured[name] === 1
      : measured[name] >= original;
    const mark = holds ? " " : "!";
    out(`  ${mark} ${module.ACCESSOR.padEnd(12)} ${module.HOW_MANY_SURVIVE.padEnd(10)} `
      + `${String(measured[name]).padEnd(11)} ${module.KEEPS}`);
  }
}

export function gaps(out) {
  out("\n  measurable, not measured here:");
  out("    - how often a repeated key reaches a real handler by accident");
  out("    - how many frameworks convert params to a plain object by default");
  out("\n  not measurable by this entry at all:");
  out("    - whether the WHATWG design is wrong. A URL query genuinely permits");
  out("      repetition, so an accessor returning one value has to choose, and");
  out("      `get` is honestly named for what it returns.");
  out("    - what any particular caller believed `get` meant.");
}

root

island_test.mjs
// The island test.
//
//   node src/island_test.mjs
//
// Section 2 is the finding: two one-value accessors of the same object keep
// opposite ends and neither reports a choice.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import * as policy from "./SCL/policy.mjs";
import * as accessors from "./SMS/accessors.mjs";
import * as upstream from "./SMS/upstream.mjs";

const here = path.dirname(fileURLToPath(import.meta.url));
const ARCH = JSON.parse(fs.readFileSync(path.join(here, "FMS", "architecture.json"), "utf8"));
const failures = [];
const check = (label, ok, detail = "") => {
  process.stdout.write(`  ${ok ? "PASS" : "FAIL"}  ${label}${detail ? ` - ${detail}` : ""}\n`);
  if (!ok) failures.push(label);
};
const say = (line = "") => process.stdout.write(`${line}\n`);
const { loaded, problems } = await accessors.load();
const original = new URLSearchParams(upstream.SAMPLE).getAll("tag").length;
const survivors = Object.fromEntries(Object.keys(loaded).map((n) =>
  [n, accessors.survivorCount(n, upstream.SAMPLE)]));

say("\n== 1. every accessor is an island, declares itself, and FMS matches the tree");
check("all five accessors loaded with no problems", problems.length === 0, problems.join("; "));
const dir = path.join(here, "TMS", "accessors");
for (const file of fs.readdirSync(dir).filter((n) => n.endsWith(".mjs")).sort()) {
  const source = fs.readFileSync(path.join(dir, file), "utf8");
  const reaches = [...source.matchAll(/^\s*import[^"']*["']([^"']+)["']/gm)].map((m) => m[1]);
  check(`${file} imports nothing`, reaches.length === 0, reaches.join(", ") || "no imports at all");
}
for (const [where, expected] of Object.entries(ARCH.units)) {
  const onDisk = fs.readdirSync(path.join(here, ...where.split("/")))
    .filter((n) => n.endsWith(".mjs")).sort();
  check(`${where}: FMS declares ${expected.length}, on disk ${onDisk.length}`,
    JSON.stringify(onDisk) === JSON.stringify([...expected].sort()), onDisk.join(", "));
}

say("\n== 2. two one-value accessors, opposite ends, no complaint from either");
const r = upstream.readings();
check("get and Object.fromEntries both return exactly one value",
  typeof r['get("tag")'] === "string" && typeof r["Object.fromEntries"] === "string");
check("and they are DIFFERENT values",
  r['get("tag")'] !== r["Object.fromEntries"],
  `${JSON.stringify(r['get("tag")'])} vs ${JSON.stringify(r["Object.fromEntries"])}`);
check("get keeps the first", r['get("tag")'] === r['getAll("tag")'][0]);
check("Object.fromEntries keeps the last",
  r["Object.fromEntries"] === r['getAll("tag")'][r['getAll("tag")'].length - 1]);
check("and getAll shows what both discarded", r['getAll("tag")'].length === 3,
  JSON.stringify(r['getAll("tag")']));
check("nothing in the interface reports the discard",
  r['has("tag")'] === true && r.size === 4,
  "has() says yes and size counts pairs, not keys - neither mentions multiplicity");

say("\n== 3. each declaration is checked by running it, against a measured baseline");
for (const name of Object.keys(loaded).sort()) {
  const module = loaded[name];
  const holds = module.HOW_MANY_SURVIVE === "one"
    ? survivors[name] === 1 : survivors[name] >= original;
  check(`${name}: declared ${module.HOW_MANY_SURVIVE}, ${survivors[name]} of ${original} survive`, holds);
}
say("        The baseline is measured from the sample. An earlier version");
say("        hardcoded 3 for \"all\" and flagged `append` - which keeps all three");
say("        AND adds one. The check was wrong; the accessor was not.");
check("a mislabelled accessor would still be caught",
  (() => { const fake = { HOW_MANY_SURVIVE: "all" };
    return !(fake.HOW_MANY_SURVIVE === "one" ? survivors.get === 1 : survivors.get >= original); })(),
  "get declared all, 1 survives");

say("\n== 4. set and append are not siblings");
const m = upstream.mutations();
check("set collapses two values to one", m.afterSet === "tag=z", m.afterSet);
check("append leaves three", m.afterAppend.split("tag=").length - 1 === 3, m.afterAppend);
check("and the two calls look alike at the call site", true,
  "params.set(k, v) and params.append(k, v) - same arity, same shape, opposite semantics");

say("\n== 5. what answers the same however it went");
const rv = upstream.returnValues();
const undef = Object.entries(rv).filter(([, v]) => v === undefined).map(([k]) => k);
check("three mutators return undefined on every path", undef.length === 3, undef.join(", "));
check("and a missing key returns null, not undefined", rv["get(missing)"] === null,
  "so `undefined` never distinguishes a mutation from anything");

say("\n== 6. fail closed");
check("an unresolvable accessor stops the run",
  Boolean(accessors.resolve("params.first", loaded).problem),
  accessors.resolve("params.first", loaded).problem);
check("SCL names an accessor that exists", Boolean(loaded[policy.accessor()]), policy.accessor());

say("\n== 7. what this entry cannot see");
say("        MEASURABLE, NOT MEASURED");
say("          - how often a repeated key reaches a real handler by accident");
say("          - how many frameworks convert params to a plain object by default");
say("        NOT MEASURABLE HERE");
say("          - whether the WHATWG design is wrong. A query genuinely permits");
say("            repetition, so a one-value accessor must choose, and `get` is");
say("            named honestly for what it returns.");
say("          - what any particular caller believed `get` meant.");

say(failures.length ? `\n${failures.length} failure(s)` : "\nall checks passed");
for (const f of failures) say(`  - ${f}`);
process.exit(failures.length ? 1 : 0);
main.mjs
// URLSearchParams: what each accessor lets through.
//
//   node src/main.mjs            the readings, the declarations, the mutators
//   node src/main.mjs --strict   exit 1 if a declaration disagrees with behaviour
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import * as report from "./DMS/report.mjs";
import * as policy from "./SCL/policy.mjs";
import * as accessors from "./SMS/accessors.mjs";
import * as upstream from "./SMS/upstream.mjs";

const here = path.dirname(fileURLToPath(import.meta.url));
const ARCH = JSON.parse(fs.readFileSync(path.join(here, "FMS", "architecture.json"), "utf8"));

const out = (line = "") => process.stdout.write(`${line}\n`);
const { loaded, problems } = await accessors.load();
if (problems.length) {
  for (const problem of problems) out(`  !! ${problem}`);
  process.exit(1);
}

out(`\n== one key, three values  [node ${upstream.version()}]\n\n  ${upstream.SAMPLE}`);
report.readings(upstream.readings(), out);

out("\n  get keeps the FIRST. Object.fromEntries keeps the LAST. Neither says so.");

const measured = Object.fromEntries(Object.keys(loaded).map((name) =>
  [name, accessors.survivorCount(name, upstream.SAMPLE)]));
out("\n== each accessor's declaration, checked by running it");
const original = new URLSearchParams(upstream.SAMPLE).getAll("tag").length;
report.declarations(loaded, measured, original, out);

const wrong = Object.keys(loaded).filter((name) => loaded[name].HOW_MANY_SURVIVE === "one"
  ? measured[name] !== 1
  : measured[name] < original);

out("\n== set and append are not siblings");
const m = upstream.mutations();
out(`    before          ${m.before}`);
out(`    after set       ${m.afterSet}`);
out(`    after append    ${m.afterAppend}`);

out("\n== what answers the same however it went");
for (const [label, value] of Object.entries(upstream.returnValues())) {
  out(`    ${label.padEnd(16)} ${JSON.stringify(value)}`);
}

const { module, problem } = accessors.resolve(policy.accessor(), loaded);
if (problem) { out(`\n  !! ${problem}`); process.exit(1); }
out(`\n== this deployment reads a single-valued field with \`${module.ACCESSOR}\``);
out(`    which keeps ${module.KEEPS} and lets ${measured[module.ACCESSOR]} of 3 through`);

report.gaps(out);

if (process.argv.includes("--strict") && wrong.length) {
  for (const name of wrong) out(`\n  !! ${name}: declared ${loaded[name].HOW_MANY_SURVIVE}, measured ${measured[name]}`);
  process.exit(1);
}