From a76889983df291dfae96a737c2252c1b20c06a30 Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Fri, 2 Oct 2026 10:40:03 +0100 Subject: [PATCH] [NO-TICKET] Stop map --upload crashing the build on a map with an undefined field The input-map identity refused any property whose value is undefined, while the upload's JSON.stringify simply omits it. The identity now omits it too, so the digest names the document the server receives. An identity failure no longer fails the build: the map uploads unbound, as detect-only. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/input-map-id.ts | 8 ++++++-- src/map-command.ts | 20 ++++++++++++++++---- tests/input-map-id.test.ts | 16 ++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/input-map-id.ts b/src/input-map-id.ts index c63d8e64..9a5051e2 100644 --- a/src/input-map-id.ts +++ b/src/input-map-id.ts @@ -22,7 +22,9 @@ function canonicalString(value: string): string { * * Objects sort keys as text; arrays retain order; strings escape only quote, backslash and control * characters; numbers are safe integers written in decimal. The bound avoids language-specific float - * and large-integer spellings. Empty objects stay distinct from empty arrays. + * and large-integer spellings. Empty objects stay distinct from empty arrays. An object property whose + * value is `undefined` is absent, as it is in the uploaded JSON body, so the digest names the document + * the server receives. */ function canonicalText(value: unknown): string { if (value === null) return 'null'; @@ -39,7 +41,9 @@ function canonicalText(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonicalText).join(',')}]`; if (value !== null && typeof value === 'object') { const record = value as Record; - const keys = Object.keys(record).sort(); + const keys = Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort(); return `{${keys.map((key) => `${canonicalString(key)}:${canonicalText(record[key])}`).join(',')}}`; } diff --git a/src/map-command.ts b/src/map-command.ts index ff8dd35c..4862c5b8 100644 --- a/src/map-command.ts +++ b/src/map-command.ts @@ -7,7 +7,7 @@ import { isProvenFlow } from './map/coordinates.js'; import { type Flags, getStringFlag } from './flags.js'; import { applyBuildStamp } from './build-stamp.js'; import { isPreBundleBuildHook } from './build-hook.js'; -import { inputMapBuildId } from './input-map-id.js'; +import { inputMapBuildId, NonCanonicalInputMap } from './input-map-id.js'; import { atomicWriteFileSync } from './safe-file.js'; import type { Config } from './types.js'; @@ -138,9 +138,21 @@ export async function runMapDetailed(flags: Flags, options: MapOptions = {}): Pr // identity of THIS map into the imported rules file. A standalone manual map remains unbound. let buildId: string | null = null; if (options.setup || isPreBundleBuildHook()) { - const candidate = inputMapBuildId(map); - const stamp = applyBuildStamp(cwd, candidate); - if (stamp.kind === 'stamped' || stamp.kind === 'unchanged') { + let candidate: string | null = null; + let identityError: string | null = null; + try { + candidate = inputMapBuildId(map); + } catch (err) { + if (!(err instanceof NonCanonicalInputMap)) throw err; + identityError = err.message; + } + const stamp = candidate === null ? null : applyBuildStamp(cwd, candidate); + if (candidate === null || stamp === null) { + log( + `patchstack: could not bind this map to the runtime guard — ${identityError}. ` + + 'Rules generated from these coordinates will detect only, not block.', + ); + } else if (stamp.kind === 'stamped' || stamp.kind === 'unchanged') { buildId = candidate; log(`patchstack: bound this map to ${stamp.file} (${candidate.slice(0, 12)}).`); } else { diff --git a/tests/input-map-id.test.ts b/tests/input-map-id.test.ts index 1025221b..e68a543c 100644 --- a/tests/input-map-id.test.ts +++ b/tests/input-map-id.test.ts @@ -54,6 +54,22 @@ describe('the input-map identity', () => { ); }); + it('identifies the uploaded JSON form, where an undefined property is absent', () => { + const map = { version: 3, endpoints: [{ file: 'src/server.ts', inputs: [] }], coverage: { filesParsed: 2 } }; + const withUndefined = { + ...map, + endpoints: [{ ...map.endpoints[0], route: undefined }], + coverage: { ...map.coverage, note: undefined }, + }; + + expect(inputMapBuildId(withUndefined)).toBe(inputMapBuildId(map)); + expect(inputMapBuildId(withUndefined)).toBe(inputMapBuildId(JSON.parse(JSON.stringify(withUndefined)))); + }); + + it('refuses an undefined array element, which the upload would rewrite as null', () => { + expect(() => inputMapBuildId({ version: 3, endpoints: [undefined] })).toThrow(NonCanonicalInputMap); + }); + it('excludes the transport field that carries the digest', () => { const map = { version: 3, endpoints: [], coverage: {} };