Skip to content

Commit f817812

Browse files
sorccuclaude
andauthored
feat(cli): include workspace package.json files in code bundle cache hash (#1288)
Extend the cache hash to cover every workspace package.json (root and member packages) in addition to the lockfile. Each package.json is canonicalized (parsed, top-level "version" stripped, re-encoded with sorted keys) before hashing, so cosmetic or CI-driven version bumps don't invalidate the cache. The hash composition mirrors the byte layout used by terraform-provider-checkly so identical inputs produce identical digests in both codebases. Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent ba862ad commit f817812

3 files changed

Lines changed: 459 additions & 13 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { createHash } from 'node:crypto'
2+
3+
import { describe, test, expect } from 'vitest'
4+
5+
import {
6+
canonicalizePackageJson,
7+
composeCacheHash,
8+
stableJsonEncode,
9+
} from '../cache-hash'
10+
11+
const sha256 = (s: string): Buffer => createHash('sha256').update(s).digest()
12+
13+
const buf = (s: string): Buffer => Buffer.from(s, 'utf8')
14+
15+
describe('stableJsonEncode', () => {
16+
test('sorts object keys', () => {
17+
expect(stableJsonEncode({ b: 1, a: 2 })).toBe('{"a":2,"b":1}')
18+
})
19+
20+
test('produces identical output regardless of source key order', () => {
21+
expect(stableJsonEncode({ a: 1, b: { d: 4, c: 3 } }))
22+
.toBe(stableJsonEncode({ b: { c: 3, d: 4 }, a: 1 }))
23+
})
24+
25+
test('escapes HTML-significant characters', () => {
26+
expect(stableJsonEncode('<&>')).toBe('"\\u003c\\u0026\\u003e"')
27+
})
28+
29+
test('escapes U+2028 and U+2029', () => {
30+
expect(stableJsonEncode('\u2028\u2029')).toBe('"\\u2028\\u2029"')
31+
})
32+
33+
test('escapes control characters', () => {
34+
expect(stableJsonEncode('\u0000\u0001')).toBe('"\\u0000\\u0001"')
35+
expect(stableJsonEncode('\b\t\n\f\r')).toBe('"\\b\\t\\n\\f\\r"')
36+
})
37+
38+
test('escapes quotes and backslashes', () => {
39+
expect(stableJsonEncode('a"b\\c')).toBe('"a\\"b\\\\c"')
40+
})
41+
42+
test('encodes primitives', () => {
43+
expect(stableJsonEncode(null)).toBe('null')
44+
expect(stableJsonEncode(true)).toBe('true')
45+
expect(stableJsonEncode(false)).toBe('false')
46+
expect(stableJsonEncode(42)).toBe('42')
47+
})
48+
49+
test('encodes arrays preserving order', () => {
50+
expect(stableJsonEncode([3, 1, 2])).toBe('[3,1,2]')
51+
})
52+
53+
test('rejects non-finite numbers', () => {
54+
expect(() => stableJsonEncode(NaN)).toThrow()
55+
expect(() => stableJsonEncode(Infinity)).toThrow()
56+
})
57+
})
58+
59+
describe('canonicalizePackageJson', () => {
60+
test('removes excluded top-level fields', () => {
61+
const raw = buf('{"name":"x","version":"1.0.0"}')
62+
expect(canonicalizePackageJson(raw, ['version']).toString('utf8'))
63+
.toBe('{"name":"x"}')
64+
})
65+
66+
test('produces identical output regardless of source key order or whitespace', () => {
67+
const a = buf('{"name":"x","version":"1.0.0","main":"index.js"}')
68+
const b = buf('{\n "main": "index.js",\n "version": "9.9.9",\n "name": "x"\n}\n')
69+
expect(canonicalizePackageJson(a, ['version']))
70+
.toEqual(canonicalizePackageJson(b, ['version']))
71+
})
72+
73+
test('only excludes top-level fields, not nested ones', () => {
74+
const raw = buf('{"name":"x","version":"1.0.0","scripts":{"version":"echo 1"}}')
75+
const canonical = canonicalizePackageJson(raw, ['version']).toString('utf8')
76+
expect(canonical).toBe('{"name":"x","scripts":{"version":"echo 1"}}')
77+
})
78+
79+
test('rejects non-object top-level values', () => {
80+
expect(() => canonicalizePackageJson(buf('[]'), [])).toThrow()
81+
expect(() => canonicalizePackageJson(buf('null'), [])).toThrow()
82+
expect(() => canonicalizePackageJson(buf('"x"'), [])).toThrow()
83+
})
84+
})
85+
86+
describe('composeCacheHash', () => {
87+
test('bumping the excluded version field does not change the hash', () => {
88+
const v1 = buf('{"name":"x","version":"1.0.0"}')
89+
const v2 = buf('{"name":"x","version":"2.0.0-deadbeef"}')
90+
const lockfile = { name: 'package-lock.json', hash: sha256('lock') }
91+
expect(composeCacheHash({
92+
lockfile,
93+
packageJsons: [{ path: 'package.json', raw: v1 }],
94+
excludedFields: ['version'],
95+
})).toBe(composeCacheHash({
96+
lockfile,
97+
packageJsons: [{ path: 'package.json', raw: v2 }],
98+
excludedFields: ['version'],
99+
}))
100+
})
101+
102+
test('changing name or dependencies changes the hash', () => {
103+
const a = buf('{"name":"a","version":"1.0.0"}')
104+
const b = buf('{"name":"b","version":"1.0.0"}')
105+
const lockfile = { name: 'package-lock.json', hash: sha256('lock') }
106+
expect(composeCacheHash({
107+
lockfile,
108+
packageJsons: [{ path: 'package.json', raw: a }],
109+
excludedFields: ['version'],
110+
})).not.toBe(composeCacheHash({
111+
lockfile,
112+
packageJsons: [{ path: 'package.json', raw: b }],
113+
excludedFields: ['version'],
114+
}))
115+
})
116+
117+
test('source key reordering does not change the hash', () => {
118+
const a = buf('{"name":"x","version":"1.0.0","dependencies":{"a":"1","b":"2"}}')
119+
const b = buf('{"dependencies":{"b":"2","a":"1"},"version":"1.0.0","name":"x"}')
120+
const lockfile = { name: 'package-lock.json', hash: sha256('lock') }
121+
expect(composeCacheHash({
122+
lockfile,
123+
packageJsons: [{ path: 'package.json', raw: a }],
124+
excludedFields: ['version'],
125+
})).toBe(composeCacheHash({
126+
lockfile,
127+
packageJsons: [{ path: 'package.json', raw: b }],
128+
excludedFields: ['version'],
129+
}))
130+
})
131+
132+
test('adding a workspace package changes the hash', () => {
133+
const root = buf('{"name":"root","version":"1.0.0"}')
134+
const member = buf('{"name":"member","version":"1.0.0"}')
135+
const lockfile = { name: 'package-lock.json', hash: sha256('lock') }
136+
const without = composeCacheHash({
137+
lockfile,
138+
packageJsons: [{ path: 'package.json', raw: root }],
139+
excludedFields: ['version'],
140+
})
141+
const withMember = composeCacheHash({
142+
lockfile,
143+
packageJsons: [
144+
{ path: 'package.json', raw: root },
145+
{ path: 'packages/member/package.json', raw: member },
146+
],
147+
excludedFields: ['version'],
148+
})
149+
expect(without).not.toBe(withMember)
150+
})
151+
152+
test('lockfile content change changes the hash', () => {
153+
const root = buf('{"name":"root"}')
154+
const a = composeCacheHash({
155+
lockfile: { name: 'package-lock.json', hash: sha256('lock-a') },
156+
packageJsons: [{ path: 'package.json', raw: root }],
157+
excludedFields: ['version'],
158+
})
159+
const b = composeCacheHash({
160+
lockfile: { name: 'package-lock.json', hash: sha256('lock-b') },
161+
packageJsons: [{ path: 'package.json', raw: root }],
162+
excludedFields: ['version'],
163+
})
164+
expect(a).not.toBe(b)
165+
})
166+
167+
test('input order of packageJsons does not affect the hash', () => {
168+
const root = buf('{"name":"root"}')
169+
const a = buf('{"name":"a"}')
170+
const b = buf('{"name":"b"}')
171+
const lockfile = { name: 'package-lock.json', hash: sha256('lock') }
172+
expect(composeCacheHash({
173+
lockfile,
174+
packageJsons: [
175+
{ path: 'packages/b/package.json', raw: b },
176+
{ path: 'package.json', raw: root },
177+
{ path: 'packages/a/package.json', raw: a },
178+
],
179+
excludedFields: ['version'],
180+
})).toBe(composeCacheHash({
181+
lockfile,
182+
packageJsons: [
183+
{ path: 'package.json', raw: root },
184+
{ path: 'packages/a/package.json', raw: a },
185+
{ path: 'packages/b/package.json', raw: b },
186+
],
187+
excludedFields: ['version'],
188+
}))
189+
})
190+
191+
// Cross-language parity fixture. The same lockfile + package.json bytes
192+
// and the same excluded fields must produce this exact digest in
193+
// terraform-provider-checkly's composeBundleChecksum. If you change this
194+
// fixture, mirror the change in the TF provider's test suite.
195+
test('matches the cross-language parity fixture digest', () => {
196+
const lockfileBytes = buf('{"lockfileVersion":3}\n')
197+
const rootPackageJson = buf([
198+
'{',
199+
' "name": "fixture-root",',
200+
' "version": "0.0.0-SNAPSHOT",',
201+
' "private": true,',
202+
' "workspaces": ["packages/*"],',
203+
' "devDependencies": {',
204+
' "@playwright/test": "1.50.0"',
205+
' }',
206+
'}',
207+
'',
208+
].join('\n'))
209+
const memberPackageJson = buf([
210+
'{',
211+
' "name": "@fixture/member",',
212+
' "version": "9.9.9",',
213+
' "main": "index.js",',
214+
' "dependencies": {',
215+
' "lodash": "^4.17.21"',
216+
' }',
217+
'}',
218+
'',
219+
].join('\n'))
220+
221+
const digest = composeCacheHash({
222+
lockfile: {
223+
name: 'package-lock.json',
224+
hash: createHash('sha256').update(lockfileBytes).digest(),
225+
},
226+
packageJsons: [
227+
{ path: 'package.json', raw: rootPackageJson },
228+
{ path: 'packages/member/package.json', raw: memberPackageJson },
229+
],
230+
excludedFields: ['version'],
231+
})
232+
233+
expect(digest).toBe('4d3072de5db2f0f8a5e29b72013dd7e4dfb25686023931ee98050d58ba4503f8')
234+
})
235+
})

packages/cli/src/services/check-parser/bundler.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { createHash } from 'node:crypto'
21
import { createReadStream, createWriteStream, WriteStream } from 'node:fs'
32
import fs from 'node:fs/promises'
43
import { tmpdir } from 'node:os'
@@ -10,6 +9,7 @@ import Debug from 'debug'
109
import * as uuid from 'uuid'
1110

1211
import { checklyStorage } from '../../rest/api'
12+
import { computeWorkspaceCacheHash } from './cache-hash'
1313
import { File } from './parser'
1414
import { Workspace } from './package-files/workspace'
1515

@@ -262,11 +262,7 @@ export class Bundler {
262262
tempDir,
263263
} = options
264264

265-
const cacheHashFile = workspace.lockfile.isOk()
266-
? workspace.lockfile.ok()
267-
: workspace.root.packageJsonPath
268-
269-
const cacheHash = await getCacheHash(cacheHashFile)
265+
const cacheHash = await computeWorkspaceCacheHash(workspace)
270266

271267
return new Bundler({
272268
tempDir,
@@ -333,13 +329,6 @@ async function createArchiver (): Promise<Archiver> {
333329
}
334330
}
335331

336-
export async function getCacheHash (lockFile: string): Promise<string> {
337-
const fileBuffer = await fs.readFile(lockFile)
338-
const hash = createHash('sha256')
339-
hash.update(fileBuffer)
340-
return hash.digest('hex')
341-
}
342-
343332
export class BundlePathMarker {
344333
#value: string
345334

0 commit comments

Comments
 (0)