Skip to content

Commit ed6b6f3

Browse files
committed
fix: prune deselected workspace dirs from Canton root package.json
In Canton custom/default mode, deselecting a feature whose paths point at an npm workspace dir (today: carpincho -> carpincho-wallet) removed the dir and stripped its scripts but left a dangling entry in the root package.json workspaces array. patchPackageJsonCanton now prunes any workspaces entry that points at a removed dir, keyed off the same removedDirs as the script stripping. Handles both the string[] and { packages } forms. Also makes the Canton test fixture faithful (it omitted workspaces entirely, which is why the bug shipped despite the carpincho-deselected case being tested) and asserts the invariant generally rather than for carpincho-wallet. Refs #8
1 parent f1a72cf commit ed6b6f3

2 files changed

Lines changed: 149 additions & 24 deletions

File tree

source/__tests__/operations/cleanupFiles.test.ts

Lines changed: 122 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { resolve } from 'node:path'
22
import { beforeEach, describe, expect, it, vi } from 'vitest'
3-
import type { FeatureName } from '../../constants/config.js'
3+
import { type FeatureName, getStackConfig } from '../../constants/config.js'
44

55
vi.mock('node:fs/promises', () => ({
66
rm: vi.fn().mockResolvedValue(undefined),
@@ -58,6 +58,30 @@ function getWrittenPackageJson(): Record<string, unknown> {
5858
return JSON.parse(lastCall[1] as string)
5959
}
6060

61+
// Reads the workspaces field in either form (string[] or { packages }).
62+
function getWorkspacePackages(pkg: Record<string, unknown>): string[] {
63+
const workspaces = pkg.workspaces
64+
65+
if (Array.isArray(workspaces)) {
66+
return workspaces as string[]
67+
}
68+
69+
const packages = (workspaces as { packages?: unknown } | undefined)?.packages
70+
return Array.isArray(packages) ? (packages as string[]) : []
71+
}
72+
73+
// Dirs removed for a selection, derived from config — keeps the invariant config-driven instead of
74+
// hardcoding carpincho-wallet.
75+
function removedCantonDirs(selected: FeatureName[]): string[] {
76+
return Object.entries(getStackConfig('canton').features)
77+
.filter(([name, definition]) => !selected.includes(name) && (definition.paths?.length ?? 0) > 0)
78+
.flatMap(([, definition]) => definition.paths as string[])
79+
}
80+
81+
function entryTargetsRemovedDir(entry: string, removedDirs: string[]): boolean {
82+
return removedDirs.some((dir) => entry === dir || entry.startsWith(`${dir}/`))
83+
}
84+
6185
function mockEvmPackageJson() {
6286
vi.mocked(readFileSync).mockReturnValue(
6387
JSON.stringify({
@@ -75,31 +99,53 @@ function mockEvmPackageJson() {
7599
)
76100
}
77101

78-
// Mirrors the real root package.json on BootNodeDev/cn-dappbooster@main.
79-
function mockCantonPackageJson() {
102+
// Mirrors cn-dappbooster@main's root package.json, including the workspaces array — carpincho-wallet
103+
// is a workspace, so deselecting carpincho must prune it.
104+
const CANTON_WORKSPACES = [
105+
'canton-connect-kit',
106+
'carpincho-wallet',
107+
'canton-barebones',
108+
'canton-barebones/wallet-service',
109+
'dapp/daml',
110+
'dapp/e2e',
111+
'dapp/frontend',
112+
]
113+
114+
const CANTON_SCRIPTS = {
115+
'canton:up': 'npm --prefix canton-barebones run up',
116+
'canton:down': 'npm --prefix canton-barebones run down',
117+
'canton:health': 'npm --prefix canton-barebones run health',
118+
'canton:token': 'npm --prefix canton-barebones run token',
119+
'build-dar': 'bash scripts/build-dar.sh',
120+
'deploy-dar': 'bash canton-barebones/scripts/deploy-dar.sh',
121+
'wallet:dev': 'npm --prefix carpincho-wallet run dev',
122+
'wallet-service:dev': 'npm --prefix canton-barebones/wallet-service run dev',
123+
'wallet-service:health': 'curl -fsS http://localhost:3010/health',
124+
'carpincho:build:extension': 'npm --prefix carpincho-wallet run build:extension',
125+
'app:dev': 'npm --prefix dapp/frontend run dev -- --host localhost --port 3012 --strictPort',
126+
lint: 'biome check',
127+
'lint:fix': 'biome check --write',
128+
format: 'biome format --write',
129+
e2e: 'npm --prefix dapp/e2e test',
130+
'e2e:headed': 'npm --prefix dapp/e2e run test:headed',
131+
'e2e:ui': 'npm --prefix dapp/e2e run test:ui',
132+
prepare: 'husky',
133+
}
134+
135+
const CANTON_DEV_DEPS = {
136+
husky: '^9.1.7',
137+
'lint-staged': '^17.0.4',
138+
'@commitlint/cli': '^21.0.1',
139+
'@commitlint/config-conventional': '^21.0.1',
140+
}
141+
142+
// Pass { packages } to exercise the object form; defaults to the string[] form.
143+
function mockCantonPackageJson(workspaces: unknown = CANTON_WORKSPACES) {
80144
vi.mocked(readFileSync).mockReturnValue(
81145
JSON.stringify({
82-
scripts: {
83-
'canton:up': 'npm --prefix canton-barebones run up',
84-
'canton:down': 'npm --prefix canton-barebones run down',
85-
'build-dar': 'bash scripts/build-dar.sh',
86-
'deploy-dar': 'bash canton-barebones/scripts/deploy-dar.sh',
87-
'wallet:dev': 'npm --prefix carpincho-wallet run dev',
88-
'wallet-service:dev': 'npm --prefix canton-barebones/wallet-service run dev',
89-
'carpincho:build:extension': 'npm --prefix carpincho-wallet run build:extension',
90-
'app:dev':
91-
'npm --prefix dapp/frontend run dev -- --host localhost --port 3012 --strictPort',
92-
lint: 'biome check',
93-
e2e: 'npm --prefix dapp/e2e test',
94-
'e2e:headed': 'npm --prefix dapp/e2e run test:headed',
95-
prepare: 'husky',
96-
},
97-
devDependencies: {
98-
husky: '^9.1.7',
99-
'lint-staged': '^17.0.4',
100-
'@commitlint/cli': '^21.0.1',
101-
'@commitlint/config-conventional': '^21.0.1',
102-
},
146+
workspaces,
147+
scripts: CANTON_SCRIPTS,
148+
devDependencies: CANTON_DEV_DEPS,
103149
}),
104150
)
105151
}
@@ -411,6 +457,12 @@ describe('cleanupFiles — canton', () => {
411457
expect(execFile).toHaveBeenCalledWith('git', ['add', '.'], { cwd: '/project/my_app' })
412458
})
413459

460+
it('keeps the full workspaces array (nothing removed)', async () => {
461+
await cleanupFiles('canton', '/project/my_app', 'full')
462+
463+
expect(getWorkspacePackages(getWrittenPackageJson())).toEqual(CANTON_WORKSPACES)
464+
})
465+
414466
it('makes the initial commit with --no-verify so kept project hooks cannot block it', async () => {
415467
await cleanupFiles('canton', '/project/my_app', 'full')
416468

@@ -496,6 +548,41 @@ describe('cleanupFiles — canton', () => {
496548
expect(scripts['wallet:dev']).toBeUndefined()
497549
expect(scripts['carpincho:build:extension']).toBeUndefined()
498550
})
551+
552+
it('prunes carpincho-wallet from the workspaces array but keeps the rest', async () => {
553+
await cleanupFiles('canton', '/project/my_app', 'custom', ['github', 'precommit', 'llm'])
554+
555+
const workspaces = getWorkspacePackages(getWrittenPackageJson())
556+
expect(workspaces).not.toContain('carpincho-wallet')
557+
expect(workspaces).toContain('canton-barebones')
558+
expect(workspaces).toContain('dapp/frontend')
559+
})
560+
561+
// The invariant the bug violated — asserted generally, not just for carpincho-wallet.
562+
it('leaves no workspace entry pointing at a removed directory', async () => {
563+
const selected: FeatureName[] = ['github', 'precommit', 'llm']
564+
await cleanupFiles('canton', '/project/my_app', 'custom', selected)
565+
566+
const removedDirs = removedCantonDirs(selected)
567+
const workspaces = getWorkspacePackages(getWrittenPackageJson())
568+
for (const entry of workspaces) {
569+
expect(entryTargetsRemovedDir(entry, removedDirs)).toBe(false)
570+
}
571+
// Guard against the array being emptied wholesale.
572+
expect(workspaces.length).toBeGreaterThan(0)
573+
})
574+
575+
it('prunes the { packages } object form and preserves sibling keys', async () => {
576+
mockCantonPackageJson({ packages: CANTON_WORKSPACES, nohoist: ['**/react'] })
577+
await cleanupFiles('canton', '/project/my_app', 'custom', ['github', 'precommit', 'llm'])
578+
579+
const written = getWrittenPackageJson()
580+
expect(Array.isArray(written.workspaces)).toBe(false)
581+
const workspaces = written.workspaces as { packages: string[]; nohoist: string[] }
582+
expect(workspaces.packages).not.toContain('carpincho-wallet')
583+
expect(workspaces.packages).toContain('dapp/frontend')
584+
expect(workspaces.nohoist).toEqual(['**/react'])
585+
})
499586
})
500587

501588
describe('custom mode — llm deselected', () => {
@@ -512,6 +599,17 @@ describe('cleanupFiles — canton', () => {
512599
expect(paths).toContain(resolve('/project/my_app', 'architecture.md'))
513600
expect(paths).toContain(resolve('/project/my_app', 'llms.txt'))
514601
})
602+
603+
// llm's paths aren't workspaces — removing it must not touch the array.
604+
it('leaves the workspaces array intact (its paths are not workspaces)', async () => {
605+
await cleanupFiles('canton', '/project/my_app', 'custom', [
606+
'github',
607+
'precommit',
608+
'carpincho',
609+
])
610+
611+
expect(getWorkspacePackages(getWrittenPackageJson())).toEqual(CANTON_WORKSPACES)
612+
})
515613
})
516614

517615
describe('onProgress callback', () => {

source/operations/cleanupFiles.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,30 @@ function scriptTargetsRemovedDir(command: string, removedDirs: string[]): boolea
164164
)
165165
}
166166

167+
function workspaceEntryRemoved(entry: string, removedDirs: string[]): boolean {
168+
return removedDirs.some((dir) => entry === dir || entry.startsWith(`${dir}/`))
169+
}
170+
171+
// Drop workspaces entries pointing at a removed dir. Handles both the string[] and { packages }
172+
// forms (other object keys preserved); mutates in place.
173+
function pruneRemovedWorkspaces(
174+
packageJson: { workspaces?: string[] | { packages?: string[] } },
175+
removedDirs: string[],
176+
): void {
177+
if (removedDirs.length === 0) {
178+
return
179+
}
180+
181+
const { workspaces } = packageJson
182+
const keep = (entry: string): boolean => !workspaceEntryRemoved(entry, removedDirs)
183+
184+
if (Array.isArray(workspaces)) {
185+
packageJson.workspaces = workspaces.filter(keep)
186+
} else if (workspaces && Array.isArray(workspaces.packages)) {
187+
workspaces.packages = workspaces.packages.filter(keep)
188+
}
189+
}
190+
167191
function patchPackageJsonCanton(
168192
projectFolder: string,
169193
removedDirs: string[],
@@ -181,6 +205,9 @@ function patchPackageJsonCanton(
181205
}
182206
}
183207

208+
// Removed dirs also drop out of the workspaces array, else the manifest lists a missing dir.
209+
pruneRemovedWorkspaces(packageJson, removedDirs)
210+
184211
// The husky tooling (prepare/commitlint scripts + husky/lint-staged/commitlint deps) only leaves
185212
// with the pre-commit feature.
186213
if (precommitRemoved) {

0 commit comments

Comments
 (0)