NEO.K / MSSP 開源專案考古018-object-freeze
專案ECMAScript Object.freeze and the two language modes
授權BSD-3-Clause (V8, the implementation measured); MIT (Node.js)
檢視版本measured at run time
日期2026-08-18
來源upstream ↗

018 — Object.freeze:宣告「不可變」的人,沒有辦法說違反它代表什麼

上游是 ECMAScript 的 Object.freeze 與兩個語言模式, 實作為 V8(BSD-3-Clause),跑在 Node.js(MIT)。版本由執行時量出來。 這一則不需要任何 I/O——一個凍結物件、一次賦值,全部在本機重現。

node src/main.mjs            # 同一次賦值,兩個模式
node src/main.mjs --strict   # 這個部署可能吞掉違反就 exit 1
node src/island_test.mjs     # 34 項檢查,全部直接跑真的內建函式,數字由它自己印

為什麼選它

同日的範例 018 主張一份宣告到底幫了誰,是讀它的人決定的

Object.freeze 是這句話在語言本體裡的樣子:宣告者說「不可變」,而「違反它會怎樣」完全由消費者決定,宣告者既說不出來也看不到。

原專案的結構地圖

同一個凍結物件,同一行賦值:

    object        mode     returned   actual   threw
    frozen        sloppy   999        100      -
    frozen        strict   -          100      TypeError: Cannot assign to read only property 'price'
    never frozen  sloppy   999        999      -
    never frozen  strict   999        999      -

第一列是重點:運算式求值為 999,物件裡還是 100。

const v = (frozen.price = 999);   // v === 999
frozen.price;                     // 100

讀運算式的人被告知寫入成功,讀物件的人被告知失敗。 同一次寫入,兩個答案。

對照組是第三、四列——一個從來沒被凍結的物件也回傳 999。所以 returned 不是「錯的」,是沒有資訊:它在成功與安靜失敗兩種情況下一模一樣。沒有這個對照組,「sloppy 回傳 999」不構成任何主張。

第二個發現:宣告者根本沒有被給那個欄位

  Object.freeze.length = 1

一個參數,就是那個物件。沒有第二個參數說違反代表什麼。 而兩個模式的入口是不同的語法構造(module/class body/明示指示詞 對上 Function 建構子/eval/傳統 script),不是一個可以傳進去的設定——所以物件連自己被誰讀了都不知道。

這正是範例 018 的那句話:方向不是宣告的性質,是宣告加上消費它的政策的性質。 那一則的修法是把假設寫進 SCL 讓建置去量;Object.freeze 連寫的地方都沒有。

第三個發現:宣告比它讀起來的窄

    frozen({ inner: { price: 100 } }), then inner.price = 999
    under both modes the nested value is now 999

淺凍結。 對內層物件的寫入在 strict 模式下也不會拋——這一次不是模式的問題,是宣告本身涵蓋的範圍比它的名字小。

MSSP 重切

集合 裡面是什麼
FMS 每個模式回不回報違反、怎麼進去,以及 units 對照
SCL 這個部署用哪個模式載入消費者、安靜的違反在這裡是不是致命的,以及它伸不到哪裡
SMS 直接跑真內建函式的探針,包含那個從未凍結的對照物件
TMS 一個模式一個檔——各自宣告回不回報違反怎麼進去,而且 import 任何東西都沒有
DMS returnedactualthrew 三欄永遠一起印——任何一欄單獨看都是同一個問題的不同答案

重切加的只有一件事:模式要宣告它回不回報違反,而那份宣告用跑的驗。 第 3b 節是鑽孔——一個宣稱回報、實際吞掉的模式必須被抓到。四個變異跑過,每一個都讓套件變紅,包含「對照組自己也被凍結」。

SCL 這裡多了一欄值得記:what_this_cannot_reach。凍結不會跟著物件走進一個 Function 建構子,所以部署可以規定自己怎麼載入消費者,規定不了別人怎麼把值再遞出去。一份說得出自己伸不到哪裡的政策,比一份宣稱全面的政策誠實。

什麼不適合拆

凍結是對的。 兩個模式底下物件都保住了原值——Object.freeze 做到了它說的那件事。

