Skip to content

Relocate parts of makeImmutable to lib/util.js#875

Merged
jdmarshall merged 1 commit into
node-config:masterfrom
jdmarshall:moveFunctions
Feb 15, 2026
Merged

Relocate parts of makeImmutable to lib/util.js#875
jdmarshall merged 1 commit into
node-config:masterfrom
jdmarshall:moveFunctions

Conversation

@jdmarshall
Copy link
Copy Markdown
Collaborator

@jdmarshall jdmarshall commented Feb 10, 2026

This PR builds on top of #864

The second commit relocates all but the selective immutability code and puts it into Util.

A subsequent PR will close a substantial bug in how makeImmutable works. IMO, #865 is a bug, so it will not receive a warning or a year grace period.

Summary by CodeRabbit

  • New Features

    • Added comprehensive utility method for object immutability with recursive support for nested objects and arrays.
    • Introduced method for selective property-level immutability.
  • Deprecations

    • Existing immutability method marked as deprecated; users should migrate to new utility method.
  • Tests

    • Added test coverage for immutability functionality including nested structures and edge cases.

@jdmarshall jdmarshall requested a review from markstos February 10, 2026 04:17
@jdmarshall
Copy link
Copy Markdown
Collaborator Author

Reference #871

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR refactors object immutability handling by introducing Util.makeImmutable() as a centralized method for recursive object freezing, deprecates ConfigUtils.makeImmutable(), introduces ConfigUtils.makeImmutablePartial() for partial immutability, and adds comprehensive tests for the new functionality.

Changes

Cohort / File(s) Summary
Util immutability implementation
lib/util.js
Adds new public static Util.makeImmutable(object) method that recursively freezes objects with Proxy-based mutation prevention, RawConfig resolution, nested array/object handling, and property immutability via Object.defineProperty.
Config deprecation and refactoring
lib/config.js
Deprecates ConfigUtils.makeImmutable(), introduces new public ConfigUtils.makeImmutablePartial() for partial immutability, updates internal calls to delegate to Util.makeImmutable(), and simplifies property collection logic while maintaining Object.preventExtensions enforcement.
Test suite
test/0-util.js
Adds comprehensive test coverage for Util.makeImmutable() validating mutation prevention on nested properties, field additions, array items, and idempotent behavior across multiple invocations.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • lorenwest
  • markstos

Poem

