diff --git a/src/input-map-id.ts b/src/input-map-id.ts index c63d8e6..9a5051e 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 ff8dd35..4862c5b 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 1025221..e68a543 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: {} };