sloppy 模式也不能事後修掉。 它就是「沒有標記的程式碼」的意思,而那是為了不弄壞既有的網頁。ES modules 一律 strict,正是承認了這件事而且只在新的入口上改。

缺陷不在任何一邊,在於違反的後果被定義在消費者那一側,而宣告者連問都問不到

這次沒有解決什麼

量得到但這次沒量: 真實程式碼裡有多少 Object.freeze 的消費者其實在 sloppy 邊界後面;Object.isFrozen 有多少呼叫端真的讀。

這一則量不到: 任何一位宣告者當初以為凍結會保證什麼。它量的是介面讓什麼通過,不是誰誤解了什麼——跟考古 015、016、017 同一句話。

沒有做的: Object.sealpreventExtensions

Proxy 順手量了一下,因為它是同一個語言裡把後果搬回宣告側的例子——量出來是一半:

    proxy invariant, sloppy read : TypeError      不變量違反,兩個模式都拋
    proxy invariant, strict read : TypeError
    proxy set trap false, sloppy : no throw       trap 自己回 false,照模式走
    proxy set trap false, strict : TypeError

不變量get 對一個 non-configurable non-writable 屬性回不同的值)不管消費者是哪個模式都拋——那確實是把後果搬回宣告側。但 set trap 自己回 false 的時候,行為跟普通賦值一模一樣。所以「Proxy 解決了這件事」是錯的:它只在引擎自己要維護的那組不變量上解決了,作者寫進 trap 的拒絕仍然由消費者決定意義。要把這個做成完整的一則需要新的探針,不是重讀既有輸出。

重切原始碼

FMS

FMS/architecture.json
{
  "name": "018-object-freeze",
  "upstream": "ECMAScript Object.freeze and the two language modes, measured on the local V8",
  "what_is_being_examined": "One declaration - this object is immutable - and two consumers that disagree about what violating it means.",

  "the_finding": "Object.freeze takes no argument about what happens when the declaration is violated. The declarer cannot state it and cannot see it. A sloppy-mode consumer gets no error, and the assignment expression still evaluates to the value that was not stored, so a caller reading the expression is told the write succeeded while the object says it did not. A strict-mode consumer gets a TypeError for the same line.",

  "second_finding": "The freeze is shallow, so `frozen.inner.x = 2` succeeds under BOTH modes. The declaration reads as a statement about the object and is a statement about its own properties.",

  "the_control": "The same assignment against an object that was never frozen. It also returns 999 - which is what makes the returned value uninformative rather than merely wrong. Without the control, 'sloppy mode returns 999' is not evidence about anything.",

  "modes": {
    "sloppy": {"reports_violations": false, "how_it_is_entered": "a Function constructor, an eval, a classic script - none of which the declarer chose"},
    "strict": {"reports_violations": true,  "how_it_is_entered": "a module, a class body, or an explicit directive"}
  },

  "sets": {
    "FMS": "this file: what each mode reports, how it is entered, and the units map",
    "SCL": "which mode this deployment loads consumers in, what a silent violation means here, and what the deployment cannot reach",
    "SMS": "the probes that run the real built-ins, including the never-frozen control",
    "TMS": "one file per mode - each declares whether it reports violations, and imports nothing",
    "DMS": "what was returned, what the object holds, and whether anything was raised - never one of the three alone"
  },

  "units": {"TMS/modes": ["sloppy.mjs", "strict.mjs"]},

  "the_recut_adds": "A mode declares whether it reports violations, and the declaration is checked by running it. Section 3b is the drill: a mode claiming to report violations while swallowing them must be caught."
}

SCL

SCL/policy.json
{
  "deployment": "config-loader",
  "mode": "strict",
  "a_silent_violation_is": "fatal",
  "why": "A configuration object handed to plugins is frozen so a plugin cannot edit it for everyone else. Under a sloppy-mode caller that freeze is decoration: the write does nothing, nothing is raised, and the assignment expression still evaluates to the value that was not stored. This deployment loads every consumer as a module so the mode is not optional.",
  "what_this_cannot_reach": "A consumer that runs the value through a sloppy-mode boundary the loader did not create - a Function constructor, an eval, a classic script tag. The freeze does not travel with the object into it.",
  "not_a_general_rule": "A best-effort defensive freeze on an object nobody was going to write to is fine either way. SCL is where the position lives."
}

