Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/input-map-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, unknown>;
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(',')}}`;
}
Expand Down
20 changes: 16 additions & 4 deletions src/map-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions tests/input-map-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} };

Expand Down
Loading