🐰 A rabbit hops through config trees,
Making objects immutable with ease,
From Util's hand, the freezing flows,
No mutations now—the safeguard glows! ❄️

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: relocating makeImmutable functionality to lib/util.js, which is the primary objective reflected in the file changes.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into master

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@lib/config.js`:
- Around line 116-128: The internal call from Config.get to make the config
immutable is triggering the user-facing deprecation in Util.errorOnce; change
the internal path so it does not fire the deprecation by adding an
internal/suppress flag or a private method on ConfigUtils (e.g.
ConfigUtils.makeImmutable(suppressDeprecation=true) or
ConfigUtils._makeImmutable) and update the call in get() (the invocation
this.util.makeImmutable()) to use the internal/suppress variant; then update
ConfigUtils.makeImmutable to only call Util.errorOnce("MAKEIMMUTABLE", ...) when
the suppress flag is false (and remove the object === undefined branch as
noted). Ensure references: get, LOAD_SYMBOL/initParam, this.util.makeImmutable,
ConfigUtils.makeImmutable, and Util.errorOnce are adjusted accordingly.

In `@lib/util.js`:
- Around line 137-208: makeImmutable currently unconditionally calls
Object.defineProperty for RawConfig and array branches causing a "Cannot
redefine property" on repeated calls; update the RawConfig handling (value
instanceof RawConfig) and the Array.isArray branch to first get the property's
descriptor via Object.getOwnPropertyDescriptor(object, propertyName) and only
call Object.defineProperty when the descriptor is absent or
writable/configurable are not already false (same guard logic used later in the
else branch). Keep the existing behavior of resolving RawConfig and freezing
arrays but wrap those Object.defineProperty calls with the descriptor check so
makeImmutable is idempotent for top-level array and RawConfig properties.

In `@test/0-util.js`:
- Around line 131-133: Update the test description to match what is being
asserted: change the it(...) description string that currently reads "The
makeHidden() method is available" to refer to makeImmutable (e.g., "The
makeImmutable() method is available") so the sentence corresponds to the
asserted symbol util.makeImmutable in the test case.
🧹 Nitpick comments (7)
lib/util.js (3)

159-161: Missing writable and configurable in defineProperty for arrays.

When only value is specified in Object.defineProperty and the property already exists as a data descriptor, writable and configurable default to false only for new property definitions. For existing properties being redefined, they default to the existing values. Explicitly setting writable: false, configurable: false here (matching the other branches) would make the intent clear and avoid subtle inconsistencies.

Proposed fix
         Object.defineProperty(object, propertyName, {
-          value: Object.freeze(value)
+          value: Object.freeze(value),
+          writable: false,
+          configurable: false
         });

167-190: Proxy get trap uses hasOwnProperty — consider Object.hasOwn or Object.prototype.hasOwnProperty.call.

Line 171 uses target.hasOwnProperty(property). If the target object was created with Object.create(null), it won't have hasOwnProperty. Config objects are unlikely to hit this, but defensive coding would use Object.prototype.hasOwnProperty.call(target, property) or the more modern Object.hasOwn(target, property).


913-918: Load.clone() double-merges the env object.

The constructor new Load(this.options, newEnv) sets copy.env = newEnv (already cloned). Then Util.extendDeep(copy, this) iterates this's enumerable properties including env, which merges the original's env data into the already-cloned copy. This is redundant (same data) but wasteful. Consider excluding env from the extend, or simplifying:

Alternative approach
   clone() {
     const newEnv = this.env.clone();
-    const copy = new Load(this.options, newEnv);
-
-    return Util.extendDeep(copy, this);
+    const copy = new Load(this.options, newEnv);
+    copy.config = Util.cloneDeep(this.config);
+    copy.sources = this.sources ? [...this.sources] : undefined;
+    copy.defaults = this.defaults ? Util.cloneDeep(this.defaults) : undefined;
+    copy.unmerged = this.unmerged ? Util.cloneDeep(this.unmerged) : undefined;
+    return copy;
   }
lib/config.js (4)

294-309: Double Object.preventExtensions call.

Util.makeImmutable(object) at line 304 already calls Object.preventExtensions(object) at the end (lib/util.js line 205). Line 305 calls it again. Harmless but redundant.

Remove the redundant call
     } else {
       Util.makeImmutable(object);
-      Object.preventExtensions(object);

       return object;
     }

342-429: Significant code duplication between makeImmutablePartial and Util.makeImmutable.

The proxy creation logic, descriptor checks, array handling, and RawConfig handling in makeImmutablePartial (lines 360-422) are near-identical to Util.makeImmutable (lib/util.js lines 142-203). The only difference is that makeImmutablePartial operates on a subset of properties rather than all keys.

Consider extracting the shared per-property immutability logic into a helper on Util that both paths can call, reducing the maintenance burden of keeping two copies in sync.


382-392: Unnecessary array spread and O(n) includes in proxy get trap.

Line 384-386 creates a new array from Object.getOwnPropertyNames(target) via spread, then uses includes() for lookup — both are O(n) per property access. In contrast, Util.makeImmutable uses target.hasOwnProperty(property) which is O(1). For consistency and performance:

Align with Util.makeImmutable
             get(target, property, receiver) {
-              const ownProps = [
-                ...Object.getOwnPropertyNames(target),
-              ]
-
-              // Bypass proxy receiver for properties directly on the target (e.g., RegExp.prototype.source)
-              // or properties that are not functions to prevent errors related to internal object methods.
-              if (ownProps.includes(property) || (property in target && typeof target[property] !== 'function')) {
+              if (Object.prototype.hasOwnProperty.call(target, property) || (property in target && typeof target[property] !== 'function')) {
                 return Reflect.get(target, property);
               }

540-567: attachProtoDeep silently skips attaching if a property with the same name exists.

Line 553: if (!toObject[fnName]) — if a config key happens to be named get, has, or util, the prototype method won't be attached and that sub-object's .get() / .has() will silently behave differently. This is pre-existing behavior, but worth noting as a potential footgun for users with these config key names.

Comment thread lib/config.js
Comment thread lib/util.js
Comment thread test/0-util.js Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@lib/config.js`:
- Around line 537-553: The attachProtoDeep method currently skips attaching
prototype helpers when the target object has truthy properties named 'get',
'has', 'util', or LOAD_SYMBOL, causing user keys to shadow methods; update the
conditional inside the for-loop that iterates over ['get','has','util',
LOAD_SYMBOL] to attach the hidden method when the existing property is not a
function (e.g., use typeof to check for 'function') so that real methods are
always installed via Util.makeHidden; reference attachProtoDeep, RawConfig,
Util.makeHidden and the fnName check to locate and update the logic, and add a
brief comment explaining the typeof-based guard to prevent silent shadowing.
- Around line 339-426: Refactor makeImmutablePartial to reuse Util.makeImmutable
instead of duplicating logic: for each property in makeImmutablePartial, if the
value is a RawConfig call value.resolve(), if it's an array call
Util.makeImmutable on any object/array items then Object.freeze the array,
otherwise if Util.isObject(value) call Util.makeImmutable(value) (removing the
inline Proxy/descriptor recursion), then apply the same descriptor guard (check
Object.getOwnPropertyDescriptor and only call Object.defineProperty with
writable:false, configurable:false when needed) and retain the final
Object.preventExtensions(object); replace the duplicated
proxy/descriptor/recursion blocks with calls to Util.makeImmutable to ensure
single-source immutability behavior.
- Around line 339-426: The makeImmutablePartial function unconditionally calls
Object.defineProperty in the RawConfig and Array.isArray branches which can
throw on repeated calls; update those branches (the RawConfig branch that
handles value instanceof RawConfig and the Array.isArray(value) branch) to first
retrieve the current descriptor via Object.getOwnPropertyDescriptor(object,
propertyName) and only call Object.defineProperty if no descriptor exists or its
writable/configurable are not already false (same guard used later in
makeImmutablePartial), ensuring idempotent behavior.

