From 0393ee6356558f1afeaa361288cc5f4b78c362a5 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Sun, 5 Jul 2026 01:39:50 -0400 Subject: [PATCH] fix(selfhost): use own-property check for retired config-lint fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unknownTopLevelWarnings classified retired vs unknown top-level manifest fields with `key in RETIRED_FIELD_MIGRATION_WARNINGS`, which walks the prototype chain. A manifest field named like an Object.prototype member (constructor, toString, hasOwnProperty, valueOf, ...) therefore tested true for the inherited property and resolved to the prototype's function instead of a real warning string — corrupting the string[] result (the function serializes to null over JSON) and suppressing the genuine unknown-field warning for that suspicious key. Use Object.prototype.hasOwnProperty.call, matching the sibling recognizedFieldsFor in the same file. Adds a regression test for a constructor-named field. --- src/selfhost/config-lint.ts | 9 +++++++-- test/unit/selfhost-config-lint.test.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/selfhost/config-lint.ts b/src/selfhost/config-lint.ts index 2ff0af08ef..c063745f8a 100644 --- a/src/selfhost/config-lint.ts +++ b/src/selfhost/config-lint.ts @@ -73,8 +73,13 @@ function unknownTopLevelWarnings(text: string | null | undefined): string[] { const parsed = parseTopLevelObject(trimmed); if (parsed === null) return []; const keys = Object.keys(parsed).filter((key) => !TOP_LEVEL_FIELD_SET.has(key)); - const retiredWarnings = keys.filter((key) => key in RETIRED_FIELD_MIGRATION_WARNINGS).map((key) => RETIRED_FIELD_MIGRATION_WARNINGS[key]!); - const unknown = keys.filter((key) => !(key in RETIRED_FIELD_MIGRATION_WARNINGS)).map(formatFieldName); + // `hasOwnProperty.call`, NOT `key in`: a manifest field named like an Object.prototype member + // (`constructor`, `toString`, `hasOwnProperty`, ...) would otherwise test true for the inherited + // property and resolve to the prototype's function instead of a real retired-field warning string, + // corrupting the string[] result and suppressing the genuine unknown-field warning. + const isRetired = (key: string): boolean => Object.prototype.hasOwnProperty.call(RETIRED_FIELD_MIGRATION_WARNINGS, key); + const retiredWarnings = keys.filter(isRetired).map((key) => RETIRED_FIELD_MIGRATION_WARNINGS[key]!); + const unknown = keys.filter((key) => !isRetired(key)).map(formatFieldName); return [ ...retiredWarnings, ...(unknown.length > 0 ? [`Manifest contains unknown top-level field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}.`] : []), diff --git a/test/unit/selfhost-config-lint.test.ts b/test/unit/selfhost-config-lint.test.ts index 87c1d4d4cb..9ddb8297b8 100644 --- a/test/unit/selfhost-config-lint.test.ts +++ b/test/unit/selfhost-config-lint.test.ts @@ -159,6 +159,16 @@ unknownSecretKey: super-secret-value expect(JSON.stringify(result)).not.toContain("/tmp/private"); }); + it("treats a field named like an Object.prototype member as unknown, not retired", () => { + const result = lintManifestText("wantedPaths: [src/]\nconstructor: whatever\n"); + + expect(result.ok).toBe(false); + expect(result.recognizedFields).toEqual(["wantedPaths"]); + expect(result.warnings).toEqual(["Manifest contains unknown top-level field: constructor."]); + // Every warning must be a real string — a prototype-name collision must never leak a function. + expect(result.warnings.every((warning) => typeof warning === "string")).toBe(true); + }); + it("uses singular wording for one unknown top-level field", () => { const result = lintManifestText("wantedPaths: [src/]\nunknownSecretKey: super-secret-value\n");