SMS

SMS/upstream.mjs
// Probes that run the real built-ins. Nothing here is simulated.
export const runtime = () => `${process.release?.name ?? "node"} ${process.version} (V8 ${process.versions.v8})`;

export const frozen = () => Object.freeze({ price: 100 });

// The control: the same shape, never frozen. It is what makes the returned
// value uninformative rather than merely wrong — both come back 999.
export const neverFrozen = () => ({ price: 100 });

export const nested = () => Object.freeze({ inner: { price: 100 } });

export function through(mode, target, key = "price", value = 999) {
  return { mode: mode.MODE, ...mode.assign(target, key, value) };
}

// Whether a mode reported the violation at all, measured rather than read off
// the unit's own declaration.
export function reportsViolations(mode) {
  return through(mode, frozen()).threw !== null;
}

// What Object.freeze was given the chance to say about violations: nothing.
// It takes one argument, the object, and there is no second one.
export const freezeArity = () => Object.freeze.length;

TMS

TMS/modes/sloppy.mjs
// Sloppy mode, reached the way real code reaches it: a Function constructor.
//
// It imports nothing. It declares that it does NOT report violations, and the
// declaration is verified by running it (island test section 3b).
export const MODE = "sloppy";
export const REPORTS_VIOLATIONS = false;
export const HOW_IT_IS_ENTERED = "a Function constructor, an eval, or a classic script";

// eslint-disable-next-line no-new-func
const write = new Function("target", "key", "value",
  "const returned = (target[key] = value); return { returned, threw: null };");

export function assign(target, key, value) {
  return { ...write(target, key, value), actual: target[key] };
}
TMS/modes/strict.mjs
// Strict mode, which is what a module body already is.
//
// The same line of source as the sloppy unit runs here and raises instead.
export const MODE = "strict";
export const REPORTS_VIOLATIONS = true;
export const HOW_IT_IS_ENTERED = "a module, a class body, or an explicit directive";

export function assign(target, key, value) {
  try {
    const returned = (target[key] = value);
    return { returned, threw: null, actual: target[key] };
  } catch (raised) {
    return { returned: null, threw: `${raised.constructor.name}: ${raised.message}`, actual: target[key] };
  }
}

DMS

DMS/report.mjs
// What a person is shown.
//
// The three columns are never printed apart. `returned` alone says the write
// worked; `actual` alone says it did not; `threw` alone says whether anybody
// was told. Any one of them is a different answer to the same question.
const pad = (value, width) => String(value).padEnd(width);

export function writes(rows) {
  const lines = ["    object        mode     returned   actual   threw"];
  for (const row of rows) {
    lines.push(`    ${pad(row.object, 13)} ${pad(row.mode, 8)} ${pad(row.returned ?? "-", 10)} `
      + `${pad(row.actual, 8)} ${row.threw ?? "-"}`);
  }
  return lines.join("\n");
}

export function shallow(before, after) {
  return [
    `    frozen({ inner: { price: ${before} } }), then inner.price = 999`,
    `    under both modes the nested value is now ${after}`,
  ].join("\n");
}

root

island_test.mjs
// The island test, run against the real built-ins.
//
//   node src/island_test.mjs
//
// Section 3 is the control. Section 3b is the drill: a mode that DECLARES it
// reports violations while swallowing them must be caught by running it.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import * as upstream from "./SMS/upstream.mjs";
import * as sloppy from "./TMS/modes/sloppy.mjs";
import * as strict from "./TMS/modes/strict.mjs";

const here = path.dirname(fileURLToPath(import.meta.url));
const FMS = JSON.parse(fs.readFileSync(path.join(here, "FMS", "architecture.json"), "utf8"));
const POLICY = JSON.parse(fs.readFileSync(path.join(here, "SCL", "policy.json"), "utf8"));
const failures = [];
let ran = 0;
const check = (label, ok, detail = "") => {
  ran += 1;
  process.stdout.write(`  ${ok ? "PASS" : "FAIL"}  ${label}${detail ? ` - ${detail}` : ""}\n`);
  if (!ok) failures.push(label);
};
const say = (line = "") => process.stdout.write(`${line}\n`);

say(`\n  ${upstream.runtime()}`);

say("\n== 1. each mode is an island, and FMS matches the tree");
for (const [unit, declared] of Object.entries(FMS.units)) {
  const dir = path.join(here, ...unit.split("/"));
  const onDisk = fs.readdirSync(dir).filter((n) => n.endsWith(".mjs")).sort();
  check(`${unit}: FMS declares what is on disk`,
    JSON.stringify(onDisk) === JSON.stringify([...declared].sort()),
    `disk ${onDisk.join(", ")} | FMS ${[...declared].sort().join(", ")}`);
  for (const file of onDisk) {
    const body = fs.readFileSync(path.join(dir, file), "utf8");
    check(`${unit}/${file} imports nothing at all`, !/^\s*import\s/m.test(body));
  }
}
check("both modes declare whether they report violations",
  [sloppy, strict].every((m) => typeof m.REPORTS_VIOLATIONS === "boolean"));
check("and how they are entered", [sloppy, strict].every((m) => Boolean(m.HOW_IT_IS_ENTERED)));

say("\n== 2. the same assignment against the same frozen object");
const viaSloppy = upstream.through(sloppy, upstream.frozen());
const viaStrict = upstream.through(strict, upstream.frozen());
check("sloppy raised nothing", viaSloppy.threw === null);
check("and the object kept its old value", viaSloppy.actual === 100);
check("but the expression evaluated to the new one", viaSloppy.returned === 999);
check("so `returned` and `actual` disagree about the same write",
  viaSloppy.returned !== viaSloppy.actual, `${viaSloppy.returned} vs ${viaSloppy.actual}`);
check("strict raised a TypeError for the identical line", /^TypeError/.test(viaStrict.threw ?? ""),
  viaStrict.threw ?? "");
check("and its object kept the old value too", viaStrict.actual === 100);
check("so the object agrees across modes and only the report differs",
  viaSloppy.actual === viaStrict.actual && (viaSloppy.threw === null) !== (viaStrict.threw === null));

say("\n== 3. the control - the same assignment against an object never frozen");
const openSloppy = upstream.through(sloppy, upstream.neverFrozen());
const openStrict = upstream.through(strict, upstream.neverFrozen());
check("it raises nothing under either mode", openSloppy.threw === null && openStrict.threw === null);
check("the write lands", openSloppy.actual === 999 && openStrict.actual === 999);
check("and it also returns 999 - the control", openSloppy.returned === 999);
check("so `returned` is the same in the success and in the silent failure",
  openSloppy.returned === viaSloppy.returned,
  `${openSloppy.returned} both times, actual ${openSloppy.actual} vs ${viaSloppy.actual}`);
check("which is what makes `returned` uninformative rather than merely wrong",
  openSloppy.returned === viaSloppy.returned && openSloppy.actual !== viaSloppy.actual);

say("\n== 3b. DRILL - a mode that overclaims must be caught by running it");
const liar = {
  MODE: "drill-liar",
  REPORTS_VIOLATIONS: true, // the declaration
  assign(target, key, value) { // the implementation, which swallows
    try { const returned = (target[key] = value); return { returned, threw: null, actual: target[key] }; }
    catch { return { returned: null, threw: null, actual: target[key] }; }
  },
};
check("the drill unit declares it reports violations", liar.REPORTS_VIOLATIONS === true);
check("running it says otherwise", upstream.reportsViolations(liar) === false);
check("so the declaration is refused", upstream.reportsViolations(liar) !== liar.REPORTS_VIOLATIONS);
check("and the honest declarations hold under the same probe",
  upstream.reportsViolations(sloppy) === sloppy.REPORTS_VIOLATIONS
  && upstream.reportsViolations(strict) === strict.REPORTS_VIOLATIONS);

say("\n== 4. the declarer was never given a way to say what a violation means");
check("Object.freeze takes one argument", upstream.freezeArity() === 1, `${upstream.freezeArity()}`);
check("and isFrozen answers the same under either mode", Object.isFrozen(upstream.frozen()));
check("so the object cannot tell which consumer it reached",
  FMS.modes.sloppy.how_it_is_entered.includes("none of which the declarer chose"));
check("the two entrances are different constructs, not a setting",
  sloppy.HOW_IT_IS_ENTERED !== strict.HOW_IT_IS_ENTERED);

say("\n== 5. the declaration is shallower than it reads");
const nested = upstream.nested();
check("the outer object is frozen", Object.isFrozen(nested));
check("the inner one is not", !Object.isFrozen(nested.inner));
const nestedStrict = strict.assign(nested.inner, "price", 999);
check("so a strict-mode write to the inner object raises nothing", nestedStrict.threw === null);
check("and it lands", nested.inner.price === 999);
check("which is a failure of the declaration, not of either mode",
  nestedStrict.threw === null && viaStrict.threw !== null);

say("\n== 6. what this does not change");
check("freezing is right - the object did keep its value under both modes",
  viaSloppy.actual === 100 && viaStrict.actual === 100);
check("sloppy mode cannot be fixed retroactively - it is what unmarked code means",
  sloppy.HOW_IT_IS_ENTERED.includes("classic script"));
check("SCL names what it cannot reach", POLICY.what_this_cannot_reach.includes("Function constructor"));
check("and this deployment does refuse", POLICY.a_silent_violation_is === "fatal");

say("");
if (failures.length > 0) {
  say(`  ${failures.length} FAILED: ${failures.join(" | ")}`);
  process.exitCode = 1;
} else {
  say(`  ${ran} checks passed - every probe ran the real built-ins`);
}
main.mjs
// One frozen object, two consumers, and a declaration that means different
// things to each of them.
//
//   node src/main.mjs            what each mode does with the same assignment
//   node src/main.mjs --strict   exit 1 if a silent violation is possible here
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import * as report from "./DMS/report.mjs";
import * as upstream from "./SMS/upstream.mjs";
import * as sloppy from "./TMS/modes/sloppy.mjs";
import * as strict from "./TMS/modes/strict.mjs";

const here = path.dirname(fileURLToPath(import.meta.url));
const POLICY = JSON.parse(fs.readFileSync(path.join(here, "SCL", "policy.json"), "utf8"));
const MODES = { sloppy, strict };
const say = (line = "") => process.stdout.write(`${line}\n`);

function main(argv) {
  say(`\n  ${upstream.runtime()}`);
  say(`  ${POLICY.deployment}: consumers loaded in ${POLICY.mode} mode\n`);

  const rows = [
    { object: "frozen", ...upstream.through(sloppy, upstream.frozen()) },
    { object: "frozen", ...upstream.through(strict, upstream.frozen()) },
    { object: "never frozen", ...upstream.through(sloppy, upstream.neverFrozen()) },
    { object: "never frozen", ...upstream.through(strict, upstream.neverFrozen()) },
  ];
  say(report.writes(rows));
  say("");
  say("  Row 1 is the finding: the expression evaluated to 999 and the object kept 100.");
  say("  Rows 3 and 4 are the control - a never-frozen object returns 999 too, which is");
  say("  what makes `returned` uninformative rather than merely wrong.\n");

  say(`  Object.freeze.length = ${upstream.freezeArity()} - one argument, the object.`);
  say("  There is no second argument for what a violation means, so the declarer");
  say("  cannot state it and cannot see which consumer it got.\n");

  const nested = upstream.nested();
  const before = nested.inner.price;
  for (const mode of [sloppy, strict]) mode.assign(nested.inner, "price", 999);
  say("  and the declaration is shallower than it reads:");
  say(report.shallow(before, nested.inner.price));

  const silentPossible = !upstream.reportsViolations(MODES.sloppy);
  if (argv.includes("--strict") && silentPossible && POLICY.a_silent_violation_is === "fatal") {
    say(`\n  --strict: a sloppy-mode consumer swallows the violation and this deployment`);
    say(`  calls that fatal. ${POLICY.what_this_cannot_reach}`);
    return 1;
  }
  return 0;
}

process.exitCode = main(process.argv.slice(2));