In `@test/0-util.js`:
- Around line 159-161: The Array branch in Util.makeImmutable currently defines
array properties with only a frozen value, leaving the descriptor
writable/configurable; update the Object.defineProperty call inside
Util.makeImmutable's array-handling branch to include writable: false and
configurable: false (so the property descriptor matches the RawConfig and object
branches) for the propertyName/value pair to enforce immutability.
🧹 Nitpick comments (3)
lib/util.js (3)

167-173: Avoid calling hasOwnProperty directly on the target object.

target.hasOwnProperty(property) can throw or return incorrect results if the target has a null prototype or overrides hasOwnProperty. Use Object.prototype.hasOwnProperty.call(target, property) or Object.hasOwn(target, property) (Node ≥ 16.9).

Proposed fix
-              if (target.hasOwnProperty(property) || (property in target && typeof target[property] !== 'function')) {
+              if (Object.hasOwn(target, property) || (property in target && typeof target[property] !== 'function')) {

753-760: errorOnce looks correct; minor style nit.

new Error; on line 756 works but parentheses (new Error()) are conventional. Otherwise, the one-time logging approach is sound for its intended purpose.


909-918: Load.clone() — cloned env gets re-merged by extendDeep.

extendDeep(copy, this) will recursively merge this.env into copy.env, even though copy was already constructed with a fresh newEnv. The deep-merge effectively duplicates the work of this.env.clone(). This isn't buggy today because extendDeep merges object properties rather than replacing the reference, but it's fragile — if Env ever gains non-plain-object state (methods storing closures, symbols, etc.), the merge could introduce subtle issues.

Consider explicitly skipping env in the extend, or simplifying to only extend the fields that need copying (config, sources, defaults, unmerged).

Comment thread lib/config.js
Comment thread lib/config.js
Comment thread test/0-util.js
@jdmarshall jdmarshall force-pushed the moveFunctions branch 2 times, most recently from 53cab6a to dcb3a4f Compare February 10, 2026 21:28
Copy link
Copy Markdown
Collaborator

@markstos markstos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this as well.

… compatibility functions.

Deprecating use of property enumeration.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/config.js (1)

332-341: ⚠️ Potential issue | 🟡 Minor

makeImmutablePartial with a string property doesn't call Object.preventExtensions.

When property is a string (line 335), the method returns early at line 336-340 via return Object.defineProperty(...), skipping Object.preventExtensions at line 418. This may be intentional for single-property immutability, but it's inconsistent with the array-of-properties path which always calls preventExtensions.

🤖 Fix all issues with AI agents
In `@lib/util.js`:
- Around line 153-163: The array branch in makeImmutable leaves the property
descriptor writable/configurable true by only passing value to
Object.defineProperty, so update the Object.defineProperty call in makeImmutable
(the block handling Array.isArray(value)) to set writable: false and
configurable: false (matching the RawConfig and scalar/object branches) while
still freezing the array contents with Object.freeze(value); this ensures the
property cannot be reassigned after makeImmutable is called.
🧹 Nitpick comments (1)
lib/util.js (1)

207-207: Nit: missing semicolon.

Consistent with the rest of the codebase, consider adding a semicolon after Object.preventExtensions(object).

Proposed fix
-    Object.preventExtensions(object)
+    Object.preventExtensions(object);

Comment thread lib/util.js
@jdmarshall jdmarshall merged commit f6b9fe3 into node-config:master Feb 15, 2026
2 checks passed
@jdmarshall jdmarshall deleted the moveFunctions branch February 26, 2026 